diff --git a/README.md b/README.md index 35092e76..06f879a7 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ - An episode note in Obsidian with timestamp links captured while the episode plays in PodNotes + An episode note in Obsidian with timestamp links captured from the episode in PodNotes

## What you can do -- **Listen beside your notes.** Search Apple Podcasts or paste any RSS feed, then play audio and video with speed control, a queue, and playlists. +- **Listen beside your notes.** Search Apple Podcasts or paste any RSS feed, then play audio and video with chapters, the feed's transcript, speed control, a queue, and playlists. - **Capture the moment.** One command inserts a timestamp link, such as `00:12:41`, into your note. Select it later to play from that second. - **Create a note for any episode.** A template fills in the show, date, description, and a link that resumes playback. Browse the notes as a table with Obsidian Bases. - **Take it with you.** PodNotes works on iOS and Android. Download episodes for offline listening, or play your own audio and video files. -- **Transcribe episodes.** Use your own OpenAI API key, or a Deepgram key when you turn on speaker labels and choose Deepgram. +- **Transcribe episodes.** Save the transcript a podcast publishes for free, or transcribe the audio with your own OpenAI or Deepgram key. - **Build your own workflows.** Use the commands, the JavaScript API, and QuickAdd. ## Get started @@ -40,8 +40,9 @@ PodNotes needs Obsidian 1.13 or later. 1. In Obsidian, open **Settings → Community plugins**. If you see **Exit Restricted mode**, select it. Select **Browse**, search for PodNotes, then select **Install** and **Enable**. -2. In **Settings → PodNotes**, search for a show or paste its RSS feed URL, then select **Add**. -3. From the command palette, run **PodNotes: Show player**, then select the show and an episode to play it. In a note in editing view, run **PodNotes: Capture Timestamp**. +2. In the PodNotes view, which opens in the right sidebar, search for a show or paste its RSS feed URL, then select the show. +3. On the show's page, select an episode to play it. +4. In a note in editing view, run **PodNotes: Capture Timestamp**, or select **Timestamp** in the player. [Getting started](https://podnotes.obsidian.guide/getting-started/) walks through each step. diff --git a/docs/README.md b/docs/README.md index fe4e2d6f..44d68015 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,30 +31,37 @@ plugin's dependencies. - `public/` holds static assets served as-is. Older images live in `public/resources/` and stay for existing links. -## Screenshots +## Screenshots and clips -Every screenshot in `public/screenshots/` comes from one script. From the repo -root, after `npm run build`: +Every screenshot in `public/screenshots/` and every clip in `public/videos/` +comes from a script that drives this worktree's isolated Obsidian (see +AGENTS.md) with the verify skill's screens tooling and its fixture podcasts. +From the repo root: ```sh +npm run build && npm run start:e2e-obsidian node docs/scripts/screenshots.mjs +node docs/scripts/videos.mjs ``` -It builds a vault from `screenshots/` (the notes, a Bases file, the PodNotes -settings in `seed.json`, and the Minimal theme), opens it in a private Obsidian -capture instance at 2x, adds the podcast feeds through Settings, plays the -episodes, creates notes and captures timestamps with PodNotes' own commands, and -writes each shot as `.webp` and `-dark.webp`, with a -`-phone.webp` crop where the desktop shot is unreadable on a phone, plus -the README demo -GIFs and the social preview `og.png`. It needs network access for the feeds, -their artwork, and the theme. The episodes in `seed.json` must still be among -each feed's latest episodes. If one has left, pick a newer one and update its -notes in `screenshots/notes/`. +`screenshots.mjs` copies the notes in `screenshots/vault/` into the vault, +creates episode notes and captures timestamps with PodNotes' own commands, as +`screenshots/seed.json` plans them, and writes each shot at 2x as +`.webp` and `-dark.webp`. Where the desktop shot is unreadable on a +phone, it adds a `-phone.webp` crop. It also writes the README demo GIFs +and the social preview `og.png`. + +`videos.mjs` records each clip with real mouse or touch input at 2x, and writes +`.mp4`, a `.webp` poster, and `-dark` twins for pages whose +screenshots have them. Pass clip names to record only those, such as +`node docs/scripts/videos.mjs transcript`. To show a screenshot on a page, write `![What it shows](/screenshots/.webp)`. The site adds the size, lazy -loading, the dark twin, and the phone crop if there is one. +loading, the dark twin, and the phone crop if there is one. To show a clip, +write `![What it shows](/videos/.mp4)` after a sentence that says what +the clip shows. The site plays it muted on a loop, or shows its poster with +controls to readers who prefer reduced motion. ## Brand diff --git a/docs/STYLE.md b/docs/STYLE.md index e44f3864..632a253f 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -85,6 +85,17 @@ other than the home page. captured in Obsidian's phone layout. The site shows it on narrow screens. - [ ] Alt text says what the screenshot shows in under 150 characters. +## Clips + +- [ ] A clip shows a task of a few steps in 10 to 20 seconds. The steps stay in + the text. +- [ ] `docs/scripts/videos.mjs` records every clip from the same fixtures. + Never add one by hand. Rerun the script after a UI change. +- [ ] One sentence before the clip says what it shows, and its alt text names + it in under 150 characters. Alt text leaves out times such as 38:28, + because the site's directive syntax drops the `:28` from an alt. +- [ ] Each clip is under about 1.5 MB. + ## Before you merge - [ ] `npm run docs:build` passes. It checks links, legacy fragments, and that diff --git a/docs/public/screenshots/add-podcast-dark.webp b/docs/public/screenshots/add-podcast-dark.webp index 984def11..77f199ce 100644 Binary files a/docs/public/screenshots/add-podcast-dark.webp and b/docs/public/screenshots/add-podcast-dark.webp differ diff --git a/docs/public/screenshots/add-podcast.webp b/docs/public/screenshots/add-podcast.webp index 49d0924e..a10bbf7e 100644 Binary files a/docs/public/screenshots/add-podcast.webp and b/docs/public/screenshots/add-podcast.webp differ diff --git a/docs/public/screenshots/bases-dark.webp b/docs/public/screenshots/bases-dark.webp index c39a0507..40baf77b 100644 Binary files a/docs/public/screenshots/bases-dark.webp and b/docs/public/screenshots/bases-dark.webp differ diff --git a/docs/public/screenshots/bases-phone-dark.webp b/docs/public/screenshots/bases-phone-dark.webp index 2daf4486..c824ebe9 100644 Binary files a/docs/public/screenshots/bases-phone-dark.webp and b/docs/public/screenshots/bases-phone-dark.webp differ diff --git a/docs/public/screenshots/bases-phone.webp b/docs/public/screenshots/bases-phone.webp index e70ead12..6f9342b3 100644 Binary files a/docs/public/screenshots/bases-phone.webp and b/docs/public/screenshots/bases-phone.webp differ diff --git a/docs/public/screenshots/bases.webp b/docs/public/screenshots/bases.webp index 281fe6b6..a14514f6 100644 Binary files a/docs/public/screenshots/bases.webp and b/docs/public/screenshots/bases.webp differ diff --git a/docs/public/screenshots/demo-dark.gif b/docs/public/screenshots/demo-dark.gif index cd7d9ee5..51a4ad38 100644 Binary files a/docs/public/screenshots/demo-dark.gif and b/docs/public/screenshots/demo-dark.gif differ diff --git a/docs/public/screenshots/demo.gif b/docs/public/screenshots/demo.gif index 622bcf2b..70bc9d61 100644 Binary files a/docs/public/screenshots/demo.gif and b/docs/public/screenshots/demo.gif differ diff --git a/docs/public/screenshots/episode-list-dark.webp b/docs/public/screenshots/episode-list-dark.webp index 61fdbb85..caba1be3 100644 Binary files a/docs/public/screenshots/episode-list-dark.webp and b/docs/public/screenshots/episode-list-dark.webp differ diff --git a/docs/public/screenshots/episode-list.webp b/docs/public/screenshots/episode-list.webp index c38fbdae..5765821f 100644 Binary files a/docs/public/screenshots/episode-list.webp and b/docs/public/screenshots/episode-list.webp differ diff --git a/docs/public/screenshots/episode-note-dark.webp b/docs/public/screenshots/episode-note-dark.webp index 7512c97a..4c3fb3e1 100644 Binary files a/docs/public/screenshots/episode-note-dark.webp and b/docs/public/screenshots/episode-note-dark.webp differ diff --git a/docs/public/screenshots/episode-note-phone-dark.webp b/docs/public/screenshots/episode-note-phone-dark.webp index 8cdc1190..3234d6a8 100644 Binary files a/docs/public/screenshots/episode-note-phone-dark.webp and b/docs/public/screenshots/episode-note-phone-dark.webp differ diff --git a/docs/public/screenshots/episode-note-phone.webp b/docs/public/screenshots/episode-note-phone.webp index da1b9aab..59034841 100644 Binary files a/docs/public/screenshots/episode-note-phone.webp and b/docs/public/screenshots/episode-note-phone.webp differ diff --git a/docs/public/screenshots/episode-note.webp b/docs/public/screenshots/episode-note.webp index 240a7835..f173c880 100644 Binary files a/docs/public/screenshots/episode-note.webp and b/docs/public/screenshots/episode-note.webp differ diff --git a/docs/public/screenshots/hero-dark.webp b/docs/public/screenshots/hero-dark.webp index 6b0cbcb0..eb1bf7b1 100644 Binary files a/docs/public/screenshots/hero-dark.webp and b/docs/public/screenshots/hero-dark.webp differ diff --git a/docs/public/screenshots/hero-phone-dark.webp b/docs/public/screenshots/hero-phone-dark.webp index 17107a08..b51e811f 100644 Binary files a/docs/public/screenshots/hero-phone-dark.webp and b/docs/public/screenshots/hero-phone-dark.webp differ diff --git a/docs/public/screenshots/hero-phone.webp b/docs/public/screenshots/hero-phone.webp index f05b4703..593b71d5 100644 Binary files a/docs/public/screenshots/hero-phone.webp and b/docs/public/screenshots/hero-phone.webp differ diff --git a/docs/public/screenshots/hero.webp b/docs/public/screenshots/hero.webp index a878b780..b5c4f9e5 100644 Binary files a/docs/public/screenshots/hero.webp and b/docs/public/screenshots/hero.webp differ diff --git a/docs/public/screenshots/mobile-player-dark.webp b/docs/public/screenshots/mobile-player-dark.webp index 34d04803..3642b69c 100644 Binary files a/docs/public/screenshots/mobile-player-dark.webp and b/docs/public/screenshots/mobile-player-dark.webp differ diff --git a/docs/public/screenshots/mobile-player.webp b/docs/public/screenshots/mobile-player.webp index eb2913f7..d5dadc4b 100644 Binary files a/docs/public/screenshots/mobile-player.webp and b/docs/public/screenshots/mobile-player.webp differ diff --git a/docs/public/screenshots/mobile-toolbar-dark.webp b/docs/public/screenshots/mobile-toolbar-dark.webp index ec01e747..9c30b13f 100644 Binary files a/docs/public/screenshots/mobile-toolbar-dark.webp and b/docs/public/screenshots/mobile-toolbar-dark.webp differ diff --git a/docs/public/screenshots/mobile-toolbar.webp b/docs/public/screenshots/mobile-toolbar.webp index c2498f93..e4c689c4 100644 Binary files a/docs/public/screenshots/mobile-toolbar.webp and b/docs/public/screenshots/mobile-toolbar.webp differ diff --git a/docs/public/screenshots/og.png b/docs/public/screenshots/og.png index e0bb71aa..76444d8c 100644 Binary files a/docs/public/screenshots/og.png and b/docs/public/screenshots/og.png differ diff --git a/docs/public/screenshots/player-dark.webp b/docs/public/screenshots/player-dark.webp index 2bc81c2c..e9d76abb 100644 Binary files a/docs/public/screenshots/player-dark.webp and b/docs/public/screenshots/player-dark.webp differ diff --git a/docs/public/screenshots/player.webp b/docs/public/screenshots/player.webp index 1e4ff05c..71142bb6 100644 Binary files a/docs/public/screenshots/player.webp and b/docs/public/screenshots/player.webp differ diff --git a/docs/public/screenshots/podcast-grid-dark.webp b/docs/public/screenshots/podcast-grid-dark.webp index 97c8b69a..12aa682e 100644 Binary files a/docs/public/screenshots/podcast-grid-dark.webp and b/docs/public/screenshots/podcast-grid-dark.webp differ diff --git a/docs/public/screenshots/podcast-grid.webp b/docs/public/screenshots/podcast-grid.webp index 5c668b11..348d3739 100644 Binary files a/docs/public/screenshots/podcast-grid.webp and b/docs/public/screenshots/podcast-grid.webp differ diff --git a/docs/public/screenshots/queue-dark.webp b/docs/public/screenshots/queue-dark.webp new file mode 100644 index 00000000..d3363a14 Binary files /dev/null and b/docs/public/screenshots/queue-dark.webp differ diff --git a/docs/public/screenshots/queue.webp b/docs/public/screenshots/queue.webp new file mode 100644 index 00000000..0c404275 Binary files /dev/null and b/docs/public/screenshots/queue.webp differ diff --git a/docs/public/screenshots/timestamps-dark.webp b/docs/public/screenshots/timestamps-dark.webp index 2bfa1ada..a6814f98 100644 Binary files a/docs/public/screenshots/timestamps-dark.webp and b/docs/public/screenshots/timestamps-dark.webp differ diff --git a/docs/public/screenshots/timestamps-phone-dark.webp b/docs/public/screenshots/timestamps-phone-dark.webp index 4cea6cf4..14d3c06d 100644 Binary files a/docs/public/screenshots/timestamps-phone-dark.webp and b/docs/public/screenshots/timestamps-phone-dark.webp differ diff --git a/docs/public/screenshots/timestamps-phone.webp b/docs/public/screenshots/timestamps-phone.webp index 9ca6943b..7f229309 100644 Binary files a/docs/public/screenshots/timestamps-phone.webp and b/docs/public/screenshots/timestamps-phone.webp differ diff --git a/docs/public/screenshots/timestamps.webp b/docs/public/screenshots/timestamps.webp index 3315976c..f33c8c02 100644 Binary files a/docs/public/screenshots/timestamps.webp and b/docs/public/screenshots/timestamps.webp differ diff --git a/docs/public/screenshots/transcript-dark.webp b/docs/public/screenshots/transcript-dark.webp new file mode 100644 index 00000000..e164ba2b Binary files /dev/null and b/docs/public/screenshots/transcript-dark.webp differ diff --git a/docs/public/screenshots/transcript-phone-dark.webp b/docs/public/screenshots/transcript-phone-dark.webp new file mode 100644 index 00000000..18c9ae8a Binary files /dev/null and b/docs/public/screenshots/transcript-phone-dark.webp differ diff --git a/docs/public/screenshots/transcript-phone.webp b/docs/public/screenshots/transcript-phone.webp new file mode 100644 index 00000000..11ddcbb4 Binary files /dev/null and b/docs/public/screenshots/transcript-phone.webp differ diff --git a/docs/public/screenshots/transcript.webp b/docs/public/screenshots/transcript.webp new file mode 100644 index 00000000..04e9cb23 Binary files /dev/null and b/docs/public/screenshots/transcript.webp differ diff --git a/docs/public/videos/add-and-play-dark.mp4 b/docs/public/videos/add-and-play-dark.mp4 new file mode 100644 index 00000000..1e7aa392 Binary files /dev/null and b/docs/public/videos/add-and-play-dark.mp4 differ diff --git a/docs/public/videos/add-and-play-dark.webp b/docs/public/videos/add-and-play-dark.webp new file mode 100644 index 00000000..7f6cfad8 Binary files /dev/null and b/docs/public/videos/add-and-play-dark.webp differ diff --git a/docs/public/videos/add-and-play.mp4 b/docs/public/videos/add-and-play.mp4 new file mode 100644 index 00000000..0012b101 Binary files /dev/null and b/docs/public/videos/add-and-play.mp4 differ diff --git a/docs/public/videos/add-and-play.webp b/docs/public/videos/add-and-play.webp new file mode 100644 index 00000000..5dc9bec2 Binary files /dev/null and b/docs/public/videos/add-and-play.webp differ diff --git a/docs/public/videos/capture-undo-dark.mp4 b/docs/public/videos/capture-undo-dark.mp4 new file mode 100644 index 00000000..2dbc6ced Binary files /dev/null and b/docs/public/videos/capture-undo-dark.mp4 differ diff --git a/docs/public/videos/capture-undo-dark.webp b/docs/public/videos/capture-undo-dark.webp new file mode 100644 index 00000000..39f41a1c Binary files /dev/null and b/docs/public/videos/capture-undo-dark.webp differ diff --git a/docs/public/videos/capture-undo.mp4 b/docs/public/videos/capture-undo.mp4 new file mode 100644 index 00000000..6a459fee Binary files /dev/null and b/docs/public/videos/capture-undo.mp4 differ diff --git a/docs/public/videos/capture-undo.webp b/docs/public/videos/capture-undo.webp new file mode 100644 index 00000000..a6dd5db8 Binary files /dev/null and b/docs/public/videos/capture-undo.webp differ diff --git a/docs/public/videos/queue-reorder-dark.mp4 b/docs/public/videos/queue-reorder-dark.mp4 new file mode 100644 index 00000000..3ae3c249 Binary files /dev/null and b/docs/public/videos/queue-reorder-dark.mp4 differ diff --git a/docs/public/videos/queue-reorder-dark.webp b/docs/public/videos/queue-reorder-dark.webp new file mode 100644 index 00000000..c26af6e8 Binary files /dev/null and b/docs/public/videos/queue-reorder-dark.webp differ diff --git a/docs/public/videos/queue-reorder.mp4 b/docs/public/videos/queue-reorder.mp4 new file mode 100644 index 00000000..71adc79d Binary files /dev/null and b/docs/public/videos/queue-reorder.mp4 differ diff --git a/docs/public/videos/queue-reorder.webp b/docs/public/videos/queue-reorder.webp new file mode 100644 index 00000000..23c86b55 Binary files /dev/null and b/docs/public/videos/queue-reorder.webp differ diff --git a/docs/public/videos/set-feed-address.mp4 b/docs/public/videos/set-feed-address.mp4 new file mode 100644 index 00000000..fa4c9a43 Binary files /dev/null and b/docs/public/videos/set-feed-address.mp4 differ diff --git a/docs/public/videos/set-feed-address.webp b/docs/public/videos/set-feed-address.webp new file mode 100644 index 00000000..02d98fdb Binary files /dev/null and b/docs/public/videos/set-feed-address.webp differ diff --git a/docs/public/videos/transcript-dark.mp4 b/docs/public/videos/transcript-dark.mp4 new file mode 100644 index 00000000..c5b91b08 Binary files /dev/null and b/docs/public/videos/transcript-dark.mp4 differ diff --git a/docs/public/videos/transcript-dark.webp b/docs/public/videos/transcript-dark.webp new file mode 100644 index 00000000..357e2a53 Binary files /dev/null and b/docs/public/videos/transcript-dark.webp differ diff --git a/docs/public/videos/transcript.mp4 b/docs/public/videos/transcript.mp4 new file mode 100644 index 00000000..949f2f51 Binary files /dev/null and b/docs/public/videos/transcript.mp4 differ diff --git a/docs/public/videos/transcript.webp b/docs/public/videos/transcript.webp new file mode 100644 index 00000000..3e30b0b0 Binary files /dev/null and b/docs/public/videos/transcript.webp differ diff --git a/docs/screenshots/notes/memory-palaces.md b/docs/screenshots/notes/memory-palaces.md new file mode 100644 index 00000000..c5d3b720 --- /dev/null +++ b/docs/screenshots/notes/memory-palaces.md @@ -0,0 +1,9 @@ +{{at 0:03:40}} Forgetting is the default. A note decides what I keep. +{{at 0:14:05}} The forgetting curve: most of a new idea is gone within a day unless I come back to it. +{{at 0:27:30}} Memory palace: put each idea in a room of a place I know by heart. +{{at 0:36:12}} Make each image strange. The brain holds on to the absurd. + +**Follow up** + +- Try it with chapter 4 of [[How to Take Smart Notes]] +- Exercise for [[Reading notes that stick]]: walk the room, then recall the argument diff --git a/docs/screenshots/notes/math-vs-machine.md b/docs/screenshots/notes/open-source.md similarity index 100% rename from docs/screenshots/notes/math-vs-machine.md rename to docs/screenshots/notes/open-source.md diff --git a/docs/screenshots/notes/pizza-tracker.md b/docs/screenshots/notes/pizza-tracker.md deleted file mode 100644 index b23b878e..00000000 --- a/docs/screenshots/notes/pizza-tracker.md +++ /dev/null @@ -1,8 +0,0 @@ -{{at 0:02:30}} 2008. Domino's launches the Pizza Tracker: order, oven, out for delivery. -{{at 0:08:45}} The tracker changes how the wait feels, not how long it is. -{{at 0:14:05}} Now the pattern is everywhere: rides, parcels, food. - -**Follow up** - -- Slide idea for [[Designing for the wait]]: one status page per decade -- Compare with the NeXT years in [[The 12 Years that Changed Steve Jobs]] diff --git a/docs/screenshots/notes/slip-box.md b/docs/screenshots/notes/slip-box.md new file mode 100644 index 00000000..46bfb8f1 --- /dev/null +++ b/docs/screenshots/notes/slip-box.md @@ -0,0 +1,7 @@ +{{at 0:04:10}} One idea per card, each with a fixed number, so a card can always point at another. +{{at 0:21:45}} The links matter more than the cards. A card with no link is lost. +{{at 0:48:30}} Forty years and 90,000 cards. He called it his partner in conversation. + +**Follow up** + +- Compare with [[How to Take Smart Notes]], chapter 2 diff --git a/docs/screenshots/notes/steve-jobs.md b/docs/screenshots/notes/steve-jobs.md deleted file mode 100644 index 8ebdb206..00000000 --- a/docs/screenshots/notes/steve-jobs.md +++ /dev/null @@ -1,7 +0,0 @@ -{{at 0:00:51}} Why Jobs was pushed out of Apple in 1985. -{{at 0:08:27}} Starting NeXT. Years of losses, and at one point a weekend away from shutting down. -{{at 0:09:34}} The story behind the NeXT logo. - -**Follow up** - -- Look up Paul Rand's brief for the NeXT logo diff --git a/docs/screenshots/seed.json b/docs/screenshots/seed.json index 5b013d54..8ae33153 100644 --- a/docs/screenshots/seed.json +++ b/docs/screenshots/seed.json @@ -1,82 +1,49 @@ { - "vaultName": "Notes", - "theme": { - "name": "Minimal", - "repo": "kepano/obsidian-minimal", - "version": "9.1.4" - }, - "feeds": [ - "https://feeds.simplecast.com/BqbsxVfO", - "https://feeds.npr.org/510289/podcast.xml", - "https://feeds.simplecast.com/EmVW7VGp", - "https://feeds.megaphone.fm/FSMI7575968096", - "https://feeds.transistor.fm/acquired", - "https://relay.fm/cortex/feed", - "https://feeds.simplecast.com/Y8lFbOT4", - "https://feeds.simplecast.com/6HKOhNgS", - "https://api.substack.com/feed/podcast/10845.rss" - ], - "search": "the daily", - "settings": { - "episodeListLimit": 25 - }, - "noteTemplate": "---\ntype: podcastEpisode\npodcast: \"{{podcastlink}}\"\ndate: {{date:YYYY-MM-DD}}\nduration: \"{{duration}}\"\ntags:\n - podcastEpisode\nstatus:\nrating:\n---\n![|96]({{artwork}})\n\n[Resume in PodNotes]({{episodelink}})\n\n> [!summary]- Episode description\n{{description:> }}\n\n## Notes\n\n", + "noteTemplate": "---\ntype: podcastEpisode\npodcast: \"{{podcastlink}}\"\ndate: {{date:YYYY-MM-DD}}\nduration: \"{{duration}}\"\ntags:\n - podcastEpisode\nstatus:\nrating:\n---\n![|96]({{artwork}})\n\n{{episodelink:Resume in PodNotes}}\n\n> [!summary]- Episode description\n{{description:> }}\n\n## Notes\n\n", "episodes": [ { - "podcast": "Cortex", - "title": "183: Scouting Indie Games for a Living, with Lucy James", + "podcast": "Signal & Noise", + "title": "Ep. 87", + "notes": "open-source.md", "status": "next" }, { - "podcast": "Planet Money", - "title": "The Domino’s Pizza Tracker Theory of Everything", - "notes": "pizza-tracker.md", - "status": "finished", - "rating": 4 + "podcast": "Lunch Break History", + "title": "Medieval monks", + "status": "next" }, { - "podcast": "Acquired", - "title": "The Home Depot", + "podcast": "Slow Thinking", + "title": "Walking as thinking", "status": "finished", "rating": 4 }, { - "podcast": "Radiolab", - "title": "Math Vs Machine", - "notes": "math-vs-machine.md", - "status": "next" + "podcast": "The Archive Hour", + "title": "Luhmann", + "notes": "slip-box.md", + "status": "finished", + "rating": 5 }, { - "podcast": "The Knowledge Project", - "title": "The 12 Years that Changed Steve Jobs", - "notes": "steve-jobs.md", + "podcast": "Field Notes", + "title": "#213", + "notes": "memory-palaces.md", "status": "listening", "rating": 5, - "playAt": 640, - "playbackRate": 1.5 + "rename": "How to remember everything you read", + "playAt": 2294 } ], "feedNotes": [ - "Planet Money", - "The Knowledge Project", - "Acquired", - "Radiolab", - "Cortex" + "Field Notes", + "The Archive Hour", + "Slow Thinking", + "Lunch Break History", + "Signal & Noise" ], - "playlist": { - "name": "Commute", - "icon": "headphones", - "episodes": [ - "A 4-Track Mind", - "Doves Type", - "Ferrari", - "Grass Apes", - "The Rocky Statue" - ] - }, - "maxTitleLength": 24, "demo": { - "at": 698, - "note": "A perfect cube, and why the case took years." + "at": 2287, + "note": "Opportunity cost is a man at the door with two keys. Use it in the talk." } } diff --git a/docs/screenshots/vault/Daily/2026-10-09.md b/docs/screenshots/vault/Daily/2026-10-09.md index c7b12b35..c87201e0 100644 --- a/docs/screenshots/vault/Daily/2026-10-09.md +++ b/docs/screenshots/vault/Daily/2026-10-09.md @@ -1,4 +1,4 @@ # Thursday, October 9 -- Finished the outline for [[Designing for the wait]]. -- Listened to The Knowledge Project on the NeXT years on the walk home. Notes in [[The 12 Years that Changed Steve Jobs]]. +- Finished the outline for [[Reading notes that stick]]. +- Listened to The Archive Hour on Luhmann's slip box on the walk home. Notes in [[Luhmanns slip box forty years later]]. diff --git a/docs/screenshots/vault/Daily/2026-10-10.md b/docs/screenshots/vault/Daily/2026-10-10.md index 42de7fb9..6e4bb8ca 100644 --- a/docs/screenshots/vault/Daily/2026-10-10.md +++ b/docs/screenshots/vault/Daily/2026-10-10.md @@ -1,4 +1,4 @@ # Friday, October 10 - Booked the room for the talk on the 24th. -- Started the Planet Money episode on the pizza tracker. Notes in [[The Domino’s Pizza Tracker Theory of Everything]]. +- Started the Field Notes episode on memory palaces. Notes in [[How to remember everything you read]]. diff --git a/docs/screenshots/vault/Daily/2026-10-11.md b/docs/screenshots/vault/Daily/2026-10-11.md index f9678279..0bf9bdf1 100644 --- a/docs/screenshots/vault/Daily/2026-10-11.md +++ b/docs/screenshots/vault/Daily/2026-10-11.md @@ -1,5 +1,5 @@ # Saturday, October 11 -- [ ] Finish the pizza tracker notes +- [ ] Finish the memory palace notes - [ ] Pick three examples for the slides - Coffee with Maya at 10 diff --git a/docs/screenshots/vault/Home.md b/docs/screenshots/vault/Home.md index 58ddee92..5649be93 100644 --- a/docs/screenshots/vault/Home.md +++ b/docs/screenshots/vault/Home.md @@ -1,6 +1,6 @@ # Home - [[Daily/2026-10-11|Today]] -- [[Projects/Designing for the wait|Designing for the wait]] +- [[Projects/Reading notes that stick|Reading notes that stick]] - [[PodNotes/Episodes.base|Podcast episodes]] -- [[Reading/The Design of Everyday Things|Currently reading]] +- [[Reading/How to Take Smart Notes|Currently reading]] diff --git a/docs/screenshots/vault/Projects/Designing for the wait.md b/docs/screenshots/vault/Projects/Designing for the wait.md deleted file mode 100644 index 97330921..00000000 --- a/docs/screenshots/vault/Projects/Designing for the wait.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -tags: - - project -due: 2026-10-24 ---- -# Designing for the wait - -A 20 minute talk on how products make waiting feel shorter. - -## Examples - -- The pizza tracker. Notes in [[The Domino’s Pizza Tracker Theory of Everything]]. -- Patience as a design skill. See [[The 12 Years that Changed Steve Jobs]]. -- Queued: [[Math Vs Machine]]. - -## Open questions - -- Start with the pizza tracker or end with it? -- How much research before the first example? diff --git a/docs/screenshots/vault/Projects/Reading notes that stick.md b/docs/screenshots/vault/Projects/Reading notes that stick.md new file mode 100644 index 00000000..0625d602 --- /dev/null +++ b/docs/screenshots/vault/Projects/Reading notes that stick.md @@ -0,0 +1,19 @@ +--- +tags: + - project +due: 2026-10-24 +--- +# Reading notes that stick + +A 20 minute talk on how to keep what you read and hear. + +## Examples + +- Memory palaces. Notes in [[How to remember everything you read]]. +- A slip box that grows for decades. See [[Luhmanns slip box forty years later]]. +- Queued: [[Medieval monks and the first sticky notes]]. + +## Open questions + +- Start with the memory palace or end with it? +- How many examples before the first exercise? diff --git a/docs/screenshots/vault/Reading/How to Take Smart Notes.md b/docs/screenshots/vault/Reading/How to Take Smart Notes.md new file mode 100644 index 00000000..bb7f8fce --- /dev/null +++ b/docs/screenshots/vault/Reading/How to Take Smart Notes.md @@ -0,0 +1,11 @@ +--- +author: Sönke Ahrens +status: reading +tags: + - book +--- +# How to Take Smart Notes + +- Write each idea in your own words, one idea per note. +- Compare with the memory palace episode: both turn an idea into something you can find again. +- Chapter 4 next. diff --git a/docs/screenshots/vault/Reading/The Design of Everyday Things.md b/docs/screenshots/vault/Reading/The Design of Everyday Things.md deleted file mode 100644 index 5d69ce70..00000000 --- a/docs/screenshots/vault/Reading/The Design of Everyday Things.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -author: Don Norman -status: reading -tags: - - book ---- -# The Design of Everyday Things - -- Feedback: show people that something is happening. Compare with the pizza tracker. -- Chapter 4 next. diff --git a/docs/scripts/scene.mjs b/docs/scripts/scene.mjs new file mode 100644 index 00000000..e080e4ab --- /dev/null +++ b/docs/scripts/scene.mjs @@ -0,0 +1,124 @@ +// What screenshots.mjs and videos.mjs share: the docs' seed plan, the fixture +// library as the docs show it, episode notes made with PodNotes' own commands, +// and the instance's CDP channel. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execObsidian, obsidianCommandArgs, resolveInstanceOptions } from "obsidian-e2e/runner"; +import { + config, + evaluate, + provision, + root, + waitFor, +} from "../../.claude/skills/verify/screens/instance.mjs"; + +export const seedDir = fileURLToPath(new URL("../screenshots", import.meta.url)); +export const plan = JSON.parse(fs.readFileSync(path.join(seedDir, "seed.json"), "utf8")); +export const instance = resolveInstanceOptions(provision, {}, config, root); +export const API = "app.plugins.plugins.podnotes.api"; + +/** The instance's own CDP channel, the one the screens tooling emulates through. */ +export async function cdp(/** @type {string} */ method, params = {}) { + const { stdout } = await execObsidian( + instance, + obsidianCommandArgs(instance.vaultName, [ + "dev:cdp", + `method=${method}`, + `params=${JSON.stringify(params)}`, + ]), + {}, + { timeout: 15000 }, + ); + if (!stdout.trim().startsWith("{")) throw new Error(`${method}: ${stdout}`); + return JSON.parse(stdout); +} + +/** @param {Awaited>} lib @param {string} podcast @param {string} title */ +export function episode(lib, podcast, title) { + const found = lib.feeds + .find(({ feed }) => feed.title.startsWith(podcast)) + ?.episodes.find((candidate) => candidate.title.startsWith(title)); + if (!found) throw new Error(`The fixture library has no episode "${title}" in ${podcast}.`); + return found; +} + +/** + * The verify skill's listening library without its podcast whose feed answers + * 404, and with the docs' note template. + * @type {import("../../.claude/skills/verify/screens/scenes.mjs").Ingredient} + */ +export function docsLibrary(data, lib) { + const missing = new Set(lib.feeds.filter((feed) => feed.missing).map(({ feed }) => feed.title)); + const kept = (/** @type {{ podcastName: string }} */ item) => !missing.has(item.podcastName); + for (const title of missing) delete data.savedFeeds[title]; + data.queue.episodes = data.queue.episodes.filter(kept); + data.favorites.episodes = data.favorites.episodes.filter(kept); + for (const list of Object.values(data.playlists)) list.episodes = list.episodes.filter(kept); + for (const [key, record] of Object.entries(data.playedEpisodes)) { + if (!kept(record)) delete data.playedEpisodes[key]; + } + data.note.template = plan.noteTemplate; +} + +/** + * Creates the loaded episode's note with Create episode note, fills in its + * properties, and writes its notes, capturing each `{{at h:mm:ss}}` line with + * PodNotes' own capture. + * @param {{ title: string, notes?: string, status: string, rating?: number, rename?: string }} entry + */ +export async function writeNote(entry) { + const before = await evaluate(`return app.workspace.getActiveFile()?.path ?? null;`); + await evaluate(`app.commands.executeCommandById("podnotes:create-podcast-note"); return true;`); + await waitFor( + `const file = app.workspace.getActiveFile()?.path; + return Boolean(file) && file !== ${JSON.stringify(before)} && file.startsWith("PodNotes/");`, + `The note for ${entry.title} did not open`, + ); + await evaluate(` + await app.fileManager.processFrontMatter(app.workspace.getActiveFile(), (fm) => { + fm.status = ${JSON.stringify(entry.status)}; + if (${JSON.stringify(entry.rating ?? null)} !== null) fm.rating = ${JSON.stringify(entry.rating ?? null)}; + }); + return true; + `); + await waitFor( + `return app.workspace.activeEditor?.editor?.getValue().includes(${JSON.stringify(`status: ${entry.status}`)});`, + `The properties of ${entry.title} did not reach the editor`, + ); + const lines = entry.notes + ? fs + .readFileSync(path.join(seedDir, "notes", entry.notes), "utf8") + .trimEnd() + .split("\n") + : []; + for (const line of lines) { + const at = line.match(/^\{\{at (\d+):(\d\d):(\d\d)\}\} (.*)$/); + await evaluate(` + const editor = app.workspace.activeEditor.editor; + editor.focus(); + editor.setCursor(editor.lastLine(), editor.getLine(editor.lastLine()).length); + return true; + `); + if (at) { + const seconds = Number(at[1]) * 3600 + Number(at[2]) * 60 + Number(at[3]); + const result = await evaluate(` + ${API}.currentTime = ${seconds}; + return await app.plugins.plugins.podnotes.capture.timestamp(); + `); + if (result?.destination !== "editor") { + throw new Error(`The capture went to ${JSON.stringify(result)}, not the note.`); + } + } + await evaluate( + `app.workspace.activeEditor.editor.replaceSelection(${JSON.stringify(`${at ? at[4] : line}\n`)}); return true;`, + ); + } + await evaluate(`await app.workspace.activeEditor.save(); return true;`); + if (!entry.rename) return await evaluate(`return app.workspace.getActiveFile().path;`); + return await evaluate(` + const file = app.workspace.getActiveFile(); + await app.fileManager.renameFile(file, file.parent.path + "/" + ${JSON.stringify(`${entry.rename}.md`)}); + return file.path; + `); +} diff --git a/docs/scripts/screenshots.mjs b/docs/scripts/screenshots.mjs index 5f4bc5b9..2540c155 100644 --- a/docs/scripts/screenshots.mjs +++ b/docs/scripts/screenshots.mjs @@ -1,824 +1,581 @@ -// Captures the docs screenshots from a lived-in vault, so they can be -// regenerated after any UI change. Run from the repo root after `npm run build`: +// Captures the docs screenshots in this worktree's isolated Obsidian, so they can +// be regenerated after any UI change. Run from the repo root: // +// npm run build && npm run start:e2e-obsidian // node docs/scripts/screenshots.mjs // -// It builds a vault from docs/screenshots (notes, a Bases file, the theme, and -// PodNotes settings), launches a private Obsidian capture instance at 2x (on -// Linux under Xvfb), adds real podcast feeds through Settings, plays episodes, -// creates episode notes and captures timestamps with PodNotes' own commands, -// and writes every shot in light and dark to docs/public/screenshots. It needs -// network access for the feeds, their artwork, and the theme. -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; +// It drives the instance with the verify skill's screens tooling +// (.claude/skills/verify/screens): the fixture library seeds the podcasts, and +// the instance's pages and player give the view's shots. It copies the notes in +// docs/screenshots/vault into the instance's vault, creates episode notes and +// captures timestamps with PodNotes' own commands, and writes every shot at 2x, +// in light and dark, to docs/public/screenshots. It leaves the vault with an +// empty library, so seed it again before other verify work. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { - captureLaunchCommand, - captureScreenshot, - connectCapture, - prepareCapture, - prepareCaptureProfile, - typeText, - videoToGif, - withRecording, -} from "obsidian-e2e/capture"; +import { connectCapture, videoToGif, withRecording, typeText } from "obsidian-e2e/capture"; import sharp from "sharp"; -import e2eConfig from "../../obsidian-e2e.config.mjs"; +import { + evaluate, + go, + place, + playback, + press, + provision, + root, + shot, + theme, + UI, + waitFor, +} from "../../.claude/skills/verify/screens/instance.mjs"; +import { library, seed } from "../../.claude/skills/verify/screens/scenes.mjs"; +import { API, cdp, docsLibrary, episode, instance, plan, seedDir, writeNote } from "./scene.mjs"; -const docsDir = fileURLToPath(new URL("..", import.meta.url)); -const repoDir = path.dirname(docsDir); -const seedDir = path.join(docsDir, "screenshots"); -const outDir = path.join(docsDir, "public", "screenshots"); -const seed = JSON.parse(await fs.readFile(path.join(seedDir, "seed.json"), "utf8")); -const vaultPath = path.join(repoDir, ".obsidian-e2e-vaults", "docs-screenshots", seed.vaultName); -const profileRoot = path.join(os.tmpdir(), "podnotes-docs-capture"); -const cdpPort = 9400 + Math.floor(Math.random() * 400); -const WINDOW = { width: 1440, height: 1000 }; -const DEMO_WINDOW = { width: 1200, height: 900 }; -const PHONE = { width: 390, height: 844 }; -// Wide enough that the artwork, title, and progress lead the player. -const SIDEBAR = 430; -const PLAYER = '.workspace-leaf-content[data-type="podcast_player_view"]'; -const ROOT = ".workspace-split.mod-root"; +const outDir = fileURLToPath(new URL("../public/screenshots", import.meta.url)); +const work = path.join(root, ".obsidian-e2e-artifacts", "docs-screenshots"); +const THEMES = /** @type {const} */ (["light", "dark"]); +const HERO = { width: 1440, height: 900, sidebar: 360 }; +const DEMO = { width: 1200, height: 900, sidebar: 360 }; // Linux draws scrollbars that macOS hides until you scroll, and window buttons // that belong to the operating system, not to Obsidian. const CAPTURE_CSS = "::-webkit-scrollbar, .titlebar-button-container { display: none; }"; -const CROP_CSS = `${CAPTURE_CSS} .status-bar { display: none; }`; -async function writeJson(file, value) { - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, `${JSON.stringify(value, null, "\t")}\n`); -} +/** @param {number} ms */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -async function buildVault() { - for (const artifact of ["main.js", "manifest.json"]) { - await fs.access(path.join(repoDir, artifact)).catch(() => { - throw new Error(`Missing ${artifact}. Run npm run build first.`); - }); - } - await fs.rm(path.dirname(vaultPath), { recursive: true, force: true }); - await fs.cp(path.join(seedDir, "vault"), vaultPath, { recursive: true }); - const config = path.join(vaultPath, ".obsidian"); - const pluginDir = path.join(config, "plugins", "podnotes"); - await fs.mkdir(pluginDir, { recursive: true }); - for (const artifact of ["main.js", "manifest.json"]) { - await fs.symlink(path.join(repoDir, artifact), path.join(pluginDir, artifact)); - } - await writeJson(path.join(pluginDir, "data.json"), { - ...e2eConfig.defaultData, - ...seed.settings, - note: { ...e2eConfig.defaultData.note, template: seed.noteTemplate }, - }); - await writeJson(path.join(config, "community-plugins.json"), ["podnotes"]); - const on = ["file-explorer", "global-search", "switcher", "graph", "backlink", "outgoing-link"]; - on.push("tag-pane", "properties", "page-preview", "daily-notes", "templates", "note-composer"); - on.push( - "command-palette", - "editor-status", - "bookmarks", - "outline", - "word-count", - "file-recovery", - "bases", +/** Renders the current layout at 2x without moving anything. */ +async function hiDpi() { + const { width, height, phone } = await evaluate( + `return { width: innerWidth, height: innerHeight, phone: app.isMobile };`, ); - const off = ["canvas", "random-note", "slash-command", "markdown-importer", "zk-prefixer"]; - off.push("audio-recorder", "workspaces", "publish", "sync", "slides", "webviewer"); - await writeJson(path.join(config, "core-plugins.json"), { - ...Object.fromEntries(on.map((id) => [id, true])), - ...Object.fromEntries(off.map((id) => [id, false])), - }); - await writeJson(path.join(config, "app.json"), { - showInlineTitle: true, - readableLineLength: true, - promptDelete: false, - // Settings open in the main window, where the capture can reach them. - settingsPopoutWindow: false, - mobileToolbarCommands: [ - "podnotes:capture-timestamp", - "editor:toggle-bold", - "editor:insert-wikilink", - "editor:toggle-checklist-status", - "editor:undo", - ], - }); - await writeJson(path.join(config, "appearance.json"), { - cssTheme: seed.theme.name, - theme: "moonstone", - interfaceFontFamily: "Inter", - textFontFamily: "Inter", - baseFontSize: 16, + await cdp("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 2, + mobile: phone, + ...(phone && { screenWidth: width, screenHeight: height }), }); - await writeJson(path.join(config, "daily-notes.json"), { - folder: "Daily", - template: "Templates/Daily note", - }); - await writeJson(path.join(config, "templates.json"), { folder: "Templates" }); - const themeDir = path.join(config, "themes", seed.theme.name); - await fs.mkdir(themeDir, { recursive: true }); - for (const file of ["theme.css", "manifest.json"]) { - const url = `https://github.com/${seed.theme.repo}/releases/download/${seed.theme.version}/${file}`; - const response = await fetch(url); - if (!response.ok) throw new Error(`${url} returned ${response.status}`); - await fs.writeFile(path.join(themeDir, file), Buffer.from(await response.arrayBuffer())); - } + await waitFor(`return devicePixelRatio === 2;`, "The window did not render at 2x", 5000); } -async function launch() { - const options = { vaultPath, root: profileRoot, cdpPort, scale: 2 }; - await fs.rm(profileRoot, { recursive: true, force: true }); - const profile = await prepareCaptureProfile(options); - const command = captureLaunchCommand(options, profile); - const child = spawn(command.file, command.args, { - env: command.env, - cwd: command.cwd, - stdio: "ignore", - detached: true, - }); - const stop = () => { - try { - process.kill(-child.pid, "SIGKILL"); - } catch {} - }; - process.on("exit", stop); - return { client: await connectCapture({ port: cdpPort, timeoutMs: 90_000 }), stop }; +/** + * The view in the right sidebar, `sidebar` px wide, in a desktop window of + * `width` x `height` with the file explorer open. + */ +async function desktop({ width, height, sidebar }) { + await place("sidebar"); + await evaluate(` + const win = require("electron").remote.getCurrentWindow(); + win.setContentSize(${width}, ${height}); + await new Promise((resolve) => setTimeout(resolve, 500)); + app.workspace.leftSplit.expand(); + app.workspace.rightSplit.setSize(${sidebar}); + return true; + `); + await waitFor( + `return innerWidth === ${width} && innerHeight === ${height};`, + `The window did not become ${width} x ${height}`, + 5000, + ); + await hiDpi(); } -async function main() { - await buildVault(); - const instance = await launch(); - const { stop } = instance; - let { client } = instance; - const api = "app.plugins.plugins.podnotes.api"; - /** - * Runs `body` as an async function in Obsidian and returns its value. The - * body is fixed code: every value it needs arrives as the CDP call's - * argument, `args`, so no value is ever written into code. - */ - async function run(body, args = {}) { - const { result } = await client.call("Runtime.evaluate", { expression: "globalThis" }); - const reply = await client.call("Runtime.callFunctionOn", { - objectId: result.objectId, - functionDeclaration: `async function (args) { ${body} }`, - arguments: [{ value: args }], - awaitPromise: true, - returnByValue: true, - }); - if (reply.exceptionDetails) { - const { exception, text } = reply.exceptionDetails; - throw new Error(exception?.description ?? text); - } - return reply.result.value; - } - /** Polls `expression`, which can read `args`, until it is truthy and returns it. */ - const waitFor = (expression, what, args = {}) => - run( - `for (const end = Date.now() + 30000; Date.now() < end; await new Promise((r) => setTimeout(r, 100))) { - const value = ${expression}; - if (value) return value; +/** Opens a vault file in the one main-area tab and shows it in the file explorer. */ +async function openFile(/** @type {string} */ file) { + await evaluate(` + const file = app.vault.getAbstractFileByPath(${JSON.stringify(file)}); + let leaf; + app.workspace.iterateRootLeaves((candidate) => (leaf ??= candidate)); + await leaf.openFile(file); + // iterateRootLeaves skips tabs Obsidian has not loaded yet, so walk the tree. + const prune = (node) => { + for (const child of [...(node.children ?? [])]) { + if (child.children) prune(child); + else if (child !== leaf) child.detach(); } - throw new Error("Timed out waiting for " + args.waitingFor);`, - { ...args, waitingFor: what }, - ); - const click = (selector, text) => - run( - `for (const end = Date.now() + 30000; Date.now() < end; await new Promise((r) => setTimeout(r, 100))) { - const match = [...document.querySelectorAll(args.selector)].find((el) => - el.getClientRects().length > 0 && (args.text === undefined || el.textContent.trim().includes(args.text))); - if (match) { match.click(); return true; } - } - throw new Error("Nothing to click: " + args.selector + " " + (args.text ?? ""));`, - { selector, text }, + }; + prune(app.workspace.rootSplit); + app.workspace.setActiveLeaf(leaf, { focus: false }); + const explorer = app.workspace.getLeavesOfType("file-explorer")[0]; + await explorer?.loadIfDeferred?.(); + explorer?.view.revealInFolder(file); + return true; + `); + await waitFor( + `return app.workspace.getActiveFile()?.path === ${JSON.stringify(file)};`, + `${file} did not open`, + ); +} + +/** + * Writes `.webp` in light and `-dark.webp` in dark. `rect` is a page + * function, as source, that returns the crop in CSS px. `focus` keeps the editor + * focused, as the phone's editor toolbar needs. Obsidian's status bar would + * float over the PodNotes view, so every shot hides it. + * @param {string} name @param {string} rect + * @param {{ css?: string, focus?: boolean }} [options] + */ +async function capture(name, rect, { css = "", focus = false } = {}) { + fs.mkdirSync(work, { recursive: true }); + for (const name_ of THEMES) { + await theme(name_); + await cdp("Input.dispatchMouseEvent", { type: "mouseMoved", x: -10, y: -10 }); + await evaluate(` + document.getElementById("docs-capture")?.remove(); + document.head.createEl("style", { attr: { id: "docs-capture" }, + text: ${JSON.stringify(`${CAPTURE_CSS} .status-bar { display: none; } ${css}`)} }); + if (!${focus}) document.activeElement?.blur?.(); + document.querySelectorAll(".tooltip, .notice").forEach((el) => el.remove()); + return true; + `); + await waitFor( + `return [...document.images].every((img) => { + const r = img.getBoundingClientRect(); + return img.complete || r.width === 0 || r.bottom < 0 || r.top > innerHeight; + });`, + "Images did not load", ); - const fill = (selector, value) => - run( - `const input = document.querySelector(args.selector); - input.focus(); - input.value = args.value; - input.dispatchEvent(new Event("input", { bubbles: true })); - return true;`, - { selector, value }, + await sleep(600); + const clip = await evaluate(`return (${rect})();`); + const [x, y, width, height] = ["x", "y", "width", "height"].map((key) => + Math.round(clip[key]), ); - const settle = (ms = 600) => new Promise((resolve) => setTimeout(resolve, ms)); + const { data } = await cdp("Page.captureScreenshot", { + format: "png", + clip: { x, y, width, height, scale: 1 }, + }); + const png = path.join(work, `${name}-${name_}.png`); + fs.writeFileSync(png, Buffer.from(data, "base64")); + await webp(png, name, name_); + } + await evaluate(`document.getElementById("docs-capture")?.remove(); return true;`); +} - /** - * Writes `.webp` in light and `-dark.webp` in dark. A rect target - * is `{ kind: "rect", body, args }`: `body` returns `{ x, y, width, height }` - * and reads its values from `args`. - */ - async function shoot(name, target, { css = CROP_CSS, size = WINDOW, clean = true } = {}) { - await fs.mkdir(outDir, { recursive: true }); - for (const theme of ["light", "dark"]) { - await prepareCapture(client, { - ...size, - scale: 2, - theme, - font: "Inter", - hideSecretWarning: true, - css, - }); - // The Xvfb pointer rests mid-screen and would leave a hover state, so park - // it over empty space: the desktop file explorer, or the phone note's left - // margin, which the open sidebar leaves uncovered. - await client.call("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: size.width === PHONE.width ? 8 : 130, - y: size.width === PHONE.width ? 160 : size.height - 80, - }); - await run( - `document.querySelectorAll(".has-focus").forEach((el) => el.classList.remove("has-focus")); return true;`, - ); - await waitFor( - `[...document.images].every((img) => { const r = img.getBoundingClientRect(); return img.complete || r.width === 0 || r.bottom < 0 || r.top > innerHeight; })`, - "images to load", - ); - await settle(400); - const png = path.join(profileRoot, `${name}-${theme}.png`); - // The capture measures the rect after its own clean-up, so it calls a - // closure that holds the values rather than code built from them. - if (target.kind === "rect") - await run( - `globalThis.captureRect = () => { ${target.body} }; return true;`, - target.args, - ); - const capture = - target.kind === "rect" - ? { kind: "rect", expression: "globalThis.captureRect()" } - : target; - await captureScreenshot(client, png, { target: capture, clean }); - await sharp(png) - .webp({ quality: 86 }) - .toFile(path.join(outDir, `${name}${theme === "dark" ? "-dark" : ""}.webp`)); +/** @param {string} png @param {string} name @param {string} themeName */ +async function webp(png, name, themeName) { + fs.mkdirSync(outDir, { recursive: true }); + const out = path.join(outDir, `${name}${themeName === "dark" ? "-dark" : ""}.webp`); + await sharp(png).webp({ quality: 86 }).toFile(out); + console.log(path.relative(root, out)); +} + +/** + * Shoots the PodNotes view, through the screens tooling, in both themes. `crop` is + * a page function, as source, that returns `{ top, bottom }` in CSS px of the + * window, either of them optional, to cut the shot to. + * @param {string} name @param {string} [text] Text the view shows once it is ready. + * @param {string} [crop] + */ +async function viewShot(name, text, crop) { + fs.mkdirSync(work, { recursive: true }); + for (const name_ of THEMES) { + await theme(name_); + const png = path.join(work, `${name}-${name_}.png`); + await shot(png, text, name_); + if (!crop) { + await webp(png, name, name_); + continue; } - await prepareCapture(client, { theme: "light", css: CAPTURE_CSS }); + const { top, bottom } = await evaluate(` + const view = document.querySelector(".podcast-view").getBoundingClientRect(); + const { top = view.top, bottom = view.bottom } = (${crop})(); + return { top: Math.round(top - view.top), bottom: Math.round(bottom - view.top) }; + `); + const { width } = await sharp(png).metadata(); + const cropped = path.join(work, `${name}-${name_}-crop.png`); + await sharp(png) + .extract({ left: 0, top: top * 2, width, height: (bottom - top) * 2 }) + .toFile(cropped); + await webp(cropped, name, name_); } +} - /** - * A crop of `selector` from its top to the bottom of its `count`th visible - * `item`, stopping short of the next item so no sliver of it shows. - */ - const cropTo = (selector, item, count, pad = 16) => ({ - kind: "rect", - body: `const box = document.querySelector(args.selector).getBoundingClientRect(); - const items = [...document.querySelectorAll(args.items)].filter((el) => el.getClientRects().length > 0); - const last = items[args.count - 1].getBoundingClientRect(); - const next = items[args.count]?.getBoundingClientRect().top ?? Infinity; - const bottom = Math.min(last.bottom + args.pad, next - 2, box.bottom); - return { x: box.x, y: box.y, width: box.width, height: bottom - box.y };`, - args: { selector, items: `${selector} ${item}`, count, pad }, - }); +/** Opens a page of the view, with the full player over it or not. */ +async function show( + /** @type {import("../../.claude/skills/verify/screens/scenes.mjs").Page} */ page, + player = false, +) { + await go(page); + await evaluate( + `app.plugins.plugins.podnotes.navigation.${player ? "expand" : "collapse"}(); return true;`, + ); +} - /** A crop of the episode note's text column, between two of its lines. */ - const noteCrop = (fromLine, toLine) => ({ - kind: "rect", - body: `const leaf = app.workspace.getLeavesOfType("markdown")[0].view; - const view = leaf.contentEl; - const pane = view.getBoundingClientRect(); - const header = leaf.containerEl.querySelector(".view-header").getBoundingClientRect(); - const column = view.querySelector(".cm-sizer").getBoundingClientRect(); - const lines = [...view.querySelectorAll(".inline-title, .metadata-container, .cm-line, .callout")]; - // On a phone the header floats over the note and casts a shadow, and the - // navigation bar sits over its bottom. - const navbar = document.querySelector(".mobile-navbar")?.getBoundingClientRect().top ?? innerHeight; - const top = Math.max(pane.top, header.bottom + 12, (${fromLine})(lines).getBoundingClientRect().top - 16); - const bottom = Math.min(pane.bottom, navbar - 12, (${toLine})(lines).getBoundingClientRect().bottom + 12); - const left = Math.max(pane.left, column.x - 16); - const right = Math.min(pane.right, column.right + 16); - return { x: left, y: top, width: right - left, height: bottom - top };`, - }); +/** A crop down to the last element matching `selector`, plus `pad`, as page source. */ +const below = (/** @type {string} */ selector, pad = 16) => + `() => ({ bottom: Math.max(...[...document.querySelectorAll(${JSON.stringify(selector)})] + .filter((el) => el.getClientRects().length > 0) + .map((el) => el.getBoundingClientRect().bottom)) + ${pad} })`; +// The first-run screen centers a short block in a tall sidebar. +const FIRST_RUN = `() => ({ + top: document.querySelector(".pn-first-run .pn-empty-mark").getBoundingClientRect().top - 32, + bottom: document.querySelector(".pn-first-run .pn-alts").getBoundingClientRect().bottom + 32, +})`; - // Picks episodes from Latest Episodes. On this capture host a selected - // podcast with hundreds of episodes can stall before its list renders, and - // a fresh view can show no episodes until Refresh, which a user would select. - async function showLatest() { - await click('.podcast-view [aria-label="Episode list"]'); - await run(`document.querySelector(".podcast-view .go-back")?.click(); return true;`); - const shown = await run( - `return document.querySelectorAll(".podcast-view .podcast-episode-item").length;`, - ); - if (!shown) await click('.podcast-view [aria-label="Refresh episodes"]'); - await waitFor( - `document.querySelectorAll(".podcast-view .podcast-episode-item").length > 0`, - "the latest episodes", - ); +/** A crop of the open note's text column between two of its blocks, as page source. */ +const noteCrop = (/** @type {string} */ from, /** @type {string} */ to) => `() => { + const leaf = app.workspace.getLeavesOfType("markdown")[0].view; + const view = leaf.contentEl; + const pane = view.getBoundingClientRect(); + const header = leaf.containerEl.querySelector(".view-header").getBoundingClientRect(); + const column = view.querySelector(".cm-sizer").getBoundingClientRect(); + const blocks = [...view.querySelectorAll(".inline-title, .metadata-container, .cm-line, .callout")]; + // On a phone the header floats over the note, and the navigation bar sits over its bottom. + const navbar = document.querySelector(".mobile-navbar")?.getBoundingClientRect().top ?? innerHeight; + const top = Math.max(pane.top, header.bottom + 12, (${from})(blocks).getBoundingClientRect().top - 16); + const cap = Math.min(pane.bottom, navbar - 12); + const last = (${to})(blocks).getBoundingClientRect().bottom; + const rects = blocks.map((el) => el.getBoundingClientRect()); + const shown = blocks.filter((el) => el.textContent.trim() || el.querySelector("img")).map((el) => el.getBoundingClientRect()); + // Ends below the last block that fits whole and above the next, so the edge never cuts a line of text. + const end = Math.max(...rects.map((rect) => rect.bottom).filter((edge) => edge <= last && edge + 12 <= cap)); + const next = Math.min(...shown.map((rect) => rect.top).filter((edge) => edge >= end - 0.5)); + const bottom = end === last ? end + 12 : Math.min(end + 12, next); + const left = Math.max(pane.left, column.x - 16); + const right = Math.min(pane.right, column.right + 16); + return { x: left, y: top, width: right - left, height: bottom - top }; +}`; +const TITLE = `(blocks) => blocks.find((el) => el.matches(".inline-title"))`; +const CALLOUT = `(blocks) => blocks.find((el) => el.matches(".callout"))`; +const NOTES_HEADING = `(blocks) => blocks.find((el) => el.textContent.trim() === "Notes")`; +const LAST_LINE = `(blocks) => blocks.filter((el) => el.matches(".cm-line") && el.textContent.trim()).at(-1)`; +const LAST_TIMESTAMP = `(blocks) => blocks.filter((el) => /\\d\\d:\\d\\d:\\d\\d/.test(el.textContent)).at(-1)`; +const VIEWPORT = `() => ({ x: 0, y: 0, width: innerWidth, height: innerHeight })`; +const BASES_TABLE = `() => { + const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); + const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; + const top = rows[0].getBoundingClientRect().top - 12; + const last = rows.at(-1).getBoundingClientRect(); + const right = Math.max(...rows.flatMap((row) => [...row.children].map((cell) => cell.getBoundingClientRect().right))); + return { x: box.x, y: top, width: Math.min(box.width, right - box.x + 24), + height: Math.min(innerHeight, last.bottom + 24) - top }; +}`; +// The PodNotes view in a phone's right drawer, down to the full player's panel +// buttons. The drawer's own tab switcher and file details sit below it. +const DRAWER_PLAYER = `() => { + const view = document.querySelector(".workspace-drawer.mod-right .podcast-view").getBoundingClientRect(); + const panels = document.querySelector(".workspace-drawer.mod-right section.pn-player .pn-switch").getBoundingClientRect(); + return { x: view.x, y: view.y, width: view.width, height: panels.bottom + 4 - view.y }; + }`; +// PodNotes' own properties hold the fixture server's address, which says nothing +// to a reader, so the note's shots hide them. +const HIDE_ID_PROPERTIES = + '.metadata-property:is([data-property-key="guid" i], [data-property-key="feedurl" i]) { display: none; }'; + +/** Replaces the instance vault's files with docs/screenshots/vault and sets up Obsidian for it. */ +async function prepareVault() { + for (const entry of fs.readdirSync(provision.vaultPath)) { + if (entry !== ".obsidian") + fs.rmSync(path.join(provision.vaultPath, entry), { recursive: true }); } + await waitFor( + `return app.vault.getFiles().length === 0;`, + "Obsidian still lists files that were deleted", + ); + fs.cpSync(path.join(seedDir, "vault"), provision.vaultPath, { recursive: true }); + await evaluate(` + for (const id of ["file-explorer", "command-palette", "properties", "bases", "page-preview", "outline", "word-count"]) { + const plugin = app.internalPlugins.getPluginById(id); + if (plugin && !plugin.enabled) await plugin.enable(true); + } + app.vault.setConfig("showInlineTitle", true); + app.vault.setConfig("readableLineLength", true); + app.vault.setConfig("mobileToolbarCommands", ["podnotes:capture-timestamp", + "editor:toggle-bold", "editor:insert-wikilink", "editor:toggle-checklist-status", "editor:undo"]); + return true; + `); + await waitFor( + `return Boolean(app.vault.getAbstractFileByPath("PodNotes/Episodes.base"));`, + "Obsidian did not index the docs vault", + ); +} - /** - * Records the README demo: capture a timestamp from the command palette and - * type a note after it. The note text goes in through the editor API, because - * the headless window does not keep keyboard focus in the editor. - */ - async function recordDemo(theme) { - await prepareCapture(client, { - ...DEMO_WINDOW, - scale: 2, - theme, - font: "Inter", - hideSecretWarning: true, - css: CAPTURE_CSS, - }); - // Both takes open the palette on the same list, not on the last take's command. - await run( - `app.internalPlugins.getPluginById("command-palette").instance.recentCommands = []; - app.workspace.leftSplit.collapse(); - app.workspace.rightSplit.setSize(360); - const leaf = app.workspace.getLeavesOfType("markdown")[0]; - app.workspace.setActiveLeaf(leaf, { focus: true }); - const editor = leaf.view.editor; - const lines = [...Array(editor.lineCount()).keys()]; - const last = lines.findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); - editor.setCursor(last, editor.getLine(last).length); - editor.replaceSelection("\\n"); - // The note is too short to scroll its Notes heading to the top, so start at - // the artwork rather than part way through it. - const artwork = lines.find((i) => editor.getLine(i).startsWith("![")); - const view = editor.cm; - view.scrollDOM.scrollTop = view.lineBlockAt(view.state.doc.line(artwork + 1).from).top - 16; - ${api}.currentTime = args.at; - return true;`, - { at: seed.demo.at }, - ); - await client.call("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: 130, - y: DEMO_WINDOW.height - 80, - }); - await settle(800); - const video = path.join(profileRoot, `demo-${theme}.mp4`); +/** Plays an episode from its podcast's page, as a listener does, then pauses it. */ +async function load(/** @type {{ title: string, podcastName: string }} */ item) { + await show({ kind: "feed", key: item.podcastName }); + execFileSync( + process.execPath, + [UI, "click", ".podcast-view .pn-ep-main", "--text", item.title.slice(0, 40)], + { stdio: "inherit" }, + ); + await waitFor( + `let state; app.plugins.plugins.podnotes.player.state.subscribe((value) => (state = value))(); + return state.status === "playing" && state.episode.title === ${JSON.stringify(item.title)};`, + `${item.title} did not play`, + ); + await evaluate(`app.plugins.plugins.podnotes.player.pause(); return true;`); +} + +/** Creates a podcast's feed note with Create podcast feed note and its picker. */ +async function writeFeedNote(/** @type {string} */ podcast) { + const before = await evaluate(`return app.workspace.getActiveFile()?.path ?? null;`); + await evaluate( + `app.commands.executeCommandById("podnotes:create-podcast-feed-note"); return true;`, + ); + await waitFor(`return Boolean(document.querySelector(".prompt-input"));`, "No podcast picker"); + execFileSync(process.execPath, [UI, "fill", ".prompt-input", podcast], { stdio: "inherit" }); + execFileSync(process.execPath, [UI, "click", ".suggestion-item", "--text", podcast], { + stdio: "inherit", + }); + await waitFor( + `const file = app.workspace.getActiveFile()?.path; + return Boolean(file) && file !== ${JSON.stringify(before)} && file.startsWith("PodNotes/Podcasts/");`, + `The feed note for ${podcast} did not open`, + ); +} + +/** Folds or unfolds the open note's properties. */ +async function showProperties(/** @type {boolean} */ open) { + await evaluate(` + const box = app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelector(".metadata-container"); + if (box.classList.contains("is-collapsed") === ${open}) box.querySelector(".metadata-properties-heading").click(); + await new Promise((resolve) => setTimeout(resolve, 300)); + return true; + `); +} + +/** Scrolls the open note so `line` sits near the top, or to the top for "". */ +async function scrollNote(/** @type {string} */ line) { + await evaluate(` + const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + const view = editor.cm; + const index = [...Array(editor.lineCount()).keys()].find((i) => editor.getLine(i) === ${JSON.stringify(line)}) ?? 0; + view.scrollDOM.scrollTop = ${JSON.stringify(line)} ? view.lineBlockAt(view.state.doc.line(index + 1).from).top - 40 : 0; + await new Promise((resolve) => setTimeout(resolve, 300)); + return true; + `); +} + +/** Puts the cursor at the end of the open note's last timestamp line. */ +async function cursorAfterLastTimestamp() { + await evaluate(` + const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + const last = [...Array(editor.lineCount()).keys()].findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); + editor.focus(); + editor.setCursor(last, editor.getLine(last).length); + return true; + `); +} + +/** + * Records the README demo in one theme: capture a timestamp from the command + * palette, then type a note after it. The note goes in through the editor, + * because the headless window does not keep keyboard focus in the editor. + * @param {string} heroNote @param {"light" | "dark"} themeName + */ +async function recordDemo(heroNote, themeName) { + const text = await evaluate( + `return await app.vault.adapter.read(${JSON.stringify(heroNote)});`, + ); + await theme(themeName); + await evaluate(` + app.internalPlugins.getPluginById("command-palette").instance.recentCommands = []; + document.getElementById("docs-capture")?.remove(); + document.head.createEl("style", { attr: { id: "docs-capture" }, + text: ${JSON.stringify(`${CAPTURE_CSS} .status-bar { display: none; }`)} }); + app.workspace.leftSplit.collapse(); + const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + const lines = [...Array(editor.lineCount()).keys()]; + const last = lines.findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); + editor.focus(); + editor.setCursor(last, editor.getLine(last).length); + editor.replaceSelection("\\n"); + // The note is too short to scroll its Notes heading to the top, so start at the artwork. + const artwork = lines.find((i) => editor.getLine(i).startsWith("![")); + editor.cm.scrollDOM.scrollTop = editor.cm.lineBlockAt(editor.cm.state.doc.line(artwork + 1).from).top - 16; + ${API}.currentTime = ${plan.demo.at}; + ${API}.start(); + return true; + `); + await cdp("Input.dispatchMouseEvent", { type: "mouseMoved", x: -10, y: -10 }); + await sleep(800); + const port = fs + .readFileSync(path.join(instance.userDataPath, "DevToolsActivePort"), "utf8") + .split("\n")[0]; + const client = await connectCapture({ port: Number(port), timeoutMs: 30000 }); + const video = path.join(work, `demo-${themeName}.mp4`); + try { await withRecording( client, video, async () => { - await settle(1200); - await run(`app.commands.executeCommandById("command-palette:open"); return true;`); - await settle(500); + await sleep(1200); + await evaluate( + `app.commands.executeCommandById("command-palette:open"); return true;`, + ); + await sleep(500); await typeText(client, "capture timestamp", { delayMs: 70, selector: ".prompt-input", }); - await settle(700); - for (const type of ["keyDown", "keyUp"]) { - await client.call("Input.dispatchKeyEvent", { - type, - key: "Enter", - code: "Enter", - windowsVirtualKeyCode: 13, - }); - } - await settle(900); - await run( - `const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; - for (const ch of args.note) { + await sleep(700); + execFileSync(process.execPath, [UI, "key", "Enter"]); + await sleep(900); + await evaluate(` + const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + for (const ch of ${JSON.stringify(plan.demo.note)}) { editor.replaceSelection(ch); - await new Promise((r) => setTimeout(r, 45)); + await new Promise((resolve) => setTimeout(resolve, 45)); } - return true;`, - { note: seed.demo.note }, - ); - await settle(2200); + return true; + `); + await sleep(2200); }, { fps: 24, backend: "screencast" }, ); - await videoToGif(video, path.join(outDir, `demo${theme === "dark" ? "-dark" : ""}.gif`), { - width: DEMO_WINDOW.width, - fps: 12, - }); + } finally { + client.close(); } + await videoToGif(video, path.join(outDir, `demo${themeName === "dark" ? "-dark" : ""}.gif`), { + width: DEMO.width, + fps: 12, + }); + console.log(`docs/public/screenshots/demo${themeName === "dark" ? "-dark" : ""}.gif`); + await evaluate(` + ${API}.stop(); + await app.vault.adapter.write(${JSON.stringify(heroNote)}, ${JSON.stringify(text)}); + document.getElementById("docs-capture")?.remove(); + app.workspace.leftSplit.expand(); + return true; + `); + await sleep(800); +} - // Properties first, while the editor and the file agree, then the notes - // through the editor with PodNotes' own capture command, then save. - async function writeNote(episode) { - await run( - `await app.fileManager.processFrontMatter(app.workspace.getActiveFile(), (fm) => { - fm.status = args.status; - if (args.rating !== undefined) fm.rating = args.rating; - }); - return true;`, - { status: episode.status, rating: episode.rating }, - ); - await waitFor( - `app.workspace.activeEditor?.editor?.getValue().includes(args.text)`, - `the properties of ${episode.title}`, - { text: `status: ${episode.status}` }, - ); - if (!episode.notes) return; - const lines = (await fs.readFile(path.join(seedDir, "notes", episode.notes), "utf8")) - .trimEnd() - .split("\n"); - for (const line of lines) { - const at = line.match(/^\{\{at (\d+):(\d\d):(\d\d)\}\} (.*)$/); - await run(`const editor = app.workspace.activeEditor.editor; - editor.focus(); - editor.setCursor(editor.lastLine(), editor.getLine(editor.lastLine()).length); - return true;`); - if (at) { - const seconds = Number(at[1]) * 3600 + Number(at[2]) * 60 + Number(at[3]); - await run( - `${api}.currentTime = args.seconds; - app.commands.executeCommandById("podnotes:capture-timestamp"); - app.workspace.activeEditor.editor.replaceSelection(args.text); - return true;`, - { seconds, text: `${at[4]}\n` }, - ); - } else { - await run( - `app.workspace.activeEditor.editor.replaceSelection(args.text); return true;`, - { - text: `${line}\n`, - }, - ); - } - } - await run(`await app.workspace.activeEditor.save(); return true;`); - } +async function main() { + const lib = await library(); + await prepareVault(); + await seed("lists", docsLibrary); - try { - await run(`await app.plugins.setEnable(true); - document.querySelectorAll(".modal-container").forEach((m) => m.remove()); - await app.plugins.loadManifests(); - await app.plugins.enablePluginAndSave("podnotes"); - return true;`); - await waitFor(api, "PodNotes to load"); - await prepareCapture(client, { - ...WINDOW, - scale: 2, - theme: "light", - font: "Inter", - hideSecretWarning: true, - css: CAPTURE_CSS, - }); + // Each episode's note, made as a listener makes it. The last one stays loaded. + // With the automatic queue off, the queue stays as the scene seeded it. + await place("pane"); + await evaluate("app.plugins.plugins.podnotes.setAutoQueue(false); return true;"); + /** @type {Map} */ + const notes = new Map(); + for (const entry of plan.episodes) { + const item = episode(lib, entry.podcast, entry.title); + await load(item); + notes.set(entry.title, await writeNote({ ...entry, title: item.title })); + } + await evaluate("app.plugins.plugins.podnotes.setAutoQueue(true); return true;"); + for (const podcast of plan.feedNotes) await writeFeedNote(podcast); + const hero = plan.episodes.at(-1); + const heroNote = /** @type {string} */ (notes.get(hero.title)); + await playback(hero.playAt); - // Add the feeds the way a user does: Settings, then Search Podcasts. - await run(`app.setting.open(); app.setting.openTabById("podnotes"); return true;`); - const search = 'input[placeholder="Search or enter feed URL..."]'; - await waitFor(`document.querySelector(args.selector)`, "the podcast search field", { - selector: search, - }); - for (const feed of seed.feeds) { - await fill(search, feed); - const label = await waitFor( - `document.querySelector('.modal.mod-settings [aria-label^="Add "][aria-label$=" podcast"]')?.getAttribute("aria-label")`, - `a result for ${feed}`, - ); - await click(`[aria-label="${label}"]`); - const title = label.slice("Add ".length, -" podcast".length); - await waitFor( - `app.plugins.plugins.podnotes.settings.savedFeeds[args.title]`, - `${title} to save`, - { - title, - }, - ); - } - await fill(search, seed.search); - await waitFor( - `document.querySelectorAll(".podcast-query-results > *").length >= 4`, - "search results", - ); - await settle(2500); - // The search shares its settings card with the playlists and more, so the - // shot hides what follows it and the card ends below the results. - await run(`window.searchSectionRest = []; - const tab = document.querySelector(".modal.mod-settings .vertical-tab-content"); - const card = (${SEARCH_CARD})(tab); - for (let node = tab.querySelector(".podcast-query-container"); node !== card; node = node.parentElement) - for (let next = node.nextElementSibling; next; next = next.nextElementSibling) { - next.style.display = "none"; - searchSectionRest.push(next); - } - return true;`); - await shoot( - "add-podcast", - { kind: "rect", body: SEARCH_SECTION }, - { css: `${CAPTURE_CSS} ${FIRST_FOUR_RESULTS}` }, - ); - await run(`searchSectionRest.forEach((el) => (el.style.display = "")); return true;`); - const { playlist } = seed; - await run( - `const form = document.querySelector(".modal.mod-settings .add-playlist-container"); - const icon = form.querySelector("select"); - icon.value = args.icon; - icon.dispatchEvent(new Event("change", { bubbles: true })); - const name = form.querySelector('input[placeholder="Playlist name"]'); - name.value = args.name; - name.dispatchEvent(new Event("input", { bubbles: true })); - return true;`, - { icon: playlist.icon, name: playlist.name }, - ); - await click('[aria-label="Add playlist"]'); - await waitFor( - `app.plugins.plugins.podnotes.settings.playlists[args.name]`, - "the playlist", - { - name: playlist.name, - }, - ); - await run(`app.setting.close(); return true;`); + // The view in the right sidebar. + await place("sidebar"); + await hiDpi(); + await show({ kind: "library" }); + await viewShot("podcast-grid", "Podcasts", below(".podcast-view .pn-grid")); + await show({ kind: "feed", key: episode(lib, hero.podcast, hero.title).podcastName }); + // The view keeps room below the mini player for Obsidian's status bar, which shots hide. + await viewShot("episode-list", hero.title, below(".podcast-view .pn-mini-open", 10)); + await show({ kind: "queue" }); + await viewShot("queue", "Queue", below('.podcast-view [role="list"][aria-label="Queue"]')); + await show({ kind: "library" }, true); + await viewShot( + "player", + "The method of loci in practice", + below("section.pn-player .pn-switch"), + ); - // Play each episode, write its note, and leave the last one playing. - await run(`app.commands.executeCommandById("podnotes:podnotes-show-leaf"); return true;`); - await waitFor( - `document.querySelector(args.selector)?.getClientRects().length`, - "the PodNotes view", - { - selector: PLAYER, - }, - ); - await run(`app.workspace.rightSplit.setSize(args.size); return true;`, { size: SIDEBAR }); - const notePaths = new Map(); - for (const episode of seed.episodes) { - await showLatest(); - await fill('.podcast-view input[placeholder="Search episodes"]', episode.title); - await click(".podcast-view .podcast-episode-item", episode.title); - await waitFor(`${api}.podcast?.title === args.title`, episode.title, { - title: episode.title, - }); - await waitFor(`${api}.length > 0`, `${episode.title} to load`); - const before = await run(`return app.workspace.getActiveFile()?.path ?? null;`); - await run( - `app.commands.executeCommandById("podnotes:create-podcast-note"); return true;`, - ); - const note = await waitFor( - `(() => { const p = app.workspace.getActiveFile()?.path; return p && p !== args.before && p.startsWith("PodNotes/") ? p : null; })()`, - `the note for ${episode.title}`, - { before }, - ); - notePaths.set(episode, note); - await writeNote(episode); - } - for (const podcast of seed.feedNotes) { - await run( - `app.commands.executeCommandById("podnotes:create-podcast-feed-note"); return true;`, - ); - await waitFor(`document.querySelector(".prompt-input")`, "the podcast picker"); - await fill(".prompt-input", podcast); - await click(".suggestion-item", podcast); - await waitFor( - `app.workspace.getActiveFile()?.path === args.path`, - `the feed note for ${podcast}`, - { - path: `PodNotes/Podcasts/${podcast}.md`, - }, - ); - } + // The wide full player, with its panels as tabs. + await place("pane"); + await hiDpi(); + await show({ kind: "library" }, true); + await press("Transcript"); + await viewShot("transcript", "and he will only give you one of them."); - // Fill the playlist, and keep only titles that fit their rows in the - // queue: a longer title runs under the row's menu button. - for (const title of playlist.episodes) { - await showLatest(); - await fill('.podcast-view input[placeholder="Search episodes"]', title); - await click( - `.podcast-view [aria-label=${JSON.stringify(`More options for ${title}`)}]`, - ); - await click(".menu .menu-item", `Add to ${playlist.name}`); - } - await fill('.podcast-view input[placeholder="Search episodes"]', ""); - await click('.podcast-view [aria-label^="Player"]'); - const longTitles = await run( - `return [...document.querySelectorAll(".podcast-view .podcast-episode-row .episode-item-title")] - .map((el) => el.textContent.trim()) - .filter((title) => title.length > args.max);`, - { max: seed.maxTitleLength }, - ); - for (const title of longTitles) { - await click( - `.podcast-view [aria-label=${JSON.stringify(`More options for ${title}`)}]`, - ); - await click(".menu .menu-item", "Remove from Queue"); - } + // The note beside the player, in a desktop window. + await desktop(HERO); + await show({ kind: "library" }, true); + await press("Transcript"); + await openFile(heroNote); + await showProperties(true); + await scrollNote(""); + await capture("episode-note", noteCrop(TITLE, CALLOUT), { css: HIDE_ID_PROPERTIES }); + // The hero folds the properties away so the captured timestamps sit mid-window. + await showProperties(false); + // The vault switcher shows the test vault's name. + await capture("hero", VIEWPORT, { + css: ".workspace-sidedock-vault-profile { visibility: hidden; }", + }); + await sharp(path.join(work, "hero-light.png")) + .resize(1200, 630, { fit: "cover", position: "top" }) + .png({ compressionLevel: 9, palette: true }) + .toFile(path.join(outDir, "og.png")); + await scrollNote("## Notes"); + await capture("timestamps", noteCrop(NOTES_HEADING, LAST_LINE)); + await scrollNote(""); + // The table's columns need the width of both sidebars. + await evaluate("app.workspace.rightSplit.collapse(); return true;"); + await openFile("PodNotes/Episodes.base"); + await sleep(1500); + await capture("bases", BASES_TABLE); + await evaluate("app.workspace.rightSplit.expand(); return true;"); - const hero = seed.episodes.at(-1); - await run( - `const file = app.vault.getAbstractFileByPath(args.path); - const leaves = []; - app.workspace.iterateRootLeaves((leaf) => leaves.push(leaf)); - await leaves[0].openFile(file); - for (const leaf of leaves.slice(1)) leaf.detach(); - app.workspace.setActiveLeaf(leaves[0], { focus: false }); - const explorer = app.workspace.getLeavesOfType("file-explorer")[0]; - await explorer.loadIfDeferred?.(); - explorer.view.revealInFolder(file); - ${api}.currentTime = args.at; - ${api}.playbackRate = args.rate; - if (!${api}.isPlaying) ${api}.start(); - return true;`, - { path: notePaths.get(hero), at: hero.playAt, rate: hero.playbackRate }, - ); - await waitFor(`app.workspace.getActiveFile()?.path === args.path`, "the hero note", { - path: notePaths.get(hero), - }); - await click('.podcast-view [aria-label^="Player"]'); - await settle(1500); - const scrollNote = (line) => - run( - `const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; - const view = editor.cm; - const index = [...Array(editor.lineCount()).keys()].find((i) => editor.getLine(i) === args.line) ?? 0; - view.scrollDOM.scrollTop = args.line ? view.lineBlockAt(view.state.doc.line(index + 1).from).top - 40 : 0; - return true;`, - { line }, - ); - const showProperties = (open) => - run( - `const box = app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelector(".metadata-container"); - if (box.classList.contains("is-collapsed") === args.open) box.querySelector(".metadata-properties-heading").click(); - await new Promise((r) => setTimeout(r, 300)); - return true;`, - { open }, - ); - const episodeNote = noteCrop( - `(lines) => lines.find((el) => el.matches(".inline-title"))`, - `(lines) => lines.find((el) => el.matches(".callout"))`, - ); - const notesSection = noteCrop( - `(lines) => lines.find((el) => el.textContent.trim() === "Notes")`, - `(lines) => lines.filter((el) => el.matches(".cm-line") && el.textContent.trim()).at(-1)`, - ); - await shoot("episode-note", episodeNote); - // The hero folds the properties away so the captured timestamps sit mid-window. - await showProperties(false); - await shoot("hero", { kind: "viewport" }, { css: CAPTURE_CSS }); - // The social preview: the top of the light hero at 1200x630. - await sharp(path.join(profileRoot, "hero-light.png")) - .resize(1200, 630, { fit: "cover", position: "top" }) - .png({ compressionLevel: 9, palette: true }) - .toFile(path.join(outDir, "og.png")); - await scrollNote("## Notes"); - await shoot("timestamps", notesSection); - await scrollNote(""); - await shoot("player", cropTo(PLAYER, ".controls-container", 1, 20)); - const heroNote = notePaths.get(hero); - const heroText = await run(`return app.vault.adapter.read(args.path);`, { path: heroNote }); - for (const theme of ["light", "dark"]) { - await recordDemo(theme); - await run(`await app.vault.adapter.write(args.path, args.text); return true;`, { - path: heroNote, - text: heroText, - }); - await settle(800); - } - await run( - `app.workspace.leftSplit.expand(); app.workspace.rightSplit.setSize(args.size); return true;`, - { size: SIDEBAR }, - ); + await desktop(DEMO); + await show({ kind: "library" }, true); + await openFile(heroNote); + for (const name of THEMES) await recordDemo(heroNote, name); + await playback(hero.playAt); - await click('.podcast-view [aria-label="Podcast grid"]'); - await click(`.podcast-view [aria-label=${JSON.stringify(playlist.name)}]`); - await waitFor( - `document.querySelectorAll(".podcast-view .podcast-episode-item").length >= args.count`, - "the playlist episodes", - { count: playlist.episodes.length }, - ); - await settle(1500); - await shoot( - "episode-list", - cropTo(PLAYER, ".podcast-episode-item", playlist.episodes.length, 12), - ); - await click('.podcast-view [aria-label="Podcast grid"]'); - await settle(1500); - await shoot("podcast-grid", cropTo(PLAYER, ".pn_image_container", 4)); + // Obsidian's phone layout. + await place("phone"); + await hiDpi(); + await openFile(heroNote); + await showProperties(true); + await scrollNote(""); + await capture("episode-note-phone", noteCrop(TITLE, CALLOUT), { css: HIDE_ID_PROPERTIES }); + await showProperties(false); + await capture("hero-phone", noteCrop(TITLE, LAST_TIMESTAMP)); + await scrollNote("## Notes"); + await capture("timestamps-phone", noteCrop(NOTES_HEADING, LAST_LINE)); + await cursorAfterLastTimestamp(); + await scrollNote("## Notes"); + await sleep(1500); + // The toolbar shows only while the editor has focus. + await capture( + "mobile-toolbar", + `() => { + const lines = [...app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelectorAll(".cm-line")]; + const top = lines.find((el) => el.textContent.trim() === "Notes").getBoundingClientRect().top - 16; + return { x: 0, y: top, width: innerWidth, height: innerHeight - top }; + }`, + { + css: ".cm-cursorLayer { display: none; } .cm-content { caret-color: transparent !important; }", + focus: true, + }, + ); + await openFile("PodNotes/Episodes.base"); + await sleep(1500); + await capture("bases-phone", BASES_TABLE); + // Show player's path on a phone: the view in the right sidebar's drawer. + await evaluate(` + app.workspace.detachLeavesOfType("podcast_player_view"); + await app.plugins.plugins.podnotes.showPlayer(); + return true; + `); + await waitFor( + `return document.querySelector(".workspace-drawer.mod-right section.pn-player")?.getBoundingClientRect().width > 0;`, + "The full player did not open in the right drawer", + ); + await capture("mobile-player", DRAWER_PLAYER); + await press("Transcript"); + await capture("transcript-phone", DRAWER_PLAYER); - await run(`app.workspace.rightSplit.collapse(); - await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath("PodNotes/Episodes.base")); - return true;`); - await settle(2000); - await shoot("bases", { - kind: "rect", - body: `const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); - const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; - const top = rows[0].getBoundingClientRect().top - 12; - const last = rows.at(-1).getBoundingClientRect(); - const right = Math.max(...rows.flatMap((row) => [...row.children].map((cell) => cell.getBoundingClientRect().right))); - return { x: box.x, y: top, width: Math.min(box.width, right - box.x + 24), height: last.bottom + 24 - top };`, - }); - // Obsidian's mobile layout at phone size. The switch reloads the window, so - // pause first and reconnect after. - await run(`${api}.stop(); return true;`); - await settle(1000); - await run(`app.emulateMobile(true); return true;`); - await settle(4000); - client.close(); - client = await connectCapture({ port: cdpPort, timeoutMs: 90_000 }); - await prepareCapture(client, { - ...PHONE, - scale: 2, - theme: "light", - font: "Inter", - hideSecretWarning: true, - css: CAPTURE_CSS, - }); - const phone = { css: CAPTURE_CSS, size: PHONE }; - await run(`app.workspace.leftSplit.collapse(); - app.workspace.rightSplit.collapse(); - await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath("PodNotes/Episodes.base")); - return true;`); - await settle(2000); - await shoot( - "bases-phone", - { - kind: "rect", - body: `const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); - const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; - const top = rows[0].getBoundingClientRect().top - 12; - const last = rows.at(-1).getBoundingClientRect(); - return { x: box.x, y: top, width: box.width, height: Math.min(innerHeight, last.bottom + 24) - top };`, - }, - phone, - ); - await run( - `await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath(args.path)); return true;`, - { path: notePaths.get(hero) }, - ); - await settle(1000); - await showProperties(true); - await scrollNote(""); - // The properties, artwork, and callout need a taller phone to clear the - // navigation bar. - await shoot("episode-note-phone", episodeNote, { - ...phone, - size: { ...PHONE, height: 932 }, - }); - await showProperties(false); - await scrollNote("## Notes"); - await shoot("timestamps-phone", notesSection, phone); - // The landing page's hero on phones: the note with its timestamps. - await scrollNote(""); - await shoot( - "hero-phone", - noteCrop( - `(lines) => lines.find((el) => el.matches(".inline-title"))`, - `(lines) => lines.filter((el) => /\\d\\d:\\d\\d:\\d\\d/.test(el.textContent.trim())).at(-1)`, - ), - phone, - ); - await run(`const editor = app.workspace.activeEditor.editor; - const last = [...Array(editor.lineCount()).keys()].findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); - editor.focus(); - editor.setCursor(last, editor.getLine(last).length); - return true;`); - await scrollNote("## Notes"); - await settle(1500); - // Not cleaned: the toolbar shows only while the editor has focus. - await shoot( - "mobile-toolbar", - { - kind: "rect", - body: `const lines = [...app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelectorAll(".cm-line")]; - const top = lines.find((el) => el.textContent.trim() === "Notes").getBoundingClientRect().top - 16; - return { x: 0, y: top, width: innerWidth, height: innerHeight - top };`, - }, - { - css: `${CAPTURE_CSS} .cm-cursorLayer { display: none; } .cm-content { caret-color: transparent !important; }`, - // A shorter phone keeps the toolbar close under the note's last line. - size: { ...PHONE, height: 724 }, - clean: false, - }, - ); - await run(`app.commands.executeCommandById("podnotes:podnotes-show-leaf"); return true;`); - await settle(2000); - await click('.podcast-view [aria-label^="Player"]'); - await run(`${api}.currentTime = args.at; ${api}.start(); return true;`, { - at: hero.playAt, - }); - await settle(1500); - await shoot( - "mobile-player", - { - kind: "rect", - body: `const drawer = document.querySelector(".podcast-view").closest(".workspace-drawer, .workspace-split").getBoundingClientRect(); - const top = document.querySelector('.podcast-view [aria-label="Podcast grid"]').getBoundingClientRect().top - 16; - const controls = document.querySelector(".podcast-view .controls-container").getBoundingClientRect(); - return { x: drawer.x, y: top, width: drawer.width, height: controls.bottom + 24 - top };`, - }, - { css: CAPTURE_CSS, size: PHONE }, - ); - console.log(`Wrote screenshots to ${path.relative(repoDir, outDir)}`); - } finally { - client.close(); - stop(); - } + // A new install: the empty library. + await seed("empty"); + await place("sidebar"); + await hiDpi(); + await show({ kind: "library" }); + await viewShot("add-podcast", "Add your first podcast", FIRST_RUN); + await theme("light"); + await cdp("Emulation.clearDeviceMetricsOverride"); + console.log(`Wrote the screenshots to ${path.relative(root, outDir)}`); } -// Shows only the first four search results, so the settings card ends with them. -const FIRST_FOUR_RESULTS = ".podcast-query-results > :nth-child(n + 5) { display: none; }"; - -// The settings card that holds Search Podcasts: the nearest box with a -// background around the search. -const SEARCH_CARD = `(tab) => { - let card = tab.querySelector(".podcast-query-container"); - while (card.parentElement !== tab && getComputedStyle(card).backgroundColor === "rgba(0, 0, 0, 0)") card = card.parentElement; - return card; -}`; - -// That card, with an even margin around it. -const SEARCH_SECTION = `const tab = document.querySelector(".modal.mod-settings .vertical-tab-content"); - const pane = tab.getBoundingClientRect(); - const box = (${SEARCH_CARD})(tab).getBoundingClientRect(); - const top = Math.max(pane.top, box.top - 16); - const bottom = Math.min(pane.bottom, box.bottom + 16); - const left = Math.max(pane.left, box.left - 16); - const right = Math.min(pane.right, box.right + 16); - return { x: left, y: top, width: right - left, height: bottom - top };`; - await main(); diff --git a/docs/scripts/videos.mjs b/docs/scripts/videos.mjs new file mode 100644 index 00000000..c343ed78 --- /dev/null +++ b/docs/scripts/videos.mjs @@ -0,0 +1,664 @@ +// Records the docs' guide clips in this worktree's isolated Obsidian, so they can +// be recorded again after any UI change. Run from the repo root: +// +// npm run build && npm run start:e2e-obsidian +// node docs/scripts/videos.mjs [clip ...] +// +// Each clip drives PodNotes with real mouse or touch input through the instance's +// DevTools port. The recording shows no system pointer, so the page draws one. +// Only the network is staged: Apple Podcasts search answers with the fixture +// library, and the private feed's made-up address loads a fixture feed. The script +// records the window at 2x, crops it to the clip's frame, and writes `.mp4` +// and a `.webp` poster, plus `-dark` twins, to docs/public/videos. It drives +// the instance with the verify skill's screens tooling, like screenshots.mjs, and +// leaves the vault with an empty library. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { connectCapture, typeText, withRecording } from "obsidian-e2e/capture"; +import sharp from "sharp"; +import { FIXTURE_HOST } from "../../scripts/e2e-instance.mjs"; +import { + evaluate, + go, + place, + playback, + root, + theme, + waitFor, +} from "../../.claude/skills/verify/screens/instance.mjs"; +import { library, seed } from "../../.claude/skills/verify/screens/scenes.mjs"; +import { API, cdp, docsLibrary, episode, instance, plan, writeNote } from "./scene.mjs"; + +const outDir = fileURLToPath(new URL("../public/videos", import.meta.url)); +const work = path.join(root, ".obsidian-e2e-artifacts", "docs-videos"); +const FPS = 30; +// Linux draws scrollbars that macOS hides until you scroll, and window buttons +// that belong to the operating system, not to Obsidian. +const CAPTURE_CSS = + "::-webkit-scrollbar, .titlebar-button-container, .status-bar { display: none; }"; +// Obviously made up, in the Supercast form that PodNotes keeps private. +const PRIVATE_ADDRESS = "https://example.supercast.com/feed/abc123.rss"; +const PRIVATE_PODCAST = "Slow Thinking"; + +/** @typedef {{ x: number, y: number, width: number, height: number }} Rect */ +/** @typedef {{ x: number, y: number }} Point */ +/** @typedef {Awaited>} Library */ + +/** @param {number} ms */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Renders the current layout at `factor`x on a window `factor` times its size, + * through Electron's zoom. Screencast frames ignore an emulated pixel ratio, so + * the screens tooling's device metrics would record at 1x. + * @param {number} factor + */ +async function zoom(factor) { + const { width, height } = await evaluate(`return { width: innerWidth, height: innerHeight };`); + await cdp("Emulation.clearDeviceMetricsOverride"); + await evaluate(` + const win = require("electron").remote.getCurrentWindow(); + // Electron skips a resize to the size it believes the window has. + win.setContentSize(${width * factor + 2}, ${height * factor + 2}); + await new Promise((resolve) => setTimeout(resolve, 300)); + win.setContentSize(${width * factor}, ${height * factor}); + require("electron").webFrame.setZoomFactor(${factor}); + return true; + `); + await waitFor( + `return devicePixelRatio === ${factor} && innerWidth === ${width} && innerHeight === ${height};`, + `The window did not render ${width} x ${height} at ${factor}x`, + 5000, + ); +} + +// A drawn pointer that follows the real pointer events: an arrow for the mouse, a +// dot for a finger, and a ring where each press lands. +const POINTER = `(() => { + document.getElementById("docs-pointer")?.remove(); + const layer = document.createElement("div"); + layer.id = "docs-pointer"; + layer.innerHTML = '
'; + document.body.append(layer); + const arrow = layer.querySelector(".arrow"); + const finger = layer.querySelector(".finger"); + const put = (el, { clientX, clientY }) => (el.style.translate = clientX + "px " + clientY + "px"); + addEventListener("pointermove", (event) => { + if (event.pointerType !== "touch") arrow.classList.add("is-on"); + put(event.pointerType === "touch" ? finger : arrow, event); + }, true); + addEventListener("pointerdown", (event) => { + if (event.pointerType === "touch") { + put(finger, event); + finger.classList.add("is-down"); + } else arrow.classList.add("is-down"); + const ring = layer.appendChild(document.createElement("div")); + ring.className = "ring"; + put(ring, event); + ring.animate([{ scale: 0.3, opacity: 0.9 }, { scale: 1.6, opacity: 0 }], { duration: 500, easing: "ease-out" }) + .finished.then(() => ring.remove()); + }, true); + for (const type of ["pointerup", "pointercancel"]) { + addEventListener(type, () => { + finger.classList.remove("is-down"); + arrow.classList.remove("is-down"); + }, true); + } + return true; +})()`; +// The screencast sends a frame only when the page paints, and obsidian-e2e holds +// the last frame of a still ending twice as long, so a pixel keeps changing. +const POINTER_CSS = ` +#docs-pointer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; } +#docs-pointer::before { content: ""; position: absolute; width: 1px; height: 1px; background: #808080; + animation: docs-tick 0.1s steps(2) infinite; } +@keyframes docs-tick { from { opacity: 0.01; } to { opacity: 0.02; } } +#docs-pointer > * { position: absolute; left: 0; top: 0; } +#docs-pointer .arrow { margin: -2px 0 0 -3px; filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35)); + transition: scale 0.08s; transform-origin: 3px 2px; } +#docs-pointer .arrow:not(.is-on) { display: none; } +#docs-pointer .arrow.is-down { scale: 0.88; } +#docs-pointer .finger { width: 36px; height: 36px; margin: -18px 0 0 -18px; border-radius: 50%; + background: rgb(128 128 128 / 0.45); border: 2px solid rgb(255 255 255 / 0.85); + box-shadow: 0 1px 4px rgb(0 0 0 / 0.3); opacity: 0; transition: opacity 0.15s; } +#docs-pointer .finger.is-down { opacity: 1; } +#docs-pointer .ring { width: 40px; height: 40px; margin: -20px 0 0 -20px; border-radius: 50%; + border: 2.5px solid var(--interactive-accent); + background: color-mix(in srgb, var(--interactive-accent) 18%, transparent); } +`; + +/** + * Finds the one visible element that matches `css`, and contains `text` when + * given, once nothing covers its center, and returns its rect. + * @param {import("obsidian-e2e/capture").CdpClient} client @param {string} css @param {string} [text] + * @returns {Promise} + */ +async function locate(client, css, text = "") { + const deadline = Date.now() + 10000; + for (;;) { + const { value, exception } = await client.evaluate(`(() => { + const text = ${JSON.stringify(text)}; + const matches = [...document.querySelectorAll(${JSON.stringify(css)})].filter((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && (!text || el.innerText.includes(text)); + }); + if (matches.length !== 1) return { count: matches.length }; + const rect = matches[0].getBoundingClientRect(); + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!hit || !matches[0].contains(hit)) return { count: 1, covered: hit ? hit.className : "nothing" }; + return { count: 1, rect: rect.toJSON() }; + })()`); + if (exception) throw new Error(exception); + const found = /** @type {{ count: number, covered?: string, rect: Rect }} */ (value); + if (found.rect) return found.rect; + if (Date.now() > deadline) { + throw new Error(`${css} ${text}: ${JSON.stringify(found)}, need one uncovered match.`); + } + await sleep(100); + } +} + +/** @param {Rect} rect @returns {Point} */ +const center = (rect) => ({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + +/** Starts and ends slow, as a hand moves. @param {number} t */ +const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); + +/** + * Real input for a clip, sent straight to the instance's DevTools port so moves + * stay smooth while the window records. + * @param {import("obsidian-e2e/capture").CdpClient} client + */ +function hand(client) { + /** @type {Point} */ + let at = { x: 0, y: 0 }; + let home = at; + /** @param {string} type @param {Point} point @param {object} [extra] */ + const mouse = (type, { x, y }, extra = {}) => + client.call("Input.dispatchMouseEvent", { type, x, y, ...extra }); + /** @param {string} type @param {Point[]} points */ + const touch = (type, points) => + client.call("Input.dispatchTouchEvent", { type, touchPoints: points }); + /** + * Moves from `at` to `to` in `ms`, sending a step each frame. The clock sets + * each step, so a busy machine sends fewer steps but never slows the move. + */ + async function glide( + /** @type {Point} */ to, + /** @type {number} */ ms, + /** @type {(point: Point) => Promise} */ send, + ) { + const from = at; + const start = Date.now(); + for (let t = 0; t < 1;) { + t = Math.min(1, (Date.now() - start) / ms); + const e = ease(t); + await send({ x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }); + await sleep(16); + } + at = to; + } + return { + /** Puts the mouse at a point without a visible move. @param {Point} point */ + async rest(point) { + at = point; + home = point; + await mouse("mouseMoved", point); + }, + /** Moves the mouse back to where it rested, so no tooltip stays up. */ + async away() { + await glide(home, 650, (point) => mouse("mouseMoved", point)); + }, + /** Moves the mouse to the element and clicks it. @param {string} css @param {string} [text] */ + async click(css, text) { + const to = center(await locate(client, css, text)); + await glide(to, 650, (point) => mouse("mouseMoved", point)); + await sleep(180); + await mouse("mousePressed", to, { button: "left", buttons: 1, clickCount: 1 }); + await sleep(90); + await mouse("mouseReleased", to, { button: "left", buttons: 0, clickCount: 1 }); + }, + /** Taps the element with a finger. @param {string} css @param {string} [text] */ + async tap(css, text) { + const to = center(await locate(client, css, text)); + at = to; + await touch("touchStart", [to]); + await sleep(220); + await touch("touchEnd", []); + }, + /** Touches the element at `from` and drags it up to just above `to`. @param {string} from @param {string} to */ + async drag(from, to) { + const start = center(await locate(client, from)); + const target = await locate(client, to); + at = start; + await touch("touchStart", [start]); + await sleep(400); + await glide({ x: start.x, y: target.y }, 1200, (point) => touch("touchMove", [point])); + await sleep(350); + await touch("touchEnd", []); + }, + /** Swipes a finger `dy` px from the element's center, to scroll. @param {string} css @param {string} text @param {number} dy */ + async swipe(css, text, dy) { + const start = center(await locate(client, css, text)); + at = start; + await touch("touchStart", [start]); + await glide({ x: start.x, y: start.y + dy }, 700, (point) => + touch("touchMove", [point]), + ); + await touch("touchEnd", []); + }, + /** Types into the focused field at a steady pace. @param {string} text */ + async type(text) { + await typeText(client, text, { delayMs: 110 }); + }, + /** Pastes into the focused field. @param {string} text */ + async paste(text) { + await client.call("Input.insertText", { text }); + }, + }; +} + +/** + * Records `clip.act` in the current window and theme, cropped to the clip's + * frame. Writes `.mp4` and a `.webp` poster of the moment `act` marks. + * @param {string} name @param {Clip} clip @param {Library} lib + */ +async function record(name, clip, lib) { + fs.mkdirSync(work, { recursive: true }); + const port = fs + .readFileSync(path.join(instance.userDataPath, "DevToolsActivePort"), "utf8") + .split("\n")[0]; + const master = path.join(work, `${name}.mp4`); + await zoom(2); + const client = await connectCapture({ port: Number(port), timeoutMs: 30000 }); + try { + await evaluate(` + document.getElementById("docs-capture")?.remove(); + document.head.createEl("style", { attr: { id: "docs-capture" }, + text: ${JSON.stringify(CAPTURE_CSS + POINTER_CSS)} }); + document.activeElement?.blur?.(); + document.querySelectorAll(".tooltip, .notice").forEach((el) => el.remove()); + return ${POINTER}; + `); + await clip.ready?.(); + /** @type {Rect} */ + const rect = await evaluate(`return (${clip.frame})();`); + const input = hand(client); + if (clip.rest) await input.rest(clip.rest(rect)); + await sleep(500); + let started = 0; + let posterAt = 0; + const { recording } = await withRecording( + client, + master, + async () => { + started = Date.now(); + await clip.act(input, () => (posterAt = (Date.now() - started) / 1000), lib); + }, + { fps: FPS, backend: "screencast", quality: 95 }, + ); + if (!posterAt) throw new Error(`${name} marked no poster frame.`); + console.log( + JSON.stringify({ + master: path.relative(root, master), + seconds: recording.durationSeconds, + frames: recording.capturedFrames, + maxGap: recording.maxFrameGapSeconds, + }), + ); + await encode(master, rect, name, posterAt); + } finally { + client.close(); + await evaluate(` + document.getElementById("docs-capture")?.remove(); + document.getElementById("docs-pointer")?.remove(); + return true; + `); + await zoom(1); + } +} + +/** + * Crops the 2x master to `rect` and encodes it for the web, with a poster. + * @param {string} master @param {Rect} rect @param {string} name @param {number} posterAt + */ +async function encode(master, rect, name, posterAt) { + fs.mkdirSync(outDir, { recursive: true }); + const even = (/** @type {number} */ n) => Math.round(n) & ~1; + const [x, y, width, height] = [rect.x, rect.y, rect.width, rect.height].map((n) => even(n * 2)); + const crop = `crop=${width}:${height}:${x}:${y}`; + const out = path.join(outDir, `${name}.mp4`); + // The screencast's frames are JPEGs, in full range. + const filters = `${crop},scale=in_range=pc:out_range=tv,format=yuv420p`; + const h264 = ["-c:v", "libx264", "-preset", "veryslow", "-crf", "30", "-tune", "animation"]; + const web = ["-color_range", "tv", "-movflags", "+faststart"]; + const source = ["-i", master, "-an", "-vf", filters]; + execFileSync("ffmpeg", ["-v", "error", "-y", ...source, ...h264, ...web, out]); + const still = path.join(work, `${name}-poster.png`); + const at = ["-ss", `${posterAt}`, "-i", master, "-frames:v", "1"]; + execFileSync("ffmpeg", ["-v", "error", "-y", ...at, "-vf", crop, still]); + await sharp(still) + .webp({ quality: 86 }) + .toFile(path.join(outDir, `${name}.webp`)); + console.log( + JSON.stringify({ clip: path.relative(root, out), bytes: fs.statSync(out).size, posterAt }), + ); +} + +/** @param {string} text */ +async function waitForText(text) { + await waitFor( + `return document.querySelector(".podcast-view")?.innerText.includes(${JSON.stringify(text)});`, + `The view did not show "${text}"`, + 10000, + ); +} + +const PLAYING = `let state; app.plugins.plugins.podnotes.player.state.subscribe((value) => (state = value))(); + return state.status === "playing"`; + +/** + * Stages the network that the clips reach, as page source. PodNotes sends these + * requests through requestUrl, which Obsidian forwards to its main process over + * the `request-url` IPC channel: Apple Podcasts searches get the fixture + * library's shows, and each address in `aliases` loads the feed it maps to. + * @param {{ title: string, author: string, url: string, artworkUrl: string, count: number }[]} shows + * @param {Record} aliases + */ +const network = (shows, aliases) => `(() => { + const ipc = window.electron.ipcRenderer; + // Staging again replaces the stub instead of wrapping the last one. + ipc.send = window.__docsSend ??= ipc.send; + const send = ipc.send; + const shows = ${JSON.stringify(shows)}; + const aliases = ${JSON.stringify(aliases)}; + ipc.send = function (channel, reply, request, ...rest) { + if (channel === "request-url" && request?.url?.startsWith("https://itunes.apple.com/search?")) { + const term = new URL(request.url).searchParams.get("term").toLowerCase(); + const results = shows.filter((show) => show.title.toLowerCase().includes(term)).map((show, n) => ({ + collectionName: show.title, artistName: show.author, feedUrl: show.url, + artworkUrl100: show.artworkUrl, collectionId: 1000 + n, trackCount: show.count, + })); + const body = new TextEncoder().encode(JSON.stringify({ resultCount: results.length, results })).buffer; + setTimeout(() => ipc.emit(reply, {}, { status: 200, headers: { "content-type": "application/json" }, body }), 250); + return; + } + if (channel === "request-url" && aliases[request?.url]) request = { ...request, url: aliases[request.url] }; + return send.call(ipc, channel, reply, request, ...rest); + }; + return true; +})()`; + +/** @param {Library} lib */ +async function stageNetwork(lib) { + const local = (/** @type {string} */ url) => url.replace(`//${FIXTURE_HOST}:`, "//127.0.0.1:"); + const shows = await Promise.all( + [...lib.feeds.filter((feed) => !feed.missing).map(({ feed }) => feed), lib.add].map( + async (feed) => { + const xml = await (await fetch(local(feed.url))).text(); + return { ...feed, count: xml.split("").length - 1 }; + }, + ), + ); + const privateFeed = lib.feeds.find(({ feed }) => feed.title === PRIVATE_PODCAST)?.feed; + if (!privateFeed) throw new Error(`The fixture library has no ${PRIVATE_PODCAST}.`); + await evaluate(`return ${network(shows, { [PRIVATE_ADDRESS]: privateFeed.url })};`); +} + +/** + * The private podcast as another device sees it: synced with its reference, + * but without its address in this device's secret storage. + * @type {import("../../.claude/skills/verify/screens/scenes.mjs").Ingredient} + */ +function privateElsewhere(data, lib) { + docsLibrary(data, lib); + const feed = data.savedFeeds[PRIVATE_PODCAST]; + feed.url = `podnotes-private-feed:${encodeURIComponent(PRIVATE_PODCAST)}`; + feed.urlSecretId = `podnotes-feed-url-${crypto.randomUUID()}`; +} + +/** The view's own rect. */ +const VIEW = `() => document.querySelector(".podcast-view").getBoundingClientRect().toJSON()`; +/** The window right of the ribbon: the note, and the view in the right sidebar. */ +const BESIDE = `() => { + const left = document.querySelector(".workspace-split.mod-root").getBoundingClientRect().left; + return { x: left, y: 0, width: innerWidth - left, height: innerHeight }; +}`; +/** @param {Rect} rect */ +const restLow = (rect) => ({ x: rect.x + rect.width * 0.78, y: rect.y + rect.height * 0.82 }); + +/** + * A clip on a docs page. `themes` are the themes its page shows screenshots in. + * `setup` runs before the recording, `ready` once the window renders at 2x, and + * `act` during the recording. `act` marks the poster's moment. `rest` places the + * mouse in the frame before it starts, and a touch clip has none. + * @typedef {{ + * themes: ("light" | "dark")[], + * setup: (lib: Library) => Promise, + * ready?: () => Promise, + * frame: string, + * rest?: (rect: Rect) => Point, + * act: (input: ReturnType, mark: () => void, lib: Library) => Promise, + * }} Clip + */ + +/** @type {Record} */ +const CLIPS = { + // Getting started: search for a show, add it, and play its first episode. + "add-and-play": { + themes: ["light", "dark"], + async setup(lib) { + await seed("empty"); + await place("pane"); + await stageNetwork(lib); + await go({ kind: "library" }); + await waitForText("Add your first podcast"); + }, + frame: VIEW, + rest: restLow, + async act(input, mark, lib) { + await sleep(1000); + await input.click('.podcast-view input[aria-label="Search or paste a link"]'); + await sleep(300); + await input.type("archive"); + await waitForText(lib.add.title); + await sleep(1300); + await input.click(`.podcast-view button.pn-result[aria-label="Add ${lib.add.title}"]`); + await waitForText("Letters in the attic"); + await sleep(1400); + await input.click(".podcast-view .pn-ep-main", "Letters in the attic"); + await waitFor(`${PLAYING};`, "The episode did not play"); + await sleep(1500); + mark(); + await sleep(1500); + }, + }, + // Capture timestamps: Timestamp in the full player while the episode plays, + // then Undo in the notice. + "capture-undo": { + themes: ["light", "dark"], + async setup(lib) { + await seed("lists", docsLibrary); + await place("sidebar"); + await playback(2200); + const hero = plan.episodes.at(-1); + const item = episode(lib, hero.podcast, hero.title); + await writeNote({ ...hero, title: item.title }); + await evaluate(` + const win = require("electron").remote.getCurrentWindow(); + win.setContentSize(864, 758); + await new Promise((resolve) => setTimeout(resolve, 500)); + app.workspace.leftSplit.collapse(); + app.workspace.rightSplit.setSize(320); + app.plugins.plugins.podnotes.navigation.expand(); + const editor = app.workspace.activeEditor.editor; + const last = [...Array(editor.lineCount()).keys()].findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); + editor.focus(); + editor.setCursor(last, editor.getLine(last).length); + editor.replaceSelection("\\n"); + const box = app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelector(".metadata-container"); + if (!box.classList.contains("is-collapsed")) box.querySelector(".metadata-properties-heading").click(); + ${API}.currentTime = ${hero.playAt}; + ${API}.start(); + return true; + `); + await waitFor(`${PLAYING};`, "The episode did not play"); + }, + // Scrolls the Notes heading to the top. A resize scrolls the editor, so + // this runs at 2x. + async ready() { + await evaluate(` + const scroller = app.workspace.getLeavesOfType("markdown")[0].view.editor.cm.scrollDOM; + for (let pass = 0; pass < 3; pass++) { + const heading = [...scroller.querySelectorAll(".cm-line")].find((el) => el.textContent.trim() === "Notes"); + scroller.scrollTop += heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 20; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return true; + `); + }, + frame: BESIDE, + rest: (rect) => ({ x: rect.x + 260, y: rect.y + rect.height * 0.8 }), + async act(input, mark) { + const timestamps = `app.workspace.getLeavesOfType("markdown")[0].view.editor.getValue().match(/^- \\[\\d/gm).length`; + await sleep(1800); + await input.click("section.pn-player button", "Timestamp"); + await waitFor(`return ${timestamps} === 5;`, "The capture did not reach the note"); + await sleep(1500); + mark(); + await sleep(1300); + await input.click(".notice button.pn-undo"); + await waitFor(`return ${timestamps} === 4;`, "Undo did not take the timestamp out"); + await sleep(1000); + await input.away(); + await sleep(1800); + }, + }, + // Use the player: the Transcript panel, a line to jump to, and a word to find. + transcript: { + themes: ["light", "dark"], + async setup() { + await seed("lists", docsLibrary); + await place("pane"); + await go({ kind: "library" }); + await evaluate(`app.plugins.plugins.podnotes.navigation.expand(); return true;`); + await playback(2290); + }, + frame: VIEW, + rest: restLow, + async act(input, mark) { + await sleep(1000); + await input.click("section.pn-player button", "Transcript"); + await waitFor( + `return Boolean(document.querySelector(".pn-tx button.pn-row.is-now"));`, + "The transcript did not load", + ); + await sleep(1500); + await input.click(".pn-tx button.pn-row", "Then spaced repetition does the rest"); + await waitFor(`${PLAYING};`, "The line did not play"); + await sleep(1800); + await input.click('.pn-tx button[aria-label="Find in transcript"]'); + await input.away(); + await input.type("walk"); + await sleep(1500); + mark(); + await sleep(1500); + }, + }, + // Use the queue and playlists: on a phone, drag a queue episode by its grip, + // then open Up next in the full player. + "queue-reorder": { + themes: ["light", "dark"], + async setup() { + await seed("lists", docsLibrary); + await place("phone"); + await go({ kind: "queue" }); + await waitForText("Ep. 88"); + }, + frame: `() => ({ x: 0, y: 0, width: innerWidth, height: innerHeight })`, + async act(input, mark) { + await sleep(1200); + await input.drag( + '.podcast-view button.pn-grip[aria-label*="position 3 of 3"]', + '.podcast-view button.pn-grip[aria-label*="position 1 of 3"]', + ); + await sleep(2000); + await input.tap(".podcast-view button.pn-mini-open"); + await sleep(1500); + await input.tap("section.pn-player button", "Up next"); + await sleep(1100); + mark(); + await sleep(800); + // The panel shows two episodes at a time, so it scrolls to the last one and + // back, to end on the new order. + await input.swipe(".pn-panel-scroll .pn-ep-main", "The case for boredom", -120); + await sleep(1300); + await input.swipe(".pn-panel-scroll .pn-ep-main", "The case for boredom", 120); + await sleep(1500); + }, + }, + // Use private feeds: on another device, a private podcast shows Address not + // on this device until you set its address. + "set-feed-address": { + themes: ["light"], + async setup(lib) { + await seed("library", privateElsewhere); + await place("pane"); + await stageNetwork(lib); + await go({ kind: "library" }); + await waitFor( + `return Boolean(document.querySelector('.podcast-view .pn-gridcard[aria-label^="${PRIVATE_PODCAST}"]'));`, + `The library did not show ${PRIVATE_PODCAST}`, + ); + }, + frame: VIEW, + rest: restLow, + async act(input, mark) { + await sleep(1000); + await input.click(`.podcast-view .pn-gridcard[aria-label^="${PRIVATE_PODCAST}"]`); + await waitForText("Address not on this device"); + await sleep(1600); + await input.click(".podcast-view button.pn-btn", "Set feed address"); + await sleep(700); + await input.click(".modal input"); + await sleep(300); + await input.paste(PRIVATE_ADDRESS); + // Off the address, so the poster shows all of it. + await input.away(); + await sleep(300); + mark(); + await sleep(700); + await input.click(".modal button.mod-cta", "Save"); + await waitFor( + `return !document.querySelector(".podcast-view")?.innerText.includes("Feed didn't load") && + document.querySelectorAll(".podcast-view .pn-ep-main").length > 0;`, + "The feed did not load from its address", + ); + await input.away(); + await sleep(2200); + }, + }, +}; + +async function main() { + const names = process.argv.slice(2); + const unknown = names.filter((name) => !(name in CLIPS)); + if (unknown.length > 0) throw new Error(`No clip named ${unknown.join(", ")}.`); + const lib = await library(); + for (const [name, clip] of Object.entries(CLIPS)) { + if (names.length > 0 && !names.includes(name)) continue; + for (const themeName of clip.themes) { + await clip.setup(lib); + await theme(themeName); + await record(`${name}${themeName === "dark" ? "-dark" : ""}`, clip, lib); + } + } + await seed("empty"); + await place("pane"); + await theme("light"); + console.log(`Wrote the clips to ${path.relative(root, outDir)}`); +} + +await main(); diff --git a/docs/src/components/Hero.astro b/docs/src/components/Hero.astro index 2b81a109..8a539cee 100644 --- a/docs/src/components/Hero.astro +++ b/docs/src/components/Hero.astro @@ -14,7 +14,7 @@ const size = await screenshotSize(shot); const phoneSize = await screenshotSize(phoneShot); const phone = "(max-width: 50rem)"; const alt = - "An episode note in Obsidian with timestamp links captured while the episode plays in PodNotes"; + "An episode note in Obsidian with timestamp links captured from the episode in PodNotes"; ---
diff --git a/docs/src/components/MarkdownContent.astro b/docs/src/components/MarkdownContent.astro index d2019646..3e714515 100644 --- a/docs/src/components/MarkdownContent.astro +++ b/docs/src/components/MarkdownContent.astro @@ -2,6 +2,7 @@ import Default from "@astrojs/starlight/components/MarkdownContent.astro"; import { withScreenshots } from "../screenshots.mjs"; import { withStackedTables } from "../tables.mjs"; +import { withVideos } from "../videos.mjs"; // Short inline code, such as `12: Interview`, never splits across two lines. const withShortCode = (html: string) => @@ -16,8 +17,39 @@ const withPathBreaks = (html: string) => ); const html = withPathBreaks( - withShortCode(withStackedTables(await withScreenshots(await Astro.slots.render("default")))), + withShortCode( + withStackedTables( + await withVideos(await withScreenshots(await Astro.slots.render("default"))), + ), + ), ); +const hasClips = html.includes('