From 7686315d6093a6819bdea743ab883b001c560ea4 Mon Sep 17 00:00:00 2001 From: Rick Hightower Date: Thu, 22 Oct 2026 15:16:58 -0500 Subject: [PATCH 1/3] feat: draw themed tables in PDF export 01M5FSD7MA4Z0KY4ZRWRV0XJZ3 PDF export printed each table row as one line of spaced text. It now draws a grid with jsPDF: a filled bold header row, striped body rows, cell borders, and text that wraps in each cell. The header row repeats on each page that a long table runs onto. Export themes get five table colors: tableHeaderBg, tableHeaderText, tableRowBg, tableRowAltBg, and tableBorder. PDF, Word, and the export preview all use them. Ice Snowstorm has a navy header and ice stripes. Clay has a terracotta header and warm stripes. The Themes dialog has a Tables group to edit them. A theme saved before this change keeps its old table look. Also files the DMG build failure, the flaky E2E journeys, and the missing wiki publish step. Co-Authored-By: Claude Opus 5.5 --- .work/todo.jsonl | 19 ++++ CHANGELOG.md | 14 +++ README.md | 7 +- docs/designs/current_design_doc.md | 10 +- docs/plans/2026-10-22-pdf-tables.md | 170 ++++++++++++++++++++++++++++ docs/roadmap.md | 89 +++++++++++++-- docs/user_guide/user-guide.md | 15 +++ e2e/export.spec.ts | 65 +++++++++++ e2e/workspace.ts | 2 + src/components/Themes/dialogs.tsx | 18 +++ src/index.css | 7 ++ src/lib/export.test.ts | 124 +++++++++++++++++++- src/lib/exportDocx.ts | 22 +++- src/lib/exportHtml.ts | 6 +- src/lib/exportPdf.ts | 79 ++++++++++++- src/lib/themes.ts | 48 +++++++- wireframes/themes.md | 67 +++++++++++ 17 files changed, 735 insertions(+), 27 deletions(-) create mode 100644 docs/plans/2026-10-22-pdf-tables.md create mode 100644 wireframes/themes.md diff --git a/.work/todo.jsonl b/.work/todo.jsonl index 424329d..26b72b3 100644 --- a/.work/todo.jsonl +++ b/.work/todo.jsonl @@ -333,3 +333,22 @@ {"actor":"richardhightower","ev":"01M5GRYQY7EZMDFZQ3BKVPJRVQ","git":"a2f4dcd","item":"01M5GRYQY7ZAK975G44M0G6T6N","op":"create","set":{"acceptance_criteria":"bun test src/lib/workspaceSynthesis.test.ts src/lib/ai/run.test.ts src/lib/ReadmeGenerator.test.ts passes, and the CI unit job passes on main.","body":"workspaceSynthesis.test.ts spies on callLLMFromUI in stubLLM() and never restores the spy. Tests in other files then see the old calls and fail. Which test fails depends on the order in which Bun runs the files, so the bug showed up only after PR 63 added test files. It fails CI on PR 63 and PR 64.","discovered_during":"01M5FT7DV6ZMNBXWA883G6CDC8","estimate":"XS","kind":"bug","labels":["tests","ci"],"level":"task","owner":"Rick Hightower","priority":"P1","status":"todo","title":"Restore the LLM spy after each workspace synthesis test","unplanned":true},"ts":"2026-10-22T03:02:28Z"} {"actor":"richardhightower","ev":"01M5GTCRJQ4SQPCBRC58ETWECP","git":"b14bcab","item":"01M5GTCRJQPGX82TS89Y93732Z","op":"create","set":{"acceptance_criteria":"A change to src/**/*.test.ts alone passes the UI Guard. A change to src/App.tsx with no wireframes/ update still fails it.","body":"scripts/check-ui-guard.sh treats every .ts file under src/ as UI source, including unit test files. A test-only fix then fails the UI Guard job and asks for a wireframe that cannot exist. Test files have no UI.","discovered_during":"01M5GRYQY7ZAK975G44M0G6T6N","estimate":"XS","kind":"bug","labels":["ci","ui-guard"],"level":"task","owner":"Rick Hightower","priority":"P2","status":"todo","title":"Stop the UI Guard from treating test files as UI source","unplanned":true},"ts":"2026-10-22T03:27:36Z"} {"actor":"richardhightower","ev":"01M5J4C2147SY139VY006Q6E54","item":"01M5J4C2142M28BJZPBSWJ4Y04","op":"create","set":{"body":"Stamp CHANGELOG 0.6.6 (reload, themed PDF and Word export, themes), lockstep Cargo.toml / Cargo.lock / tauri.conf to 0.6.6, freeze the roadmap snapshot, tag and GitHub release after merge, then install the build locally.","kind":"ops","level":"task","milestone":"v0.6.6","priority":"P2","status":"todo","title":"chore(release): v0.6.6 changelog, version bump, roadmap snapshot"},"ts":"2026-10-22T15:41:13Z"} +{"actor":"richardhightower","ev":"01M5JJZFC18WW6HYA0AVXZSGDN","item":"01M5JJZFC11HAXZHJJ6N10S79G","op":"create","set":{"body":"PDF export prints tables as spaced text. This work draws real tables in the PDF and adds table colors (header, row, alternate row, border) to export themes, so PDF, Word, and the preview all match.","kind":"feature","level":"epic","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P1","status":"todo","title":"Themed tables in PDF export"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1C30T19BW61RM33EH","item":"01M5JJZFC1Y253JZSTV70R3S3P","op":"create","set":{"body":"Legacy custom themes fall back to their own codeBg, heading, page, and muted colors.","kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P1","status":"todo","title":"Add the five table color fields to ExportTheme and both builtins"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1YC4BYFX3AR7BWTF7","item":"01M5JJZFC136XTFWX2GB74JF2W","op":"create","set":{"body":"Hand-written with jsPDF. No new dependency.","kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P1","status":"todo","title":"Draw PDF tables with borders, a header row, stripes, wrapping, and a repeated header"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1H45N2A94ECPMAQAF","item":"01M5JJZFC1GTE6MWBQE89529RQ","op":"create","set":{"kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P2","status":"todo","title":"Use the table fields in the Word export and the HTML preview"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1TKY4T8BVWF8AW9XP","item":"01M5JJZFC14JQC2NNTQKG2B4GV","op":"create","set":{"kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P2","status":"todo","title":"Add a Tables group to the Themes dialog with a wireframe"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1JJJV4XDQFB84VYBD","item":"01M5JJZFC1QWPY3EV9WCNZVC01","op":"create","set":{"kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P1","status":"todo","title":"Unit and Playwright tests for themed tables"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1FQ5FJGAJ8Z0T2HYK","item":"01M5JJZFC1MD61SVP5QBX9Q9JR","op":"create","set":{"kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P2","status":"todo","title":"Update the user guide, README, design doc, changelog, and wiki"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZFC1P1RR4ZH8HYRFC63F","item":"01M5JJZFC1B9P22KTJF6JS378H","op":"create","set":{"kind":"feature","level":"task","parent":"01M5JJZFC11HAXZHJJ6N10S79G","plan":"docs/plans/2026-10-22-pdf-tables.md","priority":"P2","status":"todo","title":"Clean up the v0.6.6 branches and file the DMG build failure"},"ts":"2026-10-22T19:56:29Z"} +{"actor":"richardhightower","ev":"01M5JJZKJCA9TEEQRS3HA62QBY","item":"01M5JJZFC1Y253JZSTV70R3S3P","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKM9BBMK8R40NRKMCSMA","item":"01M5JJZFC136XTFWX2GB74JF2W","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKP7B97HWN4HVR1PEGFN","item":"01M5JJZFC1GTE6MWBQE89529RQ","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKR55PJM3PQ1TMWBNN1X","item":"01M5JJZFC14JQC2NNTQKG2B4GV","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKT15114PY8YWPYYYTBA","item":"01M5JJZFC1QWPY3EV9WCNZVC01","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKW1QX7M4JAS67XWAK62","item":"01M5JJZFC1MD61SVP5QBX9Q9JR","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZKY0V4VXYK5H56FKCFND","item":"01M5JJZFC1B9P22KTJF6JS378H","op":"update","set":{"milestone":"v0.6.7"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZM00XP21G71CGCDJ4HM1","item":"01M5FSD7MA4Z0KY4ZRWRV0XJZ3","op":"update","set":{"milestone":"v0.6.7","status":"in_progress"},"ts":"2026-10-22T19:56:34Z"} +{"actor":"richardhightower","ev":"01M5JJZRM89V5P4FQFJPDNJCMF","item":"01M5JJZRM87A0Y5YFFHE8WF2GR","op":"create","set":{"body":"The v0.6.6 release build made Motion.app, but the DMG step in bundle_dmg.sh failed. The app itself is fine. Releases attach no files, so nothing is missing yet. Find the cause, so a release can attach a DMG.","discovered_during":"01M5J4C2142M28BJZPBSWJ4Y04","kind":"bug","level":"task","priority":"P2","status":"todo","title":"bun tauri build fails at the DMG step (bundle_dmg.sh)","unplanned":true},"ts":"2026-10-22T19:56:39Z"} +{"actor":"richardhightower","ev":"01M5JKR1P3MVZ7FXQ9FB3DDPTT","item":"01M5JKR1P3V9VD5TGSFYFXCV6D","op":"create","set":{"body":"Full Playwright runs fail one to four tests that pass when run again. On main the same day, persistence 'a new note is created, listed, and opens' failed once in a full run. On the PDF table branch, the dataset JOIN, both new-note persistence tests, and the sanitize test failed in one run and passed in the next. The machine load average was about 6. The tests use 5 s waits and retries is 0. Find the race, or give these waits more time.","discovered_during":"01M5JJZFC1QWPY3EV9WCNZVC01","kind":"bug","level":"task","priority":"P2","status":"todo","title":"E2E: new-note and dataset journeys fail at random under load","unplanned":true},"ts":"2026-10-22T20:09:55Z"} +{"actor":"richardhightower","ev":"01M5JKV9HQX2E03XRF92RPCKHD","item":"01M5JKV9HQZ7X83CD6X1SMP6VV","op":"create","set":{"body":"CLAUDE.md says to publish docs to the GitHub wiki. worklog ia-render writes publish-manifest.json (203 pages), but nothing copies the doc pages into .work/wiki-checkout and pushes them. The wiki checkout has 100 pages, and its last publish commit is from 2026-07-29. Add a publish command, or change the Definition of Done to match what really happens.","discovered_during":"01M5JJZFC1MD61SVP5QBX9Q9JR","kind":"ops","level":"task","priority":"P2","status":"todo","title":"Wiki publish: no script applies publish-manifest.json to the wiki","unplanned":true},"ts":"2026-10-22T20:11:41Z"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff35ab..7841e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to Motion are recorded here. Dates are UTC. ## Unreleased +### Added + +- **Themed tables in export.** Each theme now sets five table colors: header, + header text, row, alternate row, and border. **Themes** has a **Tables** + group to edit them. Ice Snowstorm has a navy header and ice stripes. Clay + has a terracotta header and warm stripes. + +### Fixed + +- **PDF tables** were plain spaced text. They are now a grid with borders, a + bold filled header row, striped body rows, and text that wraps in each cell. + The header row repeats on each new page. Word and the preview use the same + theme colors. + ## 0.6.6, 2026-10-22 Reload a note from disk, and export it as a themed PDF or Word file. diff --git a/README.md b/README.md index 673c9e4..42bade7 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ natural-language prompts using CLI tools you already have installed. previews the page first. Ice Snowstorm (ice page, black text, navy headings, blue rules) and Clay (Anthropic ivory paper, terracotta accent) are built in. **Themes** adds more and sets the export default. PDF uses standard fonts; - Word uses Calibri or Georgia. + Word uses Calibri or Georgia. Tables export as a bordered grid with a filled + header row and striped body rows in both PDF and Word. Each theme sets the + table header, header text, row, alternate row, and border colors. - **Zoom** — ⌘+ / ⌘- / ⌘0 scale the editor and the file tree, not the header. A brief overlay shows the percentage. Remembered in the settings file. - **Layout** — the editor fills the window. Drag the sidebar edge or the Split @@ -165,6 +167,9 @@ Recorded here rather than discovered later: - **PDF export** uses Helvetica, Times, or Courier. Word uses Calibri, Georgia, or Consolas. The on-screen preview uses system fonts, so it can look slightly closer to the theme than the PDF does. +- **PDF tables** size each column by its longest cell text, counted in + characters, so a column of wide letters can wrap early. A single table row + taller than a page runs off the page. The header row repeats on each page. See [CHANGELOG.md](CHANGELOG.md) for release history and [docs/roadmap.md](docs/roadmap.md) for what is planned. diff --git a/docs/designs/current_design_doc.md b/docs/designs/current_design_doc.md index 070cc69..aa4908a 100644 --- a/docs/designs/current_design_doc.md +++ b/docs/designs/current_design_doc.md @@ -102,7 +102,15 @@ unit → E2E). Snowstorm or Clay, plus custom themes in settings). The dialog iframe is the preview; `exportPdf` / `exportDocx` build the download from the same block list. Desktop settings pass `exportThemeId` and `themes` through - `get_settings` / `set_settings`. + `get_settings` / `set_settings`. Rust stores `themes` as opaque JSON, so + `sanitizeTheme` in `src/lib/themes.ts` is the one schema. Tables use five + theme colors: `tableHeaderBg`, `tableHeaderText`, `tableRowBg`, + `tableRowAltBg`, and `tableBorder`. A theme with no table keys gets its + pre-0.6.7 look (header `codeBg` and `heading`, rows `page`, no stripes, + border `muted`). A bad hex in one table key falls back to the default + table color. The + PDF grid is hand-drawn with jsPDF (`drawTable` in `exportPdf.ts`) and + repeats the header row after a page break. --- diff --git a/docs/plans/2026-10-22-pdf-tables.md b/docs/plans/2026-10-22-pdf-tables.md new file mode 100644 index 0000000..05d8218 --- /dev/null +++ b/docs/plans/2026-10-22-pdf-tables.md @@ -0,0 +1,170 @@ +--- +date: 2026-10-22 +slug: pdf-tables +title: Themed tables in PDF export +epic: 01M5JJZFC11HAXZHJJ6N10S79G +items: [01M5JJZFC1Y253JZSTV70R3S3P, 01M5JJZFC136XTFWX2GB74JF2W, 01M5JJZFC1GTE6MWBQE89529RQ, 01M5JJZFC14JQC2NNTQKG2B4GV, 01M5JJZFC1QWPY3EV9WCNZVC01, 01M5JJZFC1MD61SVP5QBX9Q9JR, 01M5JJZFC1B9P22KTJF6JS378H] +--- + +# Add themed table support to PDF export, clean up v0.6.6, and release v0.6.7 + +## Context + +The PDF export does not draw tables. In `src/lib/exportPdf.ts:125`, the PDF exporter writes each table row as one line of text, with the cells joined by three spaces. Word export (`src/lib/exportDocx.ts:166`) and the HTML preview (`src/lib/exportHtml.ts:38`) draw real tables. Their colors are borrowed from `codeBg`, `heading`, and `muted`. No theme field controls table style. + +The v0.6.6 release notes list this defect as work item `01M5FSD7MA4Z0KY4ZRWRV0XJZ3` ("draws tables with no borders"). This plan closes that item. + +The outcome: + +- PDF export draws real tables with a header row, cell borders, alternating row colors, and text that wraps inside each cell. +- `ExportTheme` gets table fields. You can edit them in the **Themes** dialog. +- Ice Snowstorm and Clay both ship with header and alternating row styles. +- PDF, Word, and the preview all read the same table fields, so the preview still matches the downloaded file. +- The open items from the v0.6.6 release are closed or filed. +- v0.6.7 ships with the release process that v0.6.6 used. + +## Set up the worktree + +v0.6.6 is on `main` at `5358991` and includes all of the theme work. Branch from `origin/main`: + +```bash +git fetch origin +git worktree add ../motion-pdf-tables -b feat/pdf-tables origin/main +cd ../motion-pdf-tables && bun install +``` + +Open the PR against `main`. + +## Add table fields to the theme + +File: `src/lib/themes.ts`. + +1. Add five color fields to `ExportTheme`: + + | Field | Use | + | --- | --- | + | `tableHeaderBg` | Header row fill | + | `tableHeaderText` | Header row text, bold | + | `tableRowBg` | Odd body row fill | + | `tableRowAltBg` | Even body row fill (the alternating stripe) | + | `tableBorder` | Cell border lines | + + Body cell text uses the existing `text` field. + +2. Set the builtin values: + + | Field | Ice Snowstorm | Clay | + | --- | --- | --- | + | `tableHeaderBg` | `#1D4E89` (accent) | `#D97757` (clay) | + | `tableHeaderText` | `#FFFFFF` | `#141413` | + | `tableRowBg` | `#FFFFFF` | `#FAF9F5` | + | `tableRowAltBg` | `#E4EDF7` | `#F0EEE6` | + | `tableBorder` | `#B8C7DB` | `#E8E6DC` | + + The header text contrast is 8.4:1 on Ice and 5.9:1 on Clay. Both pass WCAG AA. + +3. In `sanitizeTheme`, read the new fields with `colorOf`. For a field that is missing, fall back to the theme's own colors, not to `CLAY`: `codeBg`, `heading`, `page`, `codeBg`, and `muted`. A custom theme saved by v0.6.6 then exports exactly as it does today. Keep `colorOf` and `parseHex` as the only validation. + +## Draw tables in the PDF + +File: `src/lib/exportPdf.ts`. Do not add a dependency. `jspdf-autotable` is not installed, and the drawing code is about 60 lines with the existing `jsPDF` calls. + +Replace the fallthrough table branch at line 125 with `drawTable(cursor, block)`: + +1. Find the column count from the widest row. Pad short rows with empty cells. +2. Give each column a width in proportion to its longest cell text, clamped to 4 to 40 characters, across `PAGE_W - 2 * MARGIN`. Mark this with a `ponytail:` comment. The comment names the ceiling (a character count, not a measured width) and the upgrade path (`doc.getTextWidth`). +3. For each row, wrap each cell with `doc.splitTextToSize(text, colWidth - 2 * PAD)`. The row height is the tallest cell plus padding. +4. Call the existing `ensure(cursor, rowHeight)`. If `ensure` adds a page before a body row, draw the header row again at the top of the new page. +5. Fill the row background with `tableHeaderBg`, `tableRowBg`, or `tableRowAltBg`. Draw each cell border with `rect(..., "S")` in `tableBorder` at 0.5 pt. Draw the text with the existing `PDF_FONT` mapping, bold for the header. +6. Use 10 pt text, matching the current table size. Reuse `hexToRgb` for all colors. + +A single cell that is taller than one page overflows the page. Mark it with a `ponytail:` comment. The fix is to split the cell lines across pages, if a real note needs it. + +## Use the fields in Word and the preview + +- `src/lib/exportDocx.ts`, `table()`: header shading `tableHeaderBg` and header text `tableHeaderText`. Even body rows get `tableRowAltBg` shading, and odd body rows get `tableRowBg`. Set the table `borders` (top, bottom, left, right, insideHorizontal, insideVertical) to `tableBorder`. Keep `hexBare`. +- `src/lib/exportHtml.ts`, the ` diff --git a/src/lib/exportPdf.ts b/src/lib/exportPdf.ts index 6956b8c..c913ff5 100644 --- a/src/lib/exportPdf.ts +++ b/src/lib/exportPdf.ts @@ -122,10 +122,81 @@ function drawBlock(cursor: Cursor, block: ExportBlock) { cursor.y += 12; return; } - for (const row of [block.header, ...block.rows]) { - writeLines(cursor, row.join(" "), 10, theme.font, "normal", theme.text, 0); - } - cursor.y += 6; + drawTable(cursor, block); +} + +const TABLE_SIZE = 10; +const TABLE_LINE = 1.3; +const CELL_PAD = 5; + +interface TableRowLayout { + lines: string[][]; + height: number; +} + +/** + * Bordered grid with a filled header row and striped body rows. The header + * repeats at the top of each page the table runs onto. + */ +function drawTable(cursor: Cursor, block: ExportBlock & { type: "table" }) { + const { doc, theme } = cursor; + const cols = Math.max(block.header.length, ...block.rows.map((row) => row.length)); + if (cols === 0) return; + const pad = (row: string[]) => Array.from({ length: cols }, (_, i) => row[i] ?? ""); + const header = pad(block.header); + const rows = block.rows.map(pad); + // ponytail: widths follow character counts, not measured text. Weigh by doc.getTextWidth if columns look off. + const weights = header.map((_, i) => Math.min(40, Math.max(4, ...[header, ...rows].map((row) => row[i]!.length)))); + const total = weights.reduce((sum, weight) => sum + weight, 0); + const widths = weights.map((weight) => (weight / total) * (PAGE_W - MARGIN * 2)); + const lineH = TABLE_SIZE * TABLE_LINE; + // cursor.y is the next text baseline. The grid works from the row top. + let top = cursor.y - TABLE_SIZE; + + const layout = (cells: string[], style: Style): TableRowLayout => { + doc.setFont(PDF_FONT[theme.font], style); + doc.setFontSize(TABLE_SIZE); + const lines = cells.map((cell, i) => doc.splitTextToSize(cell || " ", widths[i]! - CELL_PAD * 2) as string[]); + return { lines, height: Math.max(...lines.map((cell) => cell.length)) * lineH + CELL_PAD * 2 }; + }; + + const paint = (row: TableRowLayout, style: Style, fill: string, color: string) => { + doc.setFont(PDF_FONT[theme.font], style); + doc.setFontSize(TABLE_SIZE); + doc.setDrawColor(...hexToRgb(theme.tableBorder)); + doc.setLineWidth(0.5); + let x = MARGIN; + row.lines.forEach((lines, i) => { + doc.setFillColor(...hexToRgb(fill)); + doc.rect(x, top, widths[i]!, row.height, "FD"); + doc.setTextColor(...hexToRgb(color)); + doc.text(lines, x + CELL_PAD, top + CELL_PAD + TABLE_SIZE * 0.8, { lineHeightFactor: TABLE_LINE }); + x += widths[i]!; + }); + top += row.height; + }; + + // ponytail: a single row taller than a page overflows it. Split the cell lines across pages if a note needs that. + const fits = (height: number) => top + height <= PAGE_H - MARGIN; + const newPage = () => { + doc.addPage(); + paintPage(cursor); + top = MARGIN; + }; + const head = layout(header, "bold"); + const paintHead = () => paint(head, "bold", theme.tableHeaderBg, theme.tableHeaderText); + const first = rows[0] ? layout(rows[0], "normal").height : 0; + if (!fits(head.height + first)) newPage(); + paintHead(); + rows.forEach((cells, i) => { + const row = layout(cells, "normal"); + if (!fits(row.height)) { + newPage(); + paintHead(); + } + paint(row, "normal", i % 2 === 1 ? theme.tableRowAltBg : theme.tableRowBg, theme.text); + }); + cursor.y = top + TABLE_SIZE + 10; } function writeRuns(cursor: Cursor, runs: InlineRun[]) { diff --git a/src/lib/themes.ts b/src/lib/themes.ts index e3ee95e..afc9e42 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -18,6 +18,12 @@ export interface ExportTheme { muted: string; codeBg: string; codeText: string; + tableHeaderBg: string; + tableHeaderText: string; + tableRowBg: string; + /** Every second body row, for the striped look. */ + tableRowAltBg: string; + tableBorder: string; font: ThemeFont; headingFont: ThemeFont; } @@ -33,6 +39,11 @@ export const ICE_SNOWSTORM: ExportTheme = { muted: "#4A6280", codeBg: "#E4EDF7", codeText: "#0B1F4B", + tableHeaderBg: "#1D4E89", + tableHeaderText: "#FFFFFF", + tableRowBg: "#FFFFFF", + tableRowAltBg: "#E4EDF7", + tableBorder: "#B8C7DB", font: "sans", headingFont: "sans", }; @@ -49,6 +60,11 @@ export const CLAY: ExportTheme = { muted: "#5E5D59", codeBg: "#F0EEE6", codeText: "#141413", + tableHeaderBg: "#D97757", + tableHeaderText: "#141413", + tableRowBg: "#FAF9F5", + tableRowAltBg: "#F0EEE6", + tableBorder: "#E8E6DC", font: "sans", headingFont: "serif", }; @@ -95,6 +111,25 @@ function colorOf(value: unknown, fallback: string): string { return typeof value === "string" ? (parseHex(value) ?? fallback) : fallback; } +const TABLE_KEYS = ["tableHeaderBg", "tableHeaderText", "tableRowBg", "tableRowAltBg", "tableBorder"] as const; + +type TableColors = Pick; + +/** The table look themes had before table colors existed: tinted header, plain rows. */ +function legacyTable(page: string, heading: string, muted: string, codeBg: string): TableColors { + return { tableHeaderBg: codeBg, tableHeaderText: heading, tableRowBg: page, tableRowAltBg: page, tableBorder: muted }; +} + +function tableColors(raw: Record, fallback: TableColors): TableColors { + return { + tableHeaderBg: colorOf(raw.tableHeaderBg, fallback.tableHeaderBg), + tableHeaderText: colorOf(raw.tableHeaderText, fallback.tableHeaderText), + tableRowBg: colorOf(raw.tableRowBg, fallback.tableRowBg), + tableRowAltBg: colorOf(raw.tableRowAltBg, fallback.tableRowAltBg), + tableBorder: colorOf(raw.tableBorder, fallback.tableBorder), + }; +} + export function sanitizeTheme(value: unknown, fallback: ExportTheme): ExportTheme | null { if (!value || typeof value !== "object") return null; const raw = value as Record; @@ -102,17 +137,22 @@ export function sanitizeTheme(value: unknown, fallback: ExportTheme): ExportThem const name = typeof raw.name === "string" ? raw.name.trim() : ""; if (!id || !name || isBuiltinThemeId(id)) return null; if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(id)) return null; + const page = colorOf(raw.page, fallback.page); + const heading = colorOf(raw.heading, fallback.heading); + const muted = colorOf(raw.muted, fallback.muted); + const codeBg = colorOf(raw.codeBg, fallback.codeBg); return { id, name: name.slice(0, 40), builtin: false, - page: colorOf(raw.page, fallback.page), + page, text: colorOf(raw.text, fallback.text), - heading: colorOf(raw.heading, fallback.heading), + heading, accent: colorOf(raw.accent, fallback.accent), - muted: colorOf(raw.muted, fallback.muted), - codeBg: colorOf(raw.codeBg, fallback.codeBg), + muted, + codeBg, codeText: colorOf(raw.codeText, fallback.codeText), + ...tableColors(raw, TABLE_KEYS.some((key) => key in raw) ? fallback : legacyTable(page, heading, muted, codeBg)), font: fontOf(raw.font, fallback.font), headingFont: fontOf(raw.headingFont, fallback.headingFont), }; diff --git a/wireframes/themes.md b/wireframes/themes.md new file mode 100644 index 0000000..9ac05ce --- /dev/null +++ b/wireframes/themes.md @@ -0,0 +1,67 @@ +# Screen: Theme settings + +## Goal + +You pick an export theme, or make your own, and set the colors that PDF and Word export use. This includes the table colors. + +## Layout + +The **Theme settings** dialog opens from the **Themes** button in the header. + +``` ++------------------------------------------------------------+ +| Themes | +| Ice Snowstorm is navy and ice. Clay is ... | ++------------------+-----------------------------------------+ +| [Ice Snowstorm] | Name [Harbor ] | +| [Clay] | Page [#] [#F3F6FB] | +| [Harbor] | Text [#] [#111111] | +| | Headings [#] [#0B1F4B] | +| | Accent [#] [#1D4E89] | +| | Tables | +| | Table header [#] [#1D4E89] | +| | Table header text [#] [#FFFFFF] | +| | Table row [#] [#FFFFFF] | +| | Table alternate row [#] [#E4EDF7] | +| | Table border [#] [#B8C7DB] | +| | Body font [sans v] | +| | Heading font [sans v] | +| | (o) Use for export | ++------------------+-----------------------------------------+ +| [New theme] [Delete] [Cancel] [Save themes] | ++------------------------------------------------------------+ +``` + +## Key elements + +| Element | Type | Behavior or notes | +| --- | --- | --- | +| Theme list | Toggle buttons | Selects the theme that the form shows. | +| Color fields | Color picker and hex input | One pair per color. Disabled for builtin themes. | +| **Tables** | Group heading | Groups the five table colors. It is a heading, not a control. | +| **Table header** | Color field | Header row fill in the preview, PDF, and Word. | +| **Table header text** | Color field | Header row text. The header text is bold. | +| **Table row** | Color field | Fill for odd body rows. | +| **Table alternate row** | Color field | Fill for even body rows, for the stripes. | +| **Table border** | Color field | Cell border lines. | + +## States + +- **Default**: The form shows the selected theme. Builtin themes show all values, disabled. +- **Empty**: Not applicable. The two builtins always exist. +- **Loading**: Not applicable. The themes come from settings that are already loaded. +- **Error**: A hex value that does not parse falls back to the default color for that field when you save. + +## Acceptance criteria + +- [x] The five table color fields show under a **Tables** heading, after **Accent**. +- [x] Each table field has the accessible names `