diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5ea3a..11d834f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,4 +12,9 @@ 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. +- 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..91fceff --- /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. 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. + +### 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..d022f25 --- /dev/null +++ b/docs/speaker-notes.md @@ -0,0 +1,108 @@ +# 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 + +The viewer renders a common subset of Markdown, listed below. This layout reads well in it: + +```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 renders: +- headings +- paragraphs +- **bold**, *italic* and `code` +- links +- images +- bulleted and numbered lists, nested by indenting +- tables +- quotes +- 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`, +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..b08fe14 --- /dev/null +++ b/src/akceo/assets/md-viewer.html @@ -0,0 +1,257 @@ + + + + + +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)}"