From bdc06fb7b492b30a469b6987bd9f7adf75e9784d Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Thu, 17 Sep 2026 23:20:52 -0700 Subject: [PATCH 1/2] Add speaker notes viewer and how-it-works docs akceo viewer writes md-viewer.html, a self-contained Markdown viewer for reading speaker notes in a second browser tab while only the deck tab is shared in a call. In Chrome and Edge it watches the notes file and re-renders on save, keeping the scroll position; this works when the page is opened straight from disk. The viewer comes from the Omnis Actual repo, reviewed for reuse: opening and dropping a file both watch it, a lost file is reported, unsafe link targets are rejected, links may contain parentheses, lists nest, and images render. The demo gains example speaker notes. New docs cover the notes setup and how Akceo works in plain terms, from the user's side and under the hood, with Mermaid diagrams. A test guards the packaged assets against raw control characters, which break the viewer's script. --- CHANGELOG.md | 5 + CLAUDE.md | 11 +- README.md | 20 ++ docs/how-it-works.md | 393 ++++++++++++++++++++++++++++++++ docs/speaker-notes.md | 105 +++++++++ examples/demo/speaker-notes.md | 41 ++++ src/akceo/assets/md-viewer.html | 248 ++++++++++++++++++++ src/akceo/cli.py | 28 ++- tests/test_cli.py | 41 +++- 9 files changed, 882 insertions(+), 10 deletions(-) create mode 100644 docs/how-it-works.md create mode 100644 docs/speaker-notes.md create mode 100644 examples/demo/speaker-notes.md create mode 100644 src/akceo/assets/md-viewer.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5ea3a..333e60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,8 @@ All notable changes to this project are documented here. The format follows - Built-in `midnight` and `paper` themes. Custom themes are CSS files that set akceo's tokens. - PNG, JPEG and WebP images are shrunk and embedded; SVG is embedded as-is. - Build errors name the file, line and slide. +- `akceo viewer` writes `md-viewer.html`, a speaker-notes viewer for a second browser tab. In + Chrome and Edge it refreshes live when the notes file changes. +- Example speaker notes for the demo deck. +- Docs: the speaker-notes setup, and how Akceo works (plain terms, user experience, internals), + with Mermaid diagrams. diff --git a/CLAUDE.md b/CLAUDE.md index b74b366..7acacd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,14 @@ runtime dependency. - `src/akceo/files.py`: reads user-supplied text files, turning read failures into `DeckError`. - `src/akceo/cli.py`: the `akceo` command. - `src/akceo/assets/`: the page template, `base.css` (layout rules; all colors and fonts come from - theme tokens), and `deck.js` (navigation). + theme tokens), `deck.js` (navigation), and `md-viewer.html` (the standalone speaker-notes + viewer that `akceo viewer` writes out). - `src/akceo/themes/`: built-in themes, one CSS file each. The first-line comment is the description `akceo themes` prints. -- `examples/demo/`: a deck that uses every layout. The end-to-end test builds it. +- `examples/demo/`: a deck that uses every layout, and its speaker notes. The end-to-end test + builds the deck. +- `docs/`: `syntax.md` (format reference), `speaker-notes.md` (notes setup), `how-it-works.md` + (design, with Mermaid diagrams), `branding.md`. ## Commands @@ -33,4 +37,7 @@ uv run akceo build examples/demo/deck.md - `docs/syntax.md` is the format reference. Update it in the same change as any parser, renderer or token change. - A new theme token goes in `themes.TOKENS`, every built-in theme, and `docs/syntax.md`. +- `docs/how-it-works.md` describes the modules and pipeline. Update it when either changes. +- Keep JavaScript escapes such as `\u0000` as text in the assets. A raw control character breaks + the page, and a test checks for it. - Keep the `Unreleased` section of `CHANGELOG.md` current. diff --git a/README.md b/README.md index fcea46c..b1eef01 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,26 @@ that sets akceo's color and font tokens. The simplest start is a copy of [`src/akceo/themes/midnight.css`](src/akceo/themes/midnight.css). The token list is in [docs/syntax.md](docs/syntax.md#themes). +## Speaker notes + +Keep your notes in a Markdown file and read them in a second browser tab, while you share only +the deck tab in your call. + +```sh +akceo viewer # writes md-viewer.html into the current folder +``` + +Open `md-viewer.html` next to your deck, for example with Chrome's split view, and open your +notes file in it. In Chrome and Edge it refreshes by itself when you save the notes. +`examples/demo/speaker-notes.md` goes with the demo deck. The setup is in +[docs/speaker-notes.md](docs/speaker-notes.md). + +## How it works + +[docs/how-it-works.md](docs/how-it-works.md) explains Akceo at three levels: in plain terms, +from the user's side, and under the hood. It has diagrams of the build pipeline, the data model +and the notes viewer. + ## Development ```sh diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..abdcafd --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,393 @@ +# How Akceo works + +This document explains Akceo at three levels. Read as far as you need. + +1. [In plain terms](#in-plain-terms): what Akceo is and why you might use it. +2. [The user's experience](#the-users-experience): what it's like to write, build and present a + deck. +3. [Under the hood](#under-the-hood): how the code turns Markdown into a deck. + +The exact file format is in [syntax.md](syntax.md), and the notes setup is in +[speaker-notes.md](speaker-notes.md). + +--- + +## In plain terms + +Akceo turns a text outline into a slide deck. + +You write your slides as plain text, with a few simple marks for headings, lists and emphasis. +Akceo reads that text and produces one file that is the whole presentation. Double-click the +file and it opens in a web browser, ready to present. + +```mermaid +flowchart LR + outline["Your outline
(a text file)"] --> akceo(["Akceo"]) + look["A look
(a theme)"] --> akceo + pictures["Your pictures"] --> akceo + akceo --> deck["One presentation file
(opens in any browser)"] +``` + +**Why work this way?** + +- **You write, Akceo designs.** There are no boxes to drag or fonts to match. Every slide + follows the same layout rules, so the deck looks consistent without effort. +- **One file, no surprises.** Pictures are packed inside the file. It works offline and on any + computer, and nothing goes missing when you email it. +- **Change the look in one step.** The same outline can be rebuilt in a dark or light theme, or + in your own brand colors. +- **Text is easy to keep.** A plain-text deck can be versioned, compared, reused and reviewed + like any other document. +- **Private notes.** Your speaker notes open in a separate tab that the audience never sees. + +**Who it's for:** people who present often and would rather write than lay out slides. Think +engineers, founders, and anyone giving a design review or a pitch. + +**What it isn't:** a drawing tool. Akceo has five fixed layouts, and that restraint is the point. +If a slide needs free-form design, make that one image in another tool and drop it into a +`split` slide. + +--- + +## The user's experience + +### The whole journey + +```mermaid +journey + title Making and giving a talk with Akceo + section Write + Outline slides in deck.md: 5: Presenter + Add images and pick a theme: 4: Presenter + Write speaker notes: 4: Presenter + section Build + Run akceo build: 5: Presenter + Fix any error it names: 3: Presenter + section Rehearse + Open deck.html and click through: 5: Presenter + Edit, rebuild, refresh: 4: Presenter + section Present + Open notes beside the deck: 4: Presenter + Share the deck tab in the call: 5: Presenter, Audience +``` + +### Writing + +A deck is one Markdown file. A short config block at the top sets the title and theme. Then each +slide follows a `---` line, and a few header lines choose its layout: + +```markdown +title: Q3 Review +theme: midnight + +--- + +layout: title +kicker: Q3 review + +# Shipping faster + +--- + +kicker: The problem + +## Builds take too long + +- **Full rebuilds** on every change. +- CI queues back up at ==peak hours==. +``` + +There are five layouts: `title`, `bullets`, `split` (image beside text), `steps` and `table`. +The inline marks are small: bold, a strong color, an accent color, dimmed asides and code. + +### Building + +```sh +akceo build deck.md +``` + +It prints the output file, its size and the slide count: + +```text +wrote deck.html (12 KB, 7 slides) +``` + +If something is wrong, the build stops and says exactly where, instead of producing a broken +deck: + +```text +akceo: deck.md:14: slide 3: the steps layout needs a 1. list +``` + +The message gives the file, the line, the slide number and what to change. Typos in keys, a key +on the wrong layout, a missing image and content a layout can't show are all caught this way. + +### The edit loop + +```mermaid +flowchart LR + edit["Edit deck.md"] --> build["akceo build deck.md"] + build -->|"error at file:line"| edit + build -->|"wrote deck.html"| refresh["Refresh the browser tab"] + refresh --> edit +``` + +### Presenting + +Open `deck.html` in a browser. + +| Action | Keys | +| --- | --- | +| Next slide | `→`, `Space`, `PageDown`, or click the right half | +| Previous slide | `←`, `PageUp`, or click the left half | +| First or last slide | `Home`, `End` | +| Full screen | `F` | + +A thin progress bar runs along the bottom, with a slide counter in the corner. Selecting text +doesn't change slides, so you can copy from a slide mid-talk. + +### Presenting with private notes + +Your notes live in a Markdown file. You read them in `md-viewer.html`, a small page that +`akceo viewer` writes out. Put the deck and the viewer side by side in one Chrome window, and +share only the deck tab in your call. When you save the notes file, the viewer updates by itself. +[speaker-notes.md](speaker-notes.md) walks through the setup. + +```mermaid +sequenceDiagram + actor P as Presenter + participant D as Deck tab + participant V as Viewer tab + participant M as Meet or Zoom + actor A as Audience + P->>D: open deck.html + P->>V: open md-viewer.html, then the notes file + P->>M: share the deck tab only + M->>A: the slides, nothing else + loop during the talk + P->>D: arrow keys + P->>V: glance at notes, scroll + end +``` + +--- + +## Under the hood + +### Design goals + +- **Self-contained output.** The built page references no external file or URL. CSS, + JavaScript and images are all inside it. +- **One runtime dependency.** Pillow, for shrinking images. Everything else is the Python + standard library, and the page uses plain JavaScript with no framework. +- **Fail loudly and precisely.** Input is checked before anything is written, and every error + names the file and line. +- **Content and look are separate.** The deck says what's on each slide; the theme alone decides + colors and fonts. + +### Modules + +```mermaid +flowchart TD + cli["cli.py
akceo build / themes / viewer"] --> render["render.py
build()"] + render --> parse["parse.py
load(), validation"] + parse --> files["files.py
read_text()"] + render --> themes["themes.py
load(), token check"] + themes --> files + render --> images["images.py
data_uri()"] + render --> assets[["assets/
page.html · base.css · deck.js"]] + themes --> builtin[["themes/
midnight.css · paper.css"]] + cli --> viewer[["assets/md-viewer.html"]] + parse -.->|raises| err["errors.py
DeckError"] + themes -.->|raises| err + images -.->|raises| err + files -.->|raises| err +``` + +| Module | Job | +| --- | --- | +| `cli.py` | Parses arguments, runs a command, writes the output, turns `DeckError` into a message and exit code 1 | +| `render.py` | Runs the build: loads the deck and theme, embeds images, renders each slide, fills the page template | +| `parse.py` | Turns Markdown into a validated `Deck`. All input rules live here. | +| `themes.py` | Finds a theme by name or path and checks that it sets every token | +| `images.py` | Turns an image file into a `data:` URI, shrinking raster images | +| `files.py` | Reads user files, turning read and decode failures into `DeckError` | +| `errors.py` | `DeckError`, the one exception type the CLI reports to the user | + +### The build pipeline + +```mermaid +sequenceDiagram + participant CLI as cli.py + participant R as render.build + participant P as parse.load + participant T as themes.load + participant I as images.data_uri + CLI->>R: deck path, optional --theme + R->>P: read and parse deck.md + P-->>R: Deck (config + slides) + R->>T: theme from --theme, else the deck's theme:, else midnight + T-->>R: theme CSS + loop each split slide + R->>I: image path, image-max + I-->>R: data URI + end + R->>R: render slides, fill page.html + R-->>CLI: HTML, slide count + CLI->>CLI: write deck.html, print summary +``` + +### Parsing + +Parsing runs in stages, each working on the output of the one before: + +```mermaid +flowchart LR + text["deck.md text"] --> chunks["Split on --- lines
(keeps line numbers)"] + chunks --> config["First chunk:
config block"] + chunks --> slides["Each other chunk:
a slide"] + slides --> header["Header lines
key: value"] + slides --> body["Body lines"] + body --> logical["Join continuations
(2-space indent, trailing \)"] + logical --> blocks["Group into blocks
h1 h2 phase ul ol lead table note para"] + header --> check{"Validate against
the layout's contract"} + blocks --> check + check -->|ok| slide["Slide"] + check -->|fail| error["DeckError
file:line: slide N: …"] +``` + +Each layout has a contract, set in data at the top of `parse.py`: + +- the header keys it accepts (`LAYOUT_KEYS`) +- the blocks it renders, and whether each may repeat (`LAYOUT_BLOCKS`) +- what it requires (`REQUIRED_KEYS`, `REQUIRED_BLOCKS`) + +Anything outside the contract is an error rather than something silently dropped. + +The parsed result is a small, immutable data model: + +```mermaid +classDiagram + class Deck { + path: Path + config: dict + slides: tuple~Slide~ + title() + error(slide, message) + } + class Slide { + number: int + line: int + meta: dict + blocks: tuple~Block~ + layout() + first(kind) + flag(key) + } + class Block { + kind: str + text: str + body: str + items: tuple + rows: tuple + } + Deck "1" --> "many" Slide + Slide "1" --> "many" Block +``` + +### Rendering + +`render_slide` writes one `
` per slide, choosing markup by layout. Inline +text goes through `inline()` in a fixed order. This order is why markup inside backticks stays +literal and why raw HTML is always escaped: + +1. Set aside `` `code` `` spans, so nothing else touches them. +2. Escape HTML (`&`, `<`, `>`). +3. Apply `***strong***`, `**bold**`, `==accent==` and `((dim))`. +4. Put the code spans back, escaped. +5. Turn hard line breaks into `
`. + +The page comes from `assets/page.html`, which has four placeholders filled in one pass: title, +styles, slides and script. Filling them in one pass means slide text that happens to contain a +placeholder name is never replaced. + +### Themes + +```mermaid +flowchart LR + base["base.css
layout rules,
colors via var(--token)"] --> pagestyle["The page's style block"] + theme["theme CSS
sets --bg, --accent, …
may override rules"] --> pagestyle + pagestyle --> page["deck.html"] +``` + +`base.css` holds only layout. Every color and font in it comes from a CSS custom property, such +as `var(--accent)`. A theme is a CSS file that sets those properties. It's added after +`base.css`, so it can also override any layout rule. `themes.load` rejects a theme that leaves +out a token, or that contains ` exists{"File exists?"} + exists -->|no| e1["DeckError: image not found"] + exists -->|yes| svg{"SVG?"} + svg -->|yes| raw["Embed the bytes unchanged"] + svg -->|no| fmt{"PNG, JPEG or WebP?"} + fmt -->|no| e2["DeckError: unsupported format"] + fmt -->|yes| fix["Rotate upright from EXIF,
shrink to image-max,
never enlarge"] + fix --> save["Re-save in the same format
(JPEG and WebP at quality 90)"] + raw --> uri["base64 data: URI in the img src"] + save --> uri +``` + +Images are processed on every build, so replacing an image file and rebuilding just works. + +### Errors + +Every problem the user can fix is a `DeckError`, with a message in one of these forms: + +```text +deck.md:14: slide 3: the steps layout needs a 1. list +deck.md:2: unknown config key 'diagrams' (expected one of: title, theme, images) +theme brand.css doesn't set: --figure-bg +``` + +`cli.main` catches `DeckError`, prints `akceo: ` to stderr and exits with code 1. +Anything else is a bug in Akceo, and it shows a normal Python traceback. + +### The page at runtime + +`deck.js` is small. It shows one slide at a time by toggling an `active` class, +moves the progress bar and counter, and maps keys and clicks to next and previous. A click is +ignored while text is selected. + +### The notes viewer + +`md-viewer.html` is a separate, self-contained page with its own small Markdown renderer. Live +refresh uses Chrome's File System Access API. That API is available to pages opened from disk, +so no server is needed. + +```mermaid +sequenceDiagram + actor P as Presenter + participant V as md-viewer.html + participant C as Chrome file access + participant F as speaker-notes.md + P->>V: click Open .md (or drop the file) + V->>C: showOpenFilePicker() + C-->>V: file handle + V->>F: read, render, show "● watching" + loop every 1.5 seconds + V->>C: handle.getFile() + C-->>V: file with lastModified + alt lastModified changed + V->>F: read and re-render, keep scroll position + end + end + Note over V,F: If the read fails, show "file unavailable · open it again" +``` + +In browsers without that API, the viewer still opens files, but you drop the file again to +refresh. diff --git a/docs/speaker-notes.md b/docs/speaker-notes.md new file mode 100644 index 0000000..3e64141 --- /dev/null +++ b/docs/speaker-notes.md @@ -0,0 +1,105 @@ +# Speaker notes + +An Akceo deck has no built-in presenter view. You keep your notes in a Markdown file and read +them in a second browser tab with `md-viewer.html`. In a video call you share only the deck tab, +so your notes stay private. + +```mermaid +flowchart LR + subgraph window["Your Chrome window, in split view"] + deck["Tab 1: deck.html
the slides"] + notes["Tab 2: md-viewer.html
showing speaker-notes.md"] + end + deck -->|"shared tab"| meeting["Google Meet or Zoom"] + meeting --> audience["Audience sees
only the slides"] + file[("speaker-notes.md
on disk")] -.->|"refreshes when you save"| notes +``` + +## Set it up + +1. **Write your notes** in a Markdown file next to your deck, for example `speaker-notes.md`. + The [notes format](#notes-format) below has a suggested layout. +2. **Get the viewer.** `akceo viewer` writes `md-viewer.html` into the current folder. Use + `-o` to put it somewhere else. You only need one copy; it can open any notes file. +3. **Open the deck** (`deck.html`) in Chrome. +4. **Open the viewer** (`md-viewer.html`) in a second tab. Click **Open .md** and pick your notes + file, or drag the file onto the page. +5. **Put the tabs side by side** with Chrome's split view, so both sit in one window. +6. **Present.** Share the deck tab in your meeting, and read your notes from the other half of + the window. + +### Try it with the demo + +```sh +akceo build examples/demo/deck.md +akceo viewer -o examples/demo +``` + +Open `examples/demo/deck.html` and `examples/demo/md-viewer.html` in two tabs. In the viewer, +open `examples/demo/speaker-notes.md`. Its sections match the demo's seven slides. + +## Sharing in a meeting + +The idea is to share the one tab that holds the slides, not your whole screen. + +- **Google Meet** in Chrome can share a single tab. Choose to present a tab and pick the deck. +- **Zoom's desktop app** shares a screen or a window rather than a tab. With split view, the + window includes your notes. Either share just the part of the screen that holds the deck, or + keep the deck in its own window and share that. + +## Live refresh + +In Chrome and Edge, the viewer watches the file you opened. When you save the notes, the page +updates within about two seconds and keeps your scroll position. The header shows +**● watching** while this is on. It works when the viewer is opened straight from disk, with no +web server. + +- If the file is moved or deleted, the header shows **file unavailable · open it again**. +- Reloading the viewer tab forgets the file. Open it again. +- Other browsers can't watch files. Drop the file on the page again to refresh. + +**A−** and **A+** change the text size, and the viewer remembers the size for next time. + +## Notes format + +Any Markdown works. This layout reads well in the viewer: + +```markdown +# Speaker Notes · My talk + +--- + +### 1 · Opening *(0:30)* +What to say on the first slide. + +### 2 · The problem *(1:00)* +- A point to hit +- Another point +``` + +- One `###` heading per slide, numbered to match the deck, makes it easy to keep your place. +- An italic part in a heading, like `*(0:30)*`, shows in small amber type. It's a good spot for + a time budget. + +The viewer handles: +- headings +- paragraphs +- **bold**, *italic* and `code` +- links +- images +- bulleted and numbered lists, nested by indenting +- tables +- quotes +- fenced code blocks +- horizontal rules + +Only `http`, `https`, `mailto` and relative links become clickable. Links open in a new tab. + +Images use `![alt](path)`. A relative path resolves from the folder that holds `md-viewer.html`, +not from the notes file, because the browser doesn't tell the viewer where the notes file lives. +So keep the viewer next to your notes: `akceo viewer -o `. `http(s)` and +`data:image/` sources work too. Any other source shows the alt text instead. + +## Limits + +- The notes don't follow the slides. Scroll the viewer as you go. diff --git a/examples/demo/speaker-notes.md b/examples/demo/speaker-notes.md new file mode 100644 index 0000000..1855102 --- /dev/null +++ b/examples/demo/speaker-notes.md @@ -0,0 +1,41 @@ +# Speaker Notes · Akceo demo + +Open this file in `md-viewer.html` in a second tab. Share only the deck tab. + +--- + +### 1 · Decks at the speed of text *(0:20)* +Open with the promise: a deck is a text file, and one command turns it into a presentation. + +### 2 · Slides are structured text *(0:45)* +Three points, in order: + +1. **Markdown**: write in any editor, keep it in git, review changes in a diff. +2. **One file**: images are embedded, so there's nothing to lose when you email it. +3. **Anywhere**: it opens in any browser, even with no network. + +If asked: this slide is the `bullets` layout, the default when a slide doesn't name one. + +### 3 · A small set of marks *(0:40)* +Walk the list top to bottom. Each item shows the syntax and the result side by side. + +- `==accent==` is the one to point out; it's how a lead line gets its color. +- The last item shows that a long line can wrap in the source without breaking on the slide. + +### 4 · One command, one file *(0:40)* +Point at the diagram: the deck and a theme go in, one HTML file comes out. + +The three phases on the right are the whole workflow. Mention that images are shrunk at build time, so a big screenshot doesn't make a big deck. + +### 5 · From zero to a deck *(0:30)* +Read the four steps as a loop, not a one-time setup. After the first install it's write, build, present. + +### 6 · Five layouts *(0:30)* +There are only five layouts, on purpose. Most slides are `bullets`; `split` is for a diagram with commentary. + +> The muted last column is `dim-last-column: yes` on this slide. + +### 7 · Same deck, any look *(0:20)* +Close by rebuilding with `--theme paper` live, if time allows. The content doesn't change, only the look. + +--- diff --git a/src/akceo/assets/md-viewer.html b/src/akceo/assets/md-viewer.html new file mode 100644 index 0000000..3246698 --- /dev/null +++ b/src/akceo/assets/md-viewer.html @@ -0,0 +1,248 @@ + + + + + +Markdown Viewer + + + +
+ No file loaded + +
+ + +
+
+
+
Open or drop a Markdown file
+
+
+
+
+
Drop to open
+ + + + diff --git a/src/akceo/cli.py b/src/akceo/cli.py index 7b8b0da..6d42a05 100644 --- a/src/akceo/cli.py +++ b/src/akceo/cli.py @@ -9,6 +9,8 @@ from akceo import render, themes from akceo.errors import DeckError +VIEWER = "md-viewer.html" + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( @@ -23,6 +25,13 @@ def _parser() -> argparse.ArgumentParser: build.add_argument("-t", "--theme", help="built-in theme name or path to a .css file (overrides theme:)") commands.add_parser("themes", help="list the built-in themes") + + viewer = commands.add_parser( + "viewer", help="write md-viewer.html, a live Markdown viewer for speaker notes" + ) + viewer.add_argument( + "-o", "--out", type=Path, default=Path(VIEWER), help=f"output file or folder (default: ./{VIEWER})" + ) return parser @@ -31,6 +40,8 @@ def main(argv: Sequence[str] | None = None) -> int: try: if args.command == "build": _build(args.deck, args.out, args.theme) + elif args.command == "viewer": + _write_viewer(args.out) else: _list_themes() except DeckError as e: @@ -42,12 +53,23 @@ def main(argv: Sequence[str] | None = None) -> int: def _build(deck: Path, out: Path | None, theme: str | None) -> None: page, count = render.build(deck, theme) out = out or deck.with_suffix(".html") + _write(out, page) + noun = "slide" if count == 1 else "slides" + print(f"wrote {out} ({out.stat().st_size / 1024:.0f} KB, {count} {noun})") + + +def _write_viewer(out: Path) -> None: + if out.is_dir(): + out = out / VIEWER + _write(out, (render.ASSETS / VIEWER).read_text(encoding="utf-8")) + print(f"wrote {out}") + + +def _write(out: Path, text: str) -> None: try: - out.write_text(page, encoding="utf-8") + out.write_text(text, encoding="utf-8") except OSError as e: raise DeckError(f"{out}: {e.strerror}") from None - noun = "slide" if count == 1 else "slides" - print(f"wrote {out} ({out.stat().st_size / 1024:.0f} KB, {count} {noun})") def _list_themes() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 119fde3..e0a7412 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,9 @@ from akceo.cli import main -DEMO = Path(__file__).parents[1] / "examples" / "demo" +ROOT = Path(__file__).parents[1] +DEMO = ROOT / "examples" / "demo" +EXTERNAL_REF = re.compile(r"""(src|href)=["']?(https?:|//|\.{0,2}/)|= 6 + for path in paths: + text = path.read_text(encoding="utf-8") + bad = {hex(ord(c)) for c in text if (ord(c) < 32 and c not in "\n\t") or 0xE000 <= ord(c) <= 0xF8FF} + assert not bad, f"{path.name}: {sorted(bad)}" From be35962dfcaf68ab7930545395dd34ec158b07a1 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Thu, 17 Sep 2026 23:32:07 -0700 Subject: [PATCH 2/2] Fix viewer read races and tighten docs Reads in the notes viewer could finish out of order and render an older file over a newer selection. Each open now bumps a generation that every read rechecks before rendering, and only one poll runs at a time. The status line is a live region for screen readers. The docs no longer promise a line number on every error or full Markdown support in the viewer. --- CHANGELOG.md | 2 +- docs/how-it-works.md | 4 ++-- docs/speaker-notes.md | 7 +++++-- src/akceo/assets/md-viewer.html | 31 ++++++++++++++++++++----------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333e60d..11d834f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project are documented here. The format follows - Five layouts: `title`, `bullets`, `split`, `steps` and `table`. - Built-in `midnight` and `paper` themes. Custom themes are CSS files that set akceo's tokens. - PNG, JPEG and WebP images are shrunk and embedded; SVG is embedded as-is. -- Build errors name the file, line and slide. +- Deck errors name the file, line and slide; theme and image errors name the file. - `akceo viewer` writes `md-viewer.html`, a speaker-notes viewer for a second browser tab. In Chrome and Edge it refreshes live when the notes file changes. - Example speaker notes for the demo deck. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index abdcafd..91fceff 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -180,8 +180,8 @@ sequenceDiagram JavaScript and images are all inside it. - **One runtime dependency.** Pillow, for shrinking images. Everything else is the Python standard library, and the page uses plain JavaScript with no framework. -- **Fail loudly and precisely.** Input is checked before anything is written, and every error - names the file and line. +- **Fail loudly and precisely.** Input is checked before anything is written. Every error names + the file it's about, and errors in the deck also give the line and slide. - **Content and look are separate.** The deck says what's on each slide; the theme alone decides colors and fonts. diff --git a/docs/speaker-notes.md b/docs/speaker-notes.md index 3e64141..d022f25 100644 --- a/docs/speaker-notes.md +++ b/docs/speaker-notes.md @@ -62,7 +62,7 @@ web server. ## Notes format -Any Markdown works. This layout reads well in the viewer: +The viewer renders a common subset of Markdown, listed below. This layout reads well in it: ```markdown # Speaker Notes · My talk @@ -81,7 +81,7 @@ What to say on the first slide. - An italic part in a heading, like `*(0:30)*`, shows in small amber type. It's a good spot for a time budget. -The viewer handles: +The viewer renders: - headings - paragraphs - **bold**, *italic* and `code` @@ -93,6 +93,9 @@ The viewer handles: - fenced code blocks - horizontal rules +Raw HTML shows as plain text, and other Markdown extensions, such as strikethrough, task lists +and footnotes, aren't rendered. + Only `http`, `https`, `mailto` and relative links become clickable. Links open in a new tab. Images use `![alt](path)`. A relative path resolves from the folder that holds `md-viewer.html`, diff --git a/src/akceo/assets/md-viewer.html b/src/akceo/assets/md-viewer.html index 3246698..b08fe14 100644 --- a/src/akceo/assets/md-viewer.html +++ b/src/akceo/assets/md-viewer.html @@ -66,7 +66,7 @@
No file loaded - +
@@ -202,24 +202,33 @@ main.scrollTop=keepScroll?top:0; } - var handle=null,lastMod=0; + // Reads finish out of order. Each open bumps the generation, and a read only renders if its + // generation is still current, so the latest selection always wins. + var handle=null,lastMod=0,generation=0,polling=false; function openHandle(h){ + var gen=++generation; return h.getFile().then(function(f){ - return f.text().then(function(t){handle=h;lastMod=f.lastModified;render(t,f.name,false);setStatus('● watching','on');}); + return f.text().then(function(t){ + if(gen!==generation)return; + handle=h;lastMod=f.lastModified;render(t,f.name,false);setStatus('● watching','on'); + }); }); } function openFile(f){ - handle=null; - f.text().then(function(t){render(t,f.name,false);setStatus(canWatch?'not watching · use Open to watch':'drop again to refresh');}); + var gen=++generation;handle=null; + f.text().then(function(t){ + if(gen!==generation)return; + render(t,f.name,false);setStatus(canWatch?'not watching · use Open to watch':'drop again to refresh'); + }); } setInterval(function(){ - if(!handle)return; - var h=handle; + if(!handle||polling)return; + var h=handle,gen=generation;polling=true; h.getFile().then(function(f){ - if(h!==handle||f.lastModified===lastMod)return; - lastMod=f.lastModified; - return f.text().then(function(t){render(t,f.name,true);}); - }).catch(function(){if(h===handle){handle=null;setStatus('file unavailable · open it again','warn');}}); + if(gen!==generation||f.lastModified===lastMod)return; + return f.text().then(function(t){if(gen!==generation)return;lastMod=f.lastModified;render(t,f.name,true);}); + }).catch(function(){if(gen===generation){handle=null;setStatus('file unavailable · open it again','warn');}}) + .then(function(){polling=false;}); },1500); document.getElementById('open').onclick=function(){