diff --git a/docs/PAGEDJS_IMAGE_STALL.md b/docs/PAGEDJS_IMAGE_STALL.md new file mode 100644 index 0000000..068ddf5 --- /dev/null +++ b/docs/PAGEDJS_IMAGE_STALL.md @@ -0,0 +1,89 @@ +# Paged.js stalls on an image at a page boundary — fixed + +Findings from the Phase D investigation in `docs/V2_FRONTEND_PROMPT.md` §5, which +started from `docs/PAGEDJS_RTL_DEBUG_PROMPT.md`. The original hypothesis there — +`overflow-x: auto` + `dir=rtl` preventing the chunker from converging — is +**wrong**. The trigger is narrower and has nothing to do with RTL. + +**Status: fixed.** `src/pdf/flattenImages.ts` swaps every `` for an +equivalent `background-image` box before pagination. The `image-stall` fixture +went from an unbounded hang to 3 pages in under 3 seconds, and the visual suite +is green at 26/26. + +## What happened + +Paged.js 0.4.3 laid out two pages and then stopped dead. The page count never +grew, `PagedConfig.after` never fired, and nothing threw. It was a **stall, not +a runaway loop** — an important distinction, because a runaway would have been +capped by a page limit. + +The 45 s watchdog in `src/pdf/renderPaged.ts` turned this into a clean error +rather than a frozen tab. That safety net stays. + +## Repro + +`tests/visual/fixtures/image-stall.md` — the minimal case, bisected down from the +`checklists` fixture. Seven sections of ordinary content followed by a paragraph +containing one 1×1 GIF data-URI image, which has to be placed at a page +boundary. + +```bash +SCRIPTO_VISUAL=1 npx vitest run tests/visual/layout.test.ts +``` + +`layout.test.ts` now carries `paginates an image sitting on a page boundary` as +an ordinary regression test. + +## Ruled out + +Each of these was tested against the minimal repro and did **not** fix the stall: + +| Hypothesis | Result | +| --- | --- | +| RTL / `dir=rtl` | Not involved — the repro is pure LTR | +| Task lists / checkbox items | Not involved — a plain paragraph stalls identically | +| `break-inside: avoid` on `img` | Still stalls with `break-inside: auto` | +| `overflow-x: auto` on `pre` / `.table-wrap` | Not involved; already `overflow: visible` while paginating | +| Unresolved image box | Still stalls with an explicit `width`/`height` in the page CSS | +| `display: block` + `margin: auto` | Still stalls as `display: inline; margin: 0` | +| Image not decoded before layout | Still stalls after awaiting every image | +| Image is the last node in the document | Still stalls with a paragraph and a whole section after it | +| Wrapping the `img` in an explicitly sized `div` | Still stalls | + +## The cause + +Replacing the `` with **any non-replaced element** — a `div` carrying the +same picture as a `background-image` — paginates cleanly, every time. The +chunker's difficulty is with the replaced element itself. + +## Why the fix was safe to ship this time + +The workaround was written up but held back because browsers do not print +background images unless `print-color-adjust: exact` applies, and Chrome's +"Background graphics" toggle is **off by default**. Getting that wrong would +silently drop images from exported PDFs — far worse than the stall. + +That objection is now closed. `.pagedjs_page, .pagedjs_page *` carry +`print-color-adjust: exact`, and the flattened boxes set it inline as well, so +the picture no longer depends on the dialog. Verified end to end by driving the +real export over the DevTools Protocol with `printBackground: false`: the +exported PDF contains the image as a 64×64 XObject and renders it correctly. + +## Two ordering bugs found along the way + +Both were real, and either alone would have defeated the workaround: + +1. **`preloadImages` ran *after* `buildExportContent`**, so the DOM transforms + saw images that had not decoded and reported no intrinsic size. +2. **The renderer marks images `loading="lazy"`.** Anything below the fold never + loads, and an export clone is never scrolled — so those images reported no + intrinsic size at all. `preloadImages` now forces `loading="eager"` and + `decoding="sync"` on the clone before waiting. + +## Sizing + +`flattenImages` sizes each box from the image's **intrinsic** dimensions, not a +measured box: it runs against the editor pane, whose width is not the printed +page's width, so a baked pixel size would be wrong on paper. `width` + +`max-width: 100%` + `aspect-ratio` reproduces how an `` with `height: auto` +behaves in flow. An image with no intrinsic size is left as an ``. diff --git a/docs/QA_VERIFICATION_PROMPT.md b/docs/QA_VERIFICATION_PROMPT.md new file mode 100644 index 0000000..2831847 --- /dev/null +++ b/docs/QA_VERIFICATION_PROMPT.md @@ -0,0 +1,170 @@ +# Prompt — Independently verify the v2 + handwriting work + +> Paste into a **fresh** Claude Code session with a browser available. Your job is to +> **test, not to build.** Do not implement features. Fix only what you can prove is broken, +> and say so explicitly when you do. +> +> Assume nothing in this document is true. Everything below is a *claim made by the +> engineer who wrote the code*. Your job is to confirm or refute each one. + +--- + +## 0. Ground rules + +- Work on branch `dev`. The baseline for every "did this regress?" question is **`bc6eba0`** + (the commit before this work started). Create a worktree for it: + ```bash + git worktree add /tmp/scripto-base bc6eba0 + ln -sfn "$PWD/node_modules" /tmp/scripto-base/node_modules + ``` +- **Report per commit.** For each of the 14 commits below: PASS / FAIL / NOT VERIFIED, with + the evidence. "Looks fine" is not evidence — a screenshot, a test name, or a measured + number is. +- When something fails, say whether it also fails on `bc6eba0`. A pre-existing bug is not a + regression, and the distinction matters. +- Do not trust the commit messages. Several make specific measurable claims. Check them. + +## 1. Gates that must be green + +```bash +npm install +npx tsc -b --force && npm run lint && npm run build && npm run test +SCRIPTO_VISUAL=1 npm run test +``` + +Expected, and worth confirming rather than assuming: + +| Gate | Expected | +| --- | --- | +| `tsc` | clean | +| `lint` | **0 errors**, 12 warnings (all pre-existing `react-refresh` / unused-directive) | +| `test` | all pass, 26 skipped | +| `SCRIPTO_VISUAL=1` | **19 passed · 6 failed · 1 expected-fail** | + +⚠️ Those **6 visual failures are claimed to be pre-existing**. Verify that claim by running +the same suite in `/tmp/scripto-base` after copying `tests/visual/harness.ts` across (the +baseline's own harness produces no output at all — see §3). If the counts differ, that is a +regression and a finding. + +## 2. The invariant that matters most + +`.scripto-doc` is cloned verbatim into the PDF / HTML / Word exports. Everything the Stage +system added is supposed to live outside it. + +- [ ] Export a PDF from a document with tables, code, math, Mermaid, ASCII art and images. + Export the same document from `/tmp/scripto-base`. **Diff the layout.** Any difference + that is not explained by the deliberate `document.css` changes (commit `e96999e`) is a + regression. +- [ ] Export HTML and Word. Grep the output for `stage-`, `skin-thumb`, `break-layer`, + `skin-palette`. **Any hit is a bug.** +- [ ] With handwriting off, confirm the exported PDF is byte-identical in layout to the + baseline, and that the network tab shows **zero** handwriting font requests. + +## 3. Claims to check, commit by commit + +**`8a3d312` decompose App.tsx** — claims no behaviour change except three deliberate fixes. +Exercise every ⌘K command, every header menu item, every dialog. Check the AI abort actually +fires on unmount (DevTools → Network, start an AI action, navigate away). + +**`9785a18` Preview Stage system** — claims 21 distinct stages, ≤12 KB gzipped CSS, 0 image +bytes, and that typing never re-renders `StageBackdrop`. +- [ ] Screenshot all 35 skins now. Are any two indistinguishable at 240 px? +- [ ] Measure the CSS: `npx esbuild src/preview/stage/stage.css --minify | gzip -9 | wc -c`. +- [ ] React Profiler: type 60 characters. Does `StageBackdrop` re-render? It must not. +- [ ] Rapidly switch skins 20 times. Any stuck backdrop or half-faded label is a failure. + +**`e8b4611` page-break editor** — claims insert→remove restores the document byte for byte. +- [ ] Do it in the UI on a real document. Compare the source before and after, exactly. +- [ ] Check Keep-together and Landscape actually change pagination. +- [ ] Confirm undo (⌘Z) reverses a break insertion. + +**`3b19365` export presets** — claims a preset never carries document `meta`. +- [ ] Save a preset from a document with a title and author. Export the `.json`. **Open it + and confirm no title or author is in there.** +- [ ] Import a deliberately malformed `.json`. It must be rejected, not crash the app. + +**`7b3dd56` Paged.js stall** — claims the harness was broken on Chrome ≥132 and the stall is +an upstream defect, unfixed on purpose. +- [ ] Confirm `SCRIPTO_VISUAL=1` produces **no output at all** on the untouched baseline. +- [ ] Confirm the `it.fails` tripwire is genuinely failing-as-expected, not silently passing. +- [ ] Read `docs/PAGEDJS_IMAGE_STALL.md` and spot-check two of its "ruled out" rows yourself. + +**`e96999e` skin component language** — this one **deliberately changes exported PDFs**. +- [ ] Confirm the change is confined to callouts, code blocks, quotes, tables, rules and + lists, and that no skin became *less* legible. +- [ ] Check Terminal and Dark specifically, in light and dark UI. Claim: they used to render + pale text on a white sheet in light mode. Verify that is fixed and that preview matches + PDF for both. + +**`7dd6102` / `51f441a` new skins** — 35 total, each with a stage and a marketing page. +- [ ] `npm run build` should prerender 118 pages. Open `/skins/invoice` and `/skins/chalkboard`. +- [ ] Confirm every skin has an Arabic label (switch the UI to العربية and open the picker). + +**`42cb1e3` templates** — claims all 54 except Blank declare a skin, and that the rewritten +ones contain no placeholder text. +- [ ] Script it: parse `src/data/templates.ts`, assert each has `skin:` in its front-matter. +- [ ] Open every template in the app. Any that still reads as filler is a finding — note it. + **`menu` is known to still be placeholder-heavy.** + +**`40c2848` numbering + record validation** +- [ ] Turn on numbered headings, then paste a document whose headings are `# 1 · Thing`, + `# 2 · Other`. There must be no double numbering. +- [ ] Check `2024 Annual Report` and `3D Rendering` are still auto-numbered (not mistaken for + manual numbering). +- [ ] Put junk in `localStorage['scripto:library:v1']` — `{"docs":[{"content":"x","config":{"meta":null}}]}` — + reload. The app must survive. + +**`b1a32d0` / `a70fb97` skin palette** +- [ ] Hover a card: the preview changes but the config must **not** be written. Confirm via + `localStorage['scripto:library:v1']`. +- [ ] Escape and click-away must restore the committed skin. +- [ ] Keyboard: arrows walk the grid, Enter applies. Check with a screen reader if you can. + +**`94e6735` / `51f441a` / `ef49350` handwriting** +- [ ] **`hand: 'none'` is a total no-op.** No `data-hand` attribute, no `--hw-*` property, no + font request. This is the single most important handwriting claim. +- [ ] Determinism: open Print Preview five times on the same document. Page count and page + breaks must be identical every time. +- [ ] Ruled paper: export a **5-page** ruled document. Check page 1, page 3 and the last page. + Every line of body text must sit on a rule. **If page 4 drifts, this is not done.** +- [ ] Arabic: export with `hand: ruqaa` and with `hand: nastaliq`. Letterforms must be + connected, direction right-to-left, numbering in Arabic-Indic digits. +- [ ] Confirm the exported PDF text is still **selectable and searchable** — nothing rasterised. +- [ ] Code, maths and Mermaid must stay in their own typefaces, never the hand. +- [ ] Drag the neatness slider on a 20-page document. It must be instant, with no + re-pagination. +- [ ] Offline (DevTools): a previously used hand still works; a new one fails with an honest + message and **does not apply**. +- [ ] 20k-word document: confirm it degrades to no variation rather than freezing. + +## 4. Cross-cutting checks + +- [ ] العربية end to end: RTL chrome, RTL document, code and maths still LTR, page numbers + `1 / N` LTR. **Look for any English string left in the Arabic UI** — two were found this + way already (the paginator's progress messages, and the error boundary). +- [ ] `prefers-reduced-motion: reduce` — no movement anywhere, textures still present. +- [ ] 375 px width — everything usable; the palette and rail must not trap the layout. +- [ ] Passphrase lock → reload → unlock: documents, presets and hand settings all intact. +- [ ] Offline (PWA): edit and export still work. +- [ ] Lighthouse on `/` ≥ 95 Perf/SEO/A11y, and confirm marketing pages still ship **zero** + framework JS. +- [ ] Bundle: report the CSS gzip delta against `bc6eba0`. It has grown; confirm by how much + and whether that is justified. + +## 5. Tooling you may want + +There is no committed screenshot harness. If you want one, drive Chrome over the DevTools +Protocol — **not** `--dump-dom` with `--virtual-time-budget`, which silently does nothing on +Chrome ≥132 (that was the harness bug fixed in `7b3dd56`). `tests/visual/harness.ts` has a +working minimal CDP client to copy. + +Useful: seed `localStorage` from the marketing origin (`/`) rather than `/app` — the editor +flushes its own library on `pagehide` and will overwrite your seed on the way out. + +## 6. Report back + +For each commit: verdict, evidence, and any finding with a minimal reproduction. Then: + +1. Anything that regressed against `bc6eba0`. +2. Anything the commit messages claim that you could not confirm. +3. The three findings you would fix first, in order, with your reasoning. diff --git a/docs/V2_FRONTEND_PROMPT.md b/docs/V2_FRONTEND_PROMPT.md new file mode 100644 index 0000000..684a61b --- /dev/null +++ b/docs/V2_FRONTEND_PROMPT.md @@ -0,0 +1,489 @@ +# Scripto v2.0 — Frontend Master Brief + +> Hand this file to a fresh Claude Code session. It is **self-contained**: it explains the codebase, +> the invariants, and specifies every workstream for v2.0. +> +> **Hard constraint: NO BACKEND.** No server, no accounts, no sync, no share links, no API. +> Everything here runs in the browser. Anything requiring a server is explicitly out of scope +> and listed in §12. +> +> **The headline work is §2 — the Preview Stage system.** Everything else is supporting. + +--- + +## 0. Orientation — read this first + +**What Scripto is.** A 100% client-side Markdown → PDF studio at `md.atom.sa`. Vite 5 + React 18 + +TypeScript (strict) + Tailwind 3. Markdown renders through react-markdown (remark/rehype); PDF is +produced by **Paged.js + the browser's native print-to-PDF**. There is a multi-document library in +`localStorage`, optional AES-256 passphrase lock, PWA offline, 21 document skins, 58 templates, +22 presets, full EN + AR with real RTL, and a prerendered zero-JS marketing site (~102 pages). + +**Run / verify (all three must stay green):** +```bash +npm install +npm run dev # http://localhost:5173/app +npx tsc -b --force && npm run lint && npm run build +npm run test # 4 suites; visual harness is opt-in +SCRIPTO_VISUAL=1 npm run test # opt-in Paged.js layout assertions +``` + +**Read before writing code:** `ARCHITECTURE.md` (§5 rendering pipeline, §6 PDF engine, §17 RTL), +`docs/RENDERING_AUDIT.md` (why the layout code looks the way it does), `docs/P1_FEATURES.md` §0 +(the dialog/command wiring recipe). + +### Conventions — non-negotiable +- **TypeScript strict, no `any`.** Use `unknown` + narrowing. Explicit types on exported APIs. +- **Immutable updates** everywhere (spread; never mutate). +- **No `console.log`** — use `logger` from `src/lib/logger.ts` and `getErrorMessage(err)`. +- **Reuse `src/components/ui/*`** — `Button`, `Dialog`, `Field` (`TextInput`/`Select`/`Switch`/ + `Slider`/`Segmented`), `Menu`, `Tooltip`, `Confirm` (`useConfirm()`), toasts via `sonner`. +- **i18n is mandatory.** Every user-visible string goes in `src/lib/i18n.ts` (`EN_STRINGS` **and** + `STRINGS.ar`) and is read via `useLanguage().t`. A green `tsc` proves no key is missing. +- **Logical CSS only** — `ms-/me-`, `ps-/pe-`, `start-/end-`, `text-start`, `rtl:`. Never + `ml-/mr-/left-/right-/text-left` in new code. Everything must be correct in LTR **and** RTL. +- **Light + dark** both correct. Design tokens live as CSS vars in `src/index.css`. +- **`prefers-reduced-motion` respected.** The app wraps in ``; + reused presets live in `src/lib/motion.ts`. +- Path alias `@/` → `src/`. + +### 🚨 The five invariants you must not break + +1. **`preview === PDF`.** The live preview element and the exported PDF are produced from the *same* + rendered DOM and the *same* stylesheet (`src/styles/document.css`). This is the product's moat. +2. **`.scripto-doc` (the `docRef` element in `Preview.tsx`) is cloned verbatim by the export path.** + `src/pdf/buildExportContent.ts` and `src/io/exporters.ts` both `cloneNode(true)` it. **Anything you + put inside it ships into the PDF, the HTML export, and the Word export.** Screen-only decoration + must live *outside* it. This is the single most important rule in this document. +3. **`PreviewHandle.getDocElement()` must keep returning that element.** `PrintPreview.tsx`, + `exporters.ts` and `useScrollSync.ts` all depend on it. Preserve the contract exactly. +4. **CodeMirror props must be identity-stable.** `useCodeMirror` reconfigures the editor when + `extensions` / `onChange` / `basicSetup` change by reference, and reconfiguring destroys + runtime-attached extensions (most visibly the ⌘F search panel). `MarkdownEditor` reads callbacks + through a ref — keep that pattern. +5. **Print isolation.** `src/styles/print.css` hides everything except `.scripto-print-portal` when + `body.scripto-printing` is set. Any new chrome must be hidden by that rule or live inside the + portal. Verify by actually exporting a PDF. + +### Key files +| File | Purpose | +| --- | --- | +| `src/App.tsx` | Orchestrator (1,150 lines): state, dialogs, `commands` array, shortcuts, deep links | +| `src/components/preview/Preview.tsx` | **The live preview — the main subject of §2** | +| `src/components/preview/PrintPreview.tsx` | Paged.js modal + Save-as-PDF (subject of §3) | +| `src/pdf/renderPaged.ts` | Runs Paged.js; image preload, 45s watchdog, `fitToPage` | +| `src/pdf/buildExportContent.ts` | Clones the live doc, namespaces ids, prepends cover + TOC | +| `src/pdf/pageStyles.ts` | Generates the `@page` CSS | +| `src/styles/document.css` | **Shared** preview + PDF stylesheet; skins live here (49 KB) | +| `src/styles/print.css` | Print isolation + on-screen page chrome | +| `src/data/skins.ts` | `SKIN_OPTIONS` (21) + `SKIN_VALUES` | +| `src/data/presets.ts` | `DOCUMENT_PRESETS` (22) | +| `src/data/templates.ts` | `TEMPLATES` (58) | +| `src/types/index.ts` | `PdfConfig`, `DocumentSkin`, `DocumentRecord`, `ExportProgress` | +| `src/lib/constants.ts` | `DEFAULT_CONFIG`, `PAPER_SIZES`, `MARGIN_PRESETS`, `FONT_STACKS` | +| `src/lib/motion.ts` | Shared easing/duration/variant presets | +| `src/lib/analytics.ts` | `trackEvent` (Vercel Analytics) | +| `src/hooks/useDocumentLibrary.ts` | The localStorage document library | + +--- + +## 1. The central idea: **the Stage and the Sheet** + +Today the preview pane is one white rounded card on a flat grey ground. It is honest and readable — +and completely undesigned. Every skin looks like the same product. + +The fix is a hard architectural split: + +``` +┌─────────────────────────────────── THE STAGE ────────────────────────────────────┐ +│ Screen-only. Never cloned. Never printed. Never exported. │ +│ Ground, texture, lighting, depth, rulers, chrome, motion, transitions. │ +│ This is where 100% of the visual drama lives — with ZERO risk to the PDF. │ +│ │ +│ ┌───────────────────── THE SHEET (.scripto-doc) ─────────────────┐ │ +│ │ SACRED. Cloned verbatim into PDF / HTML / Word. │ │ +│ │ Governed by document.css. Typography only. No animation. │ │ +│ │ Changing anything here changes the exported document. │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────────────┘ +``` + +**Why this matters:** it lets you build something that looks like Framer built it, while the export +path stays byte-for-byte as trustworthy as it is today. Every animation, every texture, every glow +lives on the Stage. The Sheet only ever changes through real typography work in `document.css`. + +**The designer's rule:** the Stage must never outshine the Sheet. The document is the hero; the +Stage is the room it sits in. If a reviewer's eye goes to the background first, the Stage is wrong. + +--- + +## 2. PHASE A — The Preview Stage system ⭐ *the headline work* + +**Goal:** 21 skins → 21 genuinely distinct **stages**, each with its own ground, paper treatment, +lighting and motion signature, built as one coherent design system rather than 21 one-offs. +Switching skins should feel like walking into a different room. + +### A1 · Module layout (build this separately — do not bolt it into `Preview.tsx`) + +Create a new, self-contained module. `src/components/preview/Preview.tsx` becomes a **thin adapter** +that renders `PreviewSurface` and forwards the `PreviewHandle` ref, so `App.tsx` barely changes. + +``` +src/preview/ +├── PreviewSurface.tsx # composes Stage + Sheet; owns docRef; keeps the PreviewHandle contract +├── stage/ +│ ├── stages.ts # the 21 stage descriptors — DATA, not components +│ ├── StageBackdrop.tsx # renders ground/texture/lighting for the active stage +│ ├── PaperFrame.tsx # renders the paper treatment around (never inside) the Sheet +│ ├── stage.css # ALL stage styling, keyed by [data-stage='x'] +│ └── types.ts # StageDescriptor, StageMotion, StageTexture +├── motion/ +│ ├── vocabulary.ts # the 10 shared motion primitives (see A3) +│ └── useStageTransition.ts # orchestrates the skin→skin transition +├── chrome/ +│ ├── PreviewToolbar.tsx # mode switch, zoom, sync, front-matter toggle +│ ├── SkinRail.tsx # hover-to-preview skin rail (A6) +│ ├── PageRuler.tsx # page-boundary overlay (A7) +│ └── PreviewEmpty.tsx # designed empty state +└── index.ts +``` + +**Rules for this module:** +- `stage.css` is imported once by `PreviewSurface`. It must contain **no `.scripto-doc` selectors** + other than read-only positioning of the wrapper — never typography, never colour of document text. +- Stages are **data** (`StageDescriptor[]`), not 21 bespoke components. A stage that needs a + component gets one small named component; the other 20 must fall out of the descriptor + CSS. +- Every stage descriptor is keyed by `DocumentSkin`, so `tsc` proves all 21 exist + (`Record` — exhaustive by construction). + +### A2 · Design foundation (do this before any stage) + +Add a small token layer in `stage.css` so the 21 stages are one system: + +- **Depth scale** — `--stage-lift-1/2/3`: layered multi-stop shadows (never a single flat shadow). +- **Ground scale** — `--stage-ground`, `--stage-ground-2`, `--stage-vignette`. +- **Edge scale** — `--stage-edge` (paper border), `--stage-edge-glow`. +- **Motion scale** — durations `--m-snap: 90ms`, `--m-quick: 160ms`, `--m-base: 280ms`, + `--m-slow: 480ms`, `--m-drift: 700ms`; easings `--e-out` (reuse `EASE_OUT` from `lib/motion.ts`), + `--e-spring`, `--e-linear`, `--e-steps`. +- **Texture primitives, CSS-only** — no image assets. Grids, scanlines, halftone dots, paper grain + and blueprint rules must all be built from `repeating-linear-gradient` / `radial-gradient` / + `conic-gradient`. **Bundle budget: the entire stage system adds ≤ 12 KB gzipped CSS and 0 image bytes.** + +### A3 · The shared motion vocabulary (10 primitives) + +Every stage composes its entrance from these — parameterised, not reinvented. Implement in +`motion/vocabulary.ts` as named variant factories. + +| Primitive | Motion | Feels like | +| --- | --- | --- | +| `rise` | opacity 0→1, translateY 12→0 | calm, default | +| `wipe` | a masked bar sweeps along the inline axis, content resolves behind | editorial | +| `draw` | borders/rules scale from 0 length, then content fades | technical | +| `type` | children reveal sequentially with micro x-jitter | mechanical | +| `snap` | 90 ms, no easing curve, no overshoot | efficient | +| `spring` | scale 0.96→1 with bounce 0.22 | friendly | +| `glow` | a light travels the paper perimeter, then content fades | nocturnal | +| `cascade` | children stagger top→bottom, tight 24 ms steps | tabular | +| `bloom` | vignette/spotlight widens while content fades up | cinematic | +| `stamp` | scale 1.04→1 with a hard short settle | institutional | + +### A4 · The 21 stages + +One per skin in `src/data/skins.ts`. Ground/paper are **screen-only**; the motion column names the +primitive from A3. + +| # | Skin | Stage | Ground | Paper treatment | Motion | +| --- | --- | --- | --- | --- | --- | +| 1 | `modern` | Studio | soft neutral vertical gradient | floating sheet, layered `lift-2`, gentle scroll parallax | `rise` | +| 2 | `classic` | Library | warm cream, faint grain | bound-page look: spine gradient on the inner edge | `wipe` (page-turn, rotateY from spine) | +| 3 | `editorial` | Spread | accent colour-field, oversized muted heading watermark behind | offset sheet, magazine gutter | `wipe` (masthead bar) | +| 4 | `technical` | Spec bench | cool slate + faint isometric grid | corner registration ticks, thin rulers on both edges | `draw` | +| 5 | `compact` | Contact sheet | tight dense ground | snug frame, minimal margin | `snap` | +| 6 | `manuscript` | Typewriter desk | warm paper-bag, fibre texture | platen shadow along the top edge | `type` | +| 7 | `blueprint` | Drafting table | deep cyan blueprint grid | white-on-blue sheet with a title-block corner | `draw` (plotter trace) | +| 8 | `corporate` | Boardroom | clean cool ground, accent band at top of stage | crisp square shadow | `rise` + band fills from start edge | +| 9 | `brutalist` | Concrete | flat raw ground | hard offset shadow, zero blur, thick border | `snap` with `--e-steps` (deliberately jarring) | +| 10 | `notebook` | Desk | soft warm ground | punch-hole/spiral edge, red margin rule, faint ruled lines *behind* the sheet | `spring` (flip-down, rotateX from top) | +| 11 | `resume` | Folder | very clean neutral, generous air | manila folder tab peeking behind the sheet | `rise` (lift out, shadow deepens) | +| 12 | `swiss` | Grid wall | visible modular grid with interval markers | snapped to grid, hairline edge | `cascade` along grid lines, no bounce | +| 13 | `terminal` | CRT | near-black, phosphor vignette, scanlines, very faint flicker | dark panel with a glowing edge | `type` (cursor blink → boot sweep) | +| 14 | `newsprint` | Press | warm grey halftone dots | off-white sheet, CMYK registration marks at corners | `bloom` (ink-set: blur+desaturate → sharp) | +| 15 | `elegant` | Gallery | deep ink ground, soft centred spotlight | hairline accent frame | `bloom` | +| 16 | `playful` | Sticker board | bright pastel blobs | rounded corners, chunky coloured shadow | `spring` (rotation settle) | +| 17 | `dark` | Night desk | true dark, ambient accent glow | luminous border | `glow` | +| 18 | `ledger` | Accounting desk | muted ledger-green | fine ruled columns visible in the gutter | `cascade` | +| 19 | `zen` | Void | almost nothing, very soft light | edge barely exists, no frame | `rise` at `--m-drift`, **no translate** — pure fade | +| 20 | `memo` | Interoffice | flat institutional ground, ghost "INTERNAL" mark **on the stage, not the paper** | header band | `stamp` | +| 21 | `poster` | Gallery wall | dramatic ground, spotlight cone | large centred sheet, strong drop shadow | `bloom` + scale 0.92→1 | + +**Acceptance for A4:** screenshot all 21 at 240 px wide. A person who has never seen Scripto must be +able to tell all 21 apart at that size. If two are confusable, one of them is not finished. + +### A5 · Skin-change transition + +Changing a skin must not be an instant repaint. On `config.skin` change: +1. Cross-fade the old Stage backdrop out and the new one in (`--m-base`). +2. Play the new stage's motion signature on the Sheet **wrapper** (never on `.scripto-doc` itself — + no transforms on the exported element; animate the parent). +3. Show a brief, tasteful stage-name label (e.g. "Blueprint") that fades after ~900 ms. + +Must be interruptible: switching skins rapidly must never leave a stuck backdrop or a half-faded +label. Under `prefers-reduced-motion`, do the cross-fade only — no movement. + +### A6 · Skin Rail — hover to preview, click to commit + +A slim vertical rail pinned to the inline-end edge of the preview (collapsible, remembered in +`localStorage` as `scripto:preview-rail`). + +- 21 small live thumbnails driven by `SKIN_OPTIONS`. +- **Hover** temporarily applies the skin to the live preview (do **not** write to `config`) — + release restores the committed skin. This is the Figma/Framer move that makes the whole feature + feel alive. +- **Click** commits via the existing `updateConfig({ skin })`. +- Keyboard accessible: the rail is a `role="listbox"`, arrow keys move a roving tabindex, focus + previews, Enter commits, Escape restores. +- Hidden below `lg` (mobile gets the existing Theme Gallery instead). +- **Performance:** thumbnails must be static CSS representations, not 21 mounted `MarkdownRenderer`s. + Reuse the approach in `marketing/content/skinStyles.ts` if it fits. + +### A7 · Preview modes + Page Ruler + +Add a segmented control to `PreviewToolbar` (persisted as `scripto:preview-mode`): + +- **Flow** (default, today's behaviour) — one continuous sheet. +- **Pages** — the **Page Ruler** overlay: measure the rendered document height against the usable + page height derived from `resolvePageDimensions(config)` minus `config.margins`, then draw + screen-only page separators with page numbers in the gutter. + - ⚠️ **Be honest about accuracy.** This is a *geometric estimate*; it does not run the paginator + and will not account for `break-inside: avoid`, `::page-break`, `:::keep-together`, table-header + repetition or `chunkCodeBlocks`. Label it as approximate in the UI (i18n string) with a + "Open Print Preview for exact pages" affordance. **Do not claim exactness.** + - The overlay is absolutely positioned in the Stage layer. It must not touch `.scripto-doc`. +- **Focus** — dims everything except the block under the cursor (reuse the `rehypeSourceLine` + data attributes that `useScrollSync` already relies on). + +### A8 · Contracts this phase must honour + +- **Typing latency must not regress.** The Stage must not re-render on keystroke — memoise it on + `config.skin` / `config.accentColor` only. Verify with a React profile: typing 60 chars must not + re-render `StageBackdrop` once. +- **GPU-composited only** — animate `transform` and `opacity`. No animated `box-shadow`, `filter` on + large surfaces, `width`/`height`, or `background-position` on the whole ground. +- The existing **`zoom`** on the preview inner wrapper and the **pinch-to-zoom** touch handlers must + keep working. If a stage transform fights `zoom`, put the stage transform on a different element. +- **Reduced motion:** all stage entrances collapse to a plain 120 ms opacity fade. Textures stay; + motion goes. +- **RTL:** every stage must be correct with `dir="rtl"`. Asymmetric stages (2 spine, 10 margin rule, + 11 folder tab, 18 gutter) must mirror. Use logical properties; add `[dir='rtl']` tweaks only where + logical props genuinely cannot express it. +- **Escape hatch:** a setting `scripto:preview-stage` = `full | minimal | off`. `off` restores + today's plain card exactly. Zen/focus mode forces `minimal`. +- **i18n:** stage names, mode labels, rail labels, ruler labels — EN + AR. + +### A9 · Acceptance for Phase A +- [ ] 21 stages, all visually distinct at 240 px. +- [ ] Skin change plays a designed transition; rapid switching never breaks. +- [ ] Skin Rail previews on hover, commits on click, fully keyboard-navigable. +- [ ] Flow / Pages / Focus modes work and persist. +- [ ] **Export a PDF and diff it against `main`: byte-level layout must be unchanged.** Nothing from + the Stage appears in the PDF, the HTML export, or the Word export. +- [ ] `SCRIPTO_VISUAL=1 npm run test` still passes. +- [ ] Correct in LTR + RTL, light + dark, reduced-motion, and at 375 px width. +- [ ] `npx tsc -b --force && npm run lint && npm run build && npm run test` green. + +--- + +## 3. PHASE B — Visual page-break editor ⭐ *the launch feature* + +**Why this is the moat:** every markdown→PDF tool's #1 complaint is "the page break landed in the +wrong place and I can't fix it." Pandoc means editing LaTeX. Typora means guessing. And Scripto +already has the write target: `src/markdown/plugins/remarkPageDirectives.ts` implements +`::page-break`, `:::keep-together` and `:::landscape` **today**. This phase is a UI layer over a +working directive system — not a new pipeline. + +**In `PrintPreview.tsx`, over the paginated `.pagedjs_page` boxes:** + +1. On hover between two block elements, show a thin insertion affordance: **"✂ Break here"**. +2. Clicking it inserts `\n\n::page-break\n\n` into the markdown at the right source position, then + re-renders. **Source mapping:** `rehypeSourceLine.ts` already stamps source line numbers onto + rendered elements and `useScrollSync` already consumes them — reuse that, and account for + `parsed.bodyLineOffset` (front-matter offset) exactly as `useScrollSync` does. +3. Existing `::page-break` markers render as a removable chip on the page edge (click ✕ → delete + those lines from the source). +4. Select a run of blocks → "Keep together" wraps them in `:::keep-together … :::`. +5. A wide table shows an inline suggestion: "Too wide for portrait — make this page landscape?" → + wraps in `:::landscape`. +6. Surface the `fit` result that `renderPagedPreview` already returns (`{ scaled, clipped }`) as a + visible warning strip: "3 elements were scaled to fit; 1 is still clipped." + +**Rules:** every mutation goes through the normal `setMarkdown` path so undo works. Never write into +the rendered DOM as the source of truth — the markdown is always authoritative. Round-trip test: +insert a break, remove it, and the document must be byte-identical to the original. + +--- + +## 4. PHASE C — Export presets + +The last feature the repo explicitly names as unstarted (`docs/RENDERING_AUDIT.md` §8: *"Named +bundles of paper size, margins, skin and font — not started"*). + +- New type `ExportPreset { id, name, config: Partial, createdAt }`, persisted at + `scripto:export-presets`. +- "Save current settings as preset…" in `ConfigPanel` + a ⌘K command; rename and delete. +- User presets appear alongside `DOCUMENT_PRESETS` in the Theme Gallery, visually separated. +- Export/import presets as a `.json` file (frontend-only portability — this is the closest thing to + "sync" available without a backend, and it is genuinely useful for teams sharing a house style). +- Applying a preset must merge exactly like `applyPreset` in `App.tsx` does today (note the + `marginPreset` → `margins` mapping). + +--- + +## 5. PHASE D — Root-cause the Paged.js RTL pagination hang + +**This is a real, shipped bug on the product's key differentiator.** Some Arabic RTL documents stall +at "Laying out pages"; a 45 s watchdog makes it fail gracefully but they still cannot export. + +Full repro and investigation plan already exist in **`docs/PAGEDJS_RTL_DEBUG_PROMPT.md`** — follow it. +Leading hypothesis stated there: `overflow-x: auto` on `pre` / `.table-wrap` combined with `dir=rtl` +prevents the chunker from converging (the same class of defect as the scroll-container/fragmentation +bug documented in `RENDERING_AUDIT.md` §2). + +Keep the watchdog and the image timeout as the safety net regardless of the fix. Add an RTL fixture +to `tests/visual/fixtures/` that reproduces it, so the harness covers it. + +--- + +## 6. PHASE E — Mobile + +`App.tsx:216` gates split view on `isDesktop` (`min-width: 1024px`) and `App.tsx:267` only +auto-opens the config panel on desktop. Meanwhile résumé and AI-output traffic is heavily mobile. + +- Tabbed Editor ⇄ Preview with a proper bottom switcher (not a degraded desktop layout). +- Config panel becomes a bottom sheet on small screens. +- Verify the Save-as-PDF path end-to-end on **iOS Safari** and **Android Chrome** — this is the + riskiest part of the whole product on mobile and it is currently unverified. Document what works. +- Touch targets ≥ 44 px; the editor toolbar must be reachable one-handed. +- Stage system: `minimal` on small screens by default (perf). + +--- + +## 7. PHASE F — IndexedDB migration + +`useDocumentLibrary.ts` `JSON.stringify`s the entire library — including base64 images — into +`localStorage` on a 300 ms debounce. Quota exhaustion is handled gracefully +(`scripto:quota-exceeded`) but the ceiling is low and real. + +- Move documents and image blobs to IndexedDB (or OPFS), keeping `localStorage` for small settings. +- **Migrate on first run, non-destructively**: copy from `scripto:library:v1`, verify, and only then + stop writing the old key. Keep the old key readable for one release. +- Preserve the debounced-write / `pagehide` flush behaviour — it is the reason typing is smooth. +- **The passphrase vault must keep working.** `lib/vault.ts` snapshots `scripto:*` localStorage + entries; it needs to learn about the new store, and `AppRoot`'s lock gate must still never mount + `App` with plaintext readable. Re-verify the full lock → reload → unlock cycle. +- Add local **version snapshots** (last 20 per document) now that space allows — the single cheapest + trust feature for a local-first app. + +--- + +## 8. PHASE G — Decompose `App.tsx` + +1,150 lines, ~20 dialog `useState`s, a 40-entry `commands` array. Adding the dialogs above will make +it unworkable. Extract, with no behaviour change: + +- `hooks/useDialogs.ts` — one reducer for all overlay state. +- `hooks/useCommands.ts` — builds the `commands` array from injected handlers. +- `hooks/useDeepLinks.ts` — the `?template=` / `?skin=` allowlist effect. +- `hooks/useAiActions.ts` — the AI selection/transform handlers. + +Do this **before** Phases B and C if you are running them in the same session; otherwise after A. + +--- + +## 9. PHASE H — Funnel analytics + +`lib/analytics.ts` tracks actions but no funnel, so there is no way to tell where users are lost. +Add to `AppAnalyticsEvent` (keep props flat, structural only — **never document content or PII**): + +`Time To First Export` · `Export Dialog Opened` · `Print Dialog Reached` · `Paste Detected` +(with `{ kind: 'rich' | 'plain' | 'image' }`) · `Page Break Inserted` · `Stage Viewed` +(`{ skin }`) · `Skin Previewed` (rail hover→commit) · `Mobile Export Attempted`. + +Define and write down the targets: **activation** = first export in session 1 (>35%); +**north star** = weekly returning exporters. + +--- + +## 10. PHASE I — Content sprint (no code, pure leverage) + +The marketing infrastructure auto-generates a page, sitemap entry, hreflang cluster, JSON-LD and OG +card for every template, skin, use-case and blog post. It is built for hundreds and currently holds +**4 blog posts, 3 comparisons, 9 use-cases**. Feeding it is the cheapest growth available. + +1. **The 5 unbuilt templates** named in `RENDERING_AUDIT.md` §8 — Arabic/bilingual invoice, incident + postmortem, ADR, statement of work, investor update. (These are also the business/team wedge.) +2. **A `/ai-output-to-pdf` use-case page.** Paste-to-Markdown already ships + (`MarkdownEditor.tsx:228`) — the feature exists and nobody knows. Target *"chatgpt to pdf"*, + *"claude output to pdf"*, *"ai answer formatting"*. Add the Arabic variant to `USE_CASES_AR`. +3. **10 blog posts** against the keyword map in `docs/SEO_PLAYBOOK.md`. +4. **More comparisons:** VS Code Markdown-PDF, Dillinger, StackEdit, Obsidian, md-to-pdf, Overleaf. +5. Update the landing hero: it says *"Markdown in."* It should also say **"paste anything."** + +Follow the recipes in `ARCHITECTURE.md` §14 — do not hand-write routes. + +--- + +## 11. Global verification + +Before declaring any phase done: + +```bash +npx tsc -b --force && npm run lint && npm run build && npm run test +SCRIPTO_VISUAL=1 npm run test +``` + +Then **manually**: +- [ ] Export a PDF from a document with tables, code, math, Mermaid, ASCII and images — compare + against a PDF exported from `main`. **Any layout difference is a regression unless intended.** +- [ ] Export HTML and Word — confirm no stage markup leaked in. +- [ ] Switch to العربية: full RTL chrome, RTL document, code/math still LTR, page numbers still + `1 / N` LTR. +- [ ] Enable the passphrase lock, reload, unlock — data intact. +- [ ] Go offline (DevTools) — the PWA still edits and exports. +- [ ] `prefers-reduced-motion: reduce` — no movement anywhere. +- [ ] 375 px width — everything usable. +- [ ] Lighthouse on `/` ≥ 95 Perf/SEO/A11y (marketing pages must still ship zero framework JS). + +--- + +## 12. Explicitly OUT of scope (needs a backend — do not build) + +Accounts · cloud sync · share links · comments/collaboration · server-side rendering (and therefore +PDF bookmarks, PDF/A, tagged PDF, password-protected PDF, batch/merge) · REST API · CLI · GitHub +Action · e-signature. + +If you find yourself wanting a server, stop and note it in the final report instead. + +--- + +## 13. Suggested execution order + +| Session | Contains | Why | +| --- | --- | --- | +| 1 | §8 Phase G (decompose `App.tsx`) | Cheap, unblocks everything, zero behaviour change | +| 2 | §2 Phase A — **the Stage system** | The headline. Big enough to own a whole session. | +| 3 | §3 Phase B + §4 Phase C | Both live in the preview/export surface | +| 4 | §5 Phase D (RTL hang) + §6 Phase E (mobile) | Both are "fix what's shipped" | +| 5 | §7 Phase F (IndexedDB) + §9 Phase H (analytics) | Both touch persistence/instrumentation | +| — | §10 Phase I (content) | Runs continuously alongside all of the above | + +Ship each phase independently. Do not batch — this repo's strength is that every feature landed +complete, documented, and green. + +--- + +## 14. Final report + +End with: what shipped per phase, before/after screenshots of all 21 stages, the PDF regression +diff result, bundle-size delta, anything deferred and why, and any invariant you had to bend +(with justification). diff --git a/docs/V3_HANDWRITING_PROMPT.md b/docs/V3_HANDWRITING_PROMPT.md new file mode 100644 index 0000000..3facbd5 --- /dev/null +++ b/docs/V3_HANDWRITING_PROMPT.md @@ -0,0 +1,635 @@ +# Scripto — The Handwriting Engine + +> **Follow-up to `docs/V2_FRONTEND_PROMPT.md`.** That brief is largely delivered: the Stage system, +> the visual page-break editor, export presets, the `App.tsx` decomposition, the Paged.js stall +> root-cause, and 8 new skins have all shipped. This document is the next body of work. +> +> **Still frontend-only. No backend, no network calls beyond font CDN.** +> +> This is the most design-sensitive feature Scripto has attempted. Read §1 and §3 before writing +> a line of code — the naive implementation (pick a handwriting font, ship it) looks fake, and the +> difference between fake and convincing is entirely in the details specified here. + +--- + +## 0. Where the codebase is now + +Re-orient before starting; several things named in the v2 brief have changed. + +- **29 skins**, not 21 — `src/data/skins.ts` now carries a `SkinGroup` taxonomy + (`essentials | business | technical | editorial | academic | expressive`) with `SKIN_GROUPS`. +- **The Stage system exists.** `src/preview/stage/stages.ts` is + `Record` — **adding a skin without a stage is a compile error.** + Stage vocabulary: `StageMotion` (10 primitives), `StageFrame` (12 decorations), `StageMark`. + All screen-only, keyed `[data-stage='']` in `src/preview/stage/stage.css`. +- **`PdfConfig.hand` does not exist yet** — you are adding it. +- `DocumentFont` is still a 5-value union (`serif | sans | lora | system | arabic`). +- **Fonts load from one Google Fonts URL**, duplicated in **two** places that must stay in sync: + `index.html` (preload → stylesheet swap) and `FONTS_HREF` in `src/io/exporters.ts`. +- The five invariants from `V2_FRONTEND_PROMPT.md` §0 still hold. **Re-read them.** The most + important, restated: **`.scripto-doc` is cloned verbatim into the PDF/HTML/Word exports.** + +--- + +## 1. The thesis: why handwriting fonts look fake, and what fixes it + +Drop Caveat on a document and it reads as *a font*, not as writing. Six things give it away, and +each has a specific fix. **This table is the feature.** + +| Tell | Why it reads fake | The fix | +| --- | --- | --- | +| Every `a` is identical | Real hands never repeat a glyph exactly | OpenType `calt` + stylistic sets; per-word variation palette (§3) | +| Perfect baseline | Real lines drift and wobble | Per-word `translateY` jitter, ±0.6 px | +| Uniform slant | Real slant varies word to word | Per-word `rotate` jitter, scaled by *neatness* | +| Uniform stroke weight | Real pens vary with pressure | Per-word `font-variation-settings: 'wght'` on variable hands (§3.4) | +| Text floats above the paper | Real writing sits **on** the rule | Rule-pitch locking (§4.2) — the hardest part of this feature | +| Rules, bullets and boxes are machine-straight | Nothing drawn by hand is straight | Hand-drawn element layer (§5) | + +**The single most important sentence in this document:** all variation must be **deterministic** — +a pure function of `(seed, wordIndex)`, never `Math.random()`. + +Three independent reasons: +1. Paged.js re-renders on every pagination. Fresh randomness ⇒ different word widths ⇒ **different + page breaks every time you open Print Preview.** +2. `preview === PDF` would break — the preview and the export would jitter differently. +3. The preview would visibly shimmer on every keystroke re-render. + +Use a small seeded PRNG (`mulberry32`) in `src/lib/handwriting/random.ts`. Seed from a stable hash of +the document id. **Add a unit test asserting that the same input produces byte-identical output +across two independent runs.** + +--- + +## 2. The data model — a new orthogonal axis + +Handwriting is **not a skin**. It is `hand × ink × stationery × neatness × slant × variation × +aging × drawn-elements`. Expressed as skins that would need 20+ entries; expressed as an axis it +composes with all 29 existing skins. + +Add to `src/types/index.ts`: + +```ts +/** Which hand writes the document. `none` = normal typeset text. */ +export type HandStyle = + | 'none' + // Latin — everyday + | 'casual' // Caveat — relaxed ballpoint, variable weight axis + | 'neat-print' // Patrick Hand — tidy printing, very legible + | 'architect' // Architects Daughter — drafting hand, all-caps feel + | 'marker' // Gloria Hallelujah — thick felt tip + | 'scratchy' // Reenie Beanie — fast, untidy, ballpoint + | 'rushed' // Just Another Hand — tall, condensed, hurried + // Latin — expressive + | 'script' // Dancing Script — flowing, connected + | 'copperplate' // Great Vibes — formal calligraphic + | 'monoline' // Sacramento — even-width script + | 'cursive' // Cedarville Cursive — schoolbook cursive + | 'chalk' // a chalkboard hand + // Arabic + | 'ruqaa' // Aref Ruqaa — the everyday Arabic hand + | 'naskh-hand' // Lateef — softer handwritten Naskh + | 'diwani' // decorative Arabic + // User-supplied + | 'custom' // an uploaded font — see §7 + +export type InkStyle = + | 'ballpoint-blue' | 'ballpoint-black' | 'fountain-blue' | 'fountain-black' + | 'pencil' | 'marker' | 'red-pen' | 'gel' | 'chalk-white' | 'sepia' + +export type Stationery = + | 'blank' | 'ruled-college' | 'ruled-wide' | 'ruled-narrow' + | 'graph' | 'dot-grid' | 'isometric' + | 'legal-pad' | 'engineering' | 'cornell' | 'index-card' | 'steno' + | 'practice-lines' // 4-line with dashed midline — handwriting worksheets + | 'music-staff' + | 'parchment' | 'kraft' | 'graph-blue' + +/** How far the variation engine goes. Higher tiers cost DOM weight (§3.5). */ +export type HandVariation = 'none' | 'word' | 'expressive' + +export interface HandConfig { + readonly hand: HandStyle + /** A second hand for headings — real notes title more carefully than they write. */ + readonly headingHand: HandStyle | 'same' + readonly ink: InkStyle + readonly stationery: Stationery + /** 0 = careful and even, 1 = rushed and messy. Scales all jitter amplitudes. */ + readonly neatness: number + /** -1 = left-handed back-slant, 0 = upright, 1 = strong right lean. */ + readonly slant: number + readonly variation: HandVariation + /** 0 = fresh, 1 = yellowed paper, faded ink, fold creases. */ + readonly aging: number + /** Hand-drawn rules, bullets, checkboxes, underlines, table borders (§5). */ + readonly drawnElements: boolean + /** A stable seed so jitter never changes under the same document. */ + readonly seed: number +} +``` + +Then `readonly hand: HandConfig` on `PdfConfig`, with `DEFAULT_HAND` (`hand: 'none'`) in +`src/lib/constants.ts`. **`hand: 'none'` must be a total no-op** — no extra DOM, no extra CSS, no +plugin work, no font requests. A user who never touches handwriting must not pay one byte for it. + +**Front-matter** (`src/lib/frontmatter.ts`): map `hand`, `ink`, `paper`/`stationery`, `neatness`, +`aging` with the existing validation style (validate against the unions; ignore unknown values). +That is what lets a template declare its own hand. + +--- + +## 2.5 Libraries — what to use, what to build + +**Do not hand-roll what is already solved.** Verified against this repo's installed tree: + +| Need | Use | Size / licence | Notes | +| --- | --- | --- | --- | +| Hand-drawn rules, boxes, underlines, ticks (§5) | **`roughjs`** | ~9 KB gz, MIT | **Already in the tree** — Mermaid 11.16.0 depends on `roughjs ^4.6.6`. Adding it as a direct dependency dedupes to ~0 extra bundle. | +| Sketchy Mermaid diagrams (§5.2) | **`look: 'handDrawn'`** | **free** | Mermaid 11.16.0 supports it natively (that is *why* it bundles rough.js). A config flag, not a dependency. | +| Pressure-sensitive stroke capture (§7) | **`perfect-freehand`** | ~4 KB gz, MIT | Turns pointer input into a variable-width outline. Also powers the signature line. | +| Generating a real font file in-browser (§7) | **`opentype.js`** | ~200 KB, MIT | Can **write** `.otf`, not just parse. Must be lazy-loaded. | +| The hands themselves | **Google Fonts** (OFL) | — | Caveat (variable `wght`), Patrick Hand, Architects Daughter, Aref Ruqaa… | +| Seeded PRNG, word wrapping, stationery, rule-pitch locking | **build our own** | ~150 lines | Too specific to generalise; `mulberry32` is five lines. | +| Font readiness | **native `FontFace` / `document.fonts`** | — | No `fontfaceobserver`. | + +### Why rough.js rather than our own stroke generator + +It takes a **`seed`** option — so it satisfies §1's determinism requirement directly — and exposes +`roughness` and `bowing`, which map onto the *neatness* slider with no translation layer. It emits +SVG `` elements, so output stays vector: **selectable, scalable, and correct at print DPI.** + +**Bundle discipline:** lazy-load `roughjs` only when `drawnElements` is on, and `opentype.js` + +`perfect-freehand` only when the "create my hand" flow opens. A user with `hand: 'none'` must +download none of it. + +--- + +## 3. The variation engine + +### 3.1 The plugin + +`src/markdown/plugins/rehypeHandwriting.ts` — a rehype plugin, registered in +`MarkdownRenderer.tsx` **last**, after `rehype-prism-plus` and `rehype-katex`. + +**Skip list — never descend into these:** +`pre`, `code`, `.katex`, `.katex-display`, `svg` (Mermaid), `.mermaid-figure`, `.ascii-figure`, +`.hljs`, anything with `data-code-chunk`, and any element already carrying `.hw`. + +Rationale: Prism has already produced a precise span tree; KaTeX layout is positionally exact; +Mermaid is SVG. Wrapping inside any of them corrupts them. + +### 3.2 Word wrapping — use a class palette, not inline styles + +The naive approach writes `style="--hw-r:0.7deg;--hw-y:0.4px"` on every word. On a 100-page +document that is ~50,000 inline style attributes, which bloats the DOM, the export clone, the +standalone HTML export, and pagination time. + +**Do this instead:** precompute **16 jitter buckets** as CSS classes. + +```html +handwriting +``` + +```css +.scripto-doc[data-hand] .hw { display: inline-block; will-change: auto } +.scripto-doc[data-hand] .hw-7 { transform: translateY(.35px) rotate(.42deg); font-variation-settings: 'wght' 412 } +/* …16 buckets, amplitudes multiplied by --hw-neatness at the root… */ +``` + +Bucket = `prng(seed, wordIndex) * 16 | 0`. Sixteen buckets is enough to defeat the eye and costs +16 CSS rules instead of 50,000 style attributes. Amplitudes scale from one root custom property +(`--hw-neatness`) so the *neatness* slider is a single CSS variable change — **no re-parse, no +re-wrap, no re-pagination.** That is the difference between a slider that feels instant and one +that hangs the tab. + +### 3.3 Never split inside a word + +Split on whitespace only. **Never split a word into per-character spans.** + +- For Latin it destroys kerning and ligatures. +- **For Arabic it is catastrophic** — Arabic is a connected script; splitting a word breaks glyph + shaping and the text renders as disconnected isolated forms. It becomes unreadable. + +Therefore `variation: 'expressive'` must **never** mean glyph-level DOM splitting. It means: +more buckets (32), wider amplitude, and OpenType stylistic sets — nothing structural. + +### 3.4 Pressure, via variable fonts + +Caveat ships a `wght` axis. Where the active hand is variable, add a jittered +`font-variation-settings: 'wght' <380–460>` to each bucket. This simulates pen pressure and is the +single highest-payoff detail in the whole engine — it is what stops the text looking flat. +Gate it behind a per-hand `variable: true` flag in the hand registry; for static fonts, omit it. + +### 3.5 Performance contract + +- `variation: 'none'` → the plugin returns the tree untouched. Zero cost. +- Above **20,000 words**, degrade to `'none'` automatically and toast once (i18n string). Do not + let a thesis freeze the tab. +- Wrapping runs inside the existing debounced render path — it must not add more than ~15 ms for a + typical 10-page document. Measure it; report the number. +- `transform` on inline-blocks is compositor-friendly, but 50k composited layers are not. Do **not** + set `will-change: transform` on `.hw`. + +--- + +## 4. Stationery — and the hard part + +### 4.1 Papers, all CSS-only + +Every stationery is built from `repeating-linear-gradient` / `radial-gradient` / `conic-gradient`. +**Zero image bytes.** Live in a new `src/styles/stationery.css`, keyed `[data-stationery='x']`. + +Note `cornell` (cue column + summary band), `practice-lines` (4 lines with a dashed midline — this +one exists for teachers making handwriting worksheets, a real and underserved audience), and +`music-staff`. + +### 4.2 ⚠️ Rule-pitch locking — read this twice + +**Handwriting on ruled paper must sit ON the rules.** If the text baseline and the rule are even +1 px out of phase, the whole illusion collapses and it looks worse than plain text. Three +constraints, all mandatory: + +1. **`line-height` must equal the rule pitch exactly.** When a ruled stationery is active, derive + `--doc-leading` from the pitch instead of `config.lineHeight`, and **disable the line-height + slider in the UI** with a short explanation (i18n) rather than letting the user silently break it. +2. **The top margin must be an integer multiple of the pitch.** Add + `snapMarginsToRule(margins, pitch)` in `src/lib/handwriting/rules.ts` and apply it when a ruled + stationery is active. Surface the adjusted value in `ConfigPanel` so nothing appears to change + behind the user's back. +3. **The rules must restart in phase on every page.** This is the real problem. Under Paged.js each + `.pagedjs_page` is its own box, so a background painted on the document body will drift. Paint + the rules on the **page box** in `src/pdf/pageStyles.ts` (`@page { background-image: … }` / + `.pagedjs_page` background), not on `.scripto-doc`, and ensure the content box top sits at an + exact multiple of the pitch from the page top. + +**Acceptance for §4.2:** export a 5-page ruled document. On **every** page, every line of body text +must sit on a rule. Check page 1, page 3 and the last page. If page 4 drifts, this is not done. + +### 4.3 Aging + +`aging` (0–1) drives: paper yellowing (a warm overlay), ink fade (opacity + slight desaturation), +edge vignetting, and fold creases (two faint gradient lines). All CSS. Fold creases and vignette are +subtle at 1.0 — this is a seasoning control, not a filter. + +--- + +## 5. Hand-drawn elements + +With `drawnElements: true`, the machine-straight furniture is replaced by hand-drawn strokes. +Implement as **inline SVG paths generated from the seeded PRNG**, in +`src/lib/handwriting/strokes.ts` — three stroke variants per element type, chosen by bucket. + +| Element | Hand-drawn treatment | +| --- | --- | +| `h1`–`h3` | A wobbly underline stroke; H1 optionally double-struck | +| `hr` | A squiggle, never a straight rule | +| `blockquote` | A drawn bracket down the inline-start edge | +| `ul` bullets | Drawn dots / dashes / asterisks, varying per item | +| Task checkboxes | A drawn box; checked items get a drawn tick that overshoots the box | +| `table` | Hand-ruled borders with slight wobble | +| Callouts | A drawn box plus a margin doodle for the type | +| `==mark==` | A highlighter swipe with uneven ends, not a rectangle | +| `a` | A hand underline in the ink colour | +| Images | Photo-corner mounts or washi tape | + +### 5.1 Code, math and diagrams — the "taped-in printout" + +**Do not set code, math or Mermaid in a handwriting font.** It becomes unreadable, and this is the +mistake every handwriting theme makes. + +Instead present them as artefacts that were *printed and taped into the notebook*: keep the +monospace/KaTeX/SVG rendering exactly as-is, and give the block a screen-and-print treatment — a +white card, a fraction of a degree of rotation (seeded, so it is stable), a soft drop shadow, and +tape corners. It reads as deliberate. It is also the honest engineering answer, because it preserves +legibility, selectable text and vector output. + +### 5.2 Sketchy diagrams — free + +Mermaid 11.16.0 (installed) supports `look: 'handDrawn'` natively. When `drawnElements` is on, pass +it in the Mermaid init config in `src/markdown/components/Mermaid.tsx`. Combined with +`handDrawnSeed` (set it from `HandConfig.seed`) the diagrams are deterministic too. **No new +dependency, roughly ten lines.** It is the highest payoff-to-effort item in this brief — do it early +so the demo lands. + +## 6. Fonts — loading, offline, and PDF weight + +Handwriting fonts must **not** join the global font URL in `index.html`. That URL is preloaded on +every visit including the marketing site; adding 15 display faces would wreck LCP and CLS on pages +that will never use them. + +**Required approach:** +- A hand registry in `src/lib/handwriting/hands.ts`: + `Record`. +- Load **on demand** when a hand is selected — inject a `` for that family + only, once, and track loaded families in a module-level `Set`. +- **`await document.fonts.load(...)` before allowing export.** `renderPaged.ts` already awaits + `document.fonts.ready`; a hand that is still loading when Paged.js measures will paginate against + the fallback metrics and the PDF will be wrong. Block the Print Preview until the hand is ready, + showing the existing progress UI. +- **PWA/offline:** Google Fonts are runtime-cached, so a hand used once works offline afterwards, + but a *newly chosen* hand will not load on a plane. Detect the failure and toast honestly + (i18n) rather than silently rendering the fallback. +- **`src/io/exporters.ts` `FONTS_HREF`** must include the *active* hand for the standalone HTML and + Word exports — build it dynamically from the config instead of the current constant. +- **PDF size:** browser print embeds the full face. Display scripts are large; a handwriting PDF may + be several MB heavier. Note it in the export UI when `hand !== 'none'`. + +### 6.1 Arabic hands — the differentiator + +No markdown→PDF tool does Arabic handwriting. Ruqʿah (`Aref Ruqaa`) is the everyday Arabic hand and +this is genuinely unprecedented in this product category. + +Rules: Arabic hands are `scripts: ['arabic']` and must be filtered out of the picker for LTR +documents (and vice-versa) — or clearly marked, since a Latin hand renders Arabic as tofu. Word-level +jitter is safe; §3.3's no-glyph-splitting rule is **absolute** here. `slant` is meaningless for +Arabic — hide the control when an Arabic hand is active. + +--- + +## 7. "Your own handwriting" — two paths + +Fully client-side, and the reason people will tell other people about this. + +### 7.1 Path A — import a font (ship this first) + +1. **Scripto generates the capture sheet.** A new template (`handwriting-capture`) renders a grid of + boxes, one per character, sized for a service like Calligraphr. The user exports it as a PDF from + Scripto itself — the product produces its own onboarding artefact. +2. They print it, write in it, scan it, convert it (Calligraphr has a free tier) to `.ttf`/`.otf`/`.woff2`. +3. **They upload the font back.** Store the bytes in IndexedDB, register at runtime via the + `FontFace` API, expose as `hand: 'custom'`. +4. It embeds in the PDF like any other font. + +**Constraints:** validate magic bytes and reject non-fonts; cap ~5 MB; handle `FontFace` rejection +with a real message; let the user name, list and delete hands. Never send the file anywhere — say so +plainly in the dialog. + +### 7.2 Path B — draw your alphabet in the browser (the moonshot) + +Path A depends on a printer, a scanner and a third-party service. **Path B removes all three.** + +1. A canvas glyph grid: the user writes each character with a finger, stylus or trackpad. +2. **`perfect-freehand`** converts each pointer stroke into a variable-width outline — capture the + stroke as vectors from the start, so there is **no raster-tracing step at all**. +3. **`opentype.js`** assembles those outlines into glyphs and **writes a real `.otf`** in the browser. +4. Register via `FontFace`, store in IndexedDB, use it like any other hand. + +Fully offline, no account, no upload, no external service. This is the single most shareable thing +in this brief — see §9. + +**Scope control:** Latin upper + lower + digits + common punctuation (~70 glyphs) is enough. Derive +sensible side bearings from the drawn bounding box; do not attempt kerning pairs. Autosave progress +so a half-finished alphabet survives a reload. **Do not attempt Arabic in Path B** — Arabic needs +four positional forms per letter plus shaping rules, which is a different and much larger project. +Say so in the UI rather than shipping something broken. + +## 8. Working across all 29 skins and every template + +The handwriting axis composes with **29 skins × 58+ templates**. Most combinations are fine; some +are actively wrong. Handle this with data, not hope. + +### 8.1 Hand affinity — a required field on every skin + +Add to `SkinOption` in `src/data/skins.ts` (required, so `tsc` forces a decision for all 29): + +```ts +/** How well this skin receives a handwriting hand. */ +export type HandAffinity = 'native' | 'good' | 'adapts' | 'discouraged' +``` + +| Affinity | Meaning | Skins | +| --- | --- | --- | +| `native` | Designed for a hand; ships one by default | the 6 new skins (§10.1) | +| `good` | Reads well immediately | `modern` `classic` `notebook` `manuscript` `zen` `editorial` `playful` `handout` `poster` `elegant` | +| `adapts` | Works once the skin's own furniture yields (§8.2) | `compact` `memo` `letter` `thesis` `journal` `newsprint` `brutalist` `dark` `corporate` `technical` | +| `discouraged` | Handwriting fights the skin's purpose | `terminal` `blueprint` `swiss` `ledger` `invoice` `contract` `rfc` `changelog` `resume` | + +**`resume` deserves a specific warning.** A handwritten CV defeats the entire point of the ATS skin. +Warn clearly and offer to switch skins — but still allow it. Users get to make their own choices. + +**Policy — guide, never block:** +- The skin picker and Skin Rail show a small hand-affinity badge. +- Applying a hand to a `discouraged` skin shows a **one-time, dismissible inline notice** (not a + modal, not on every change) offering a one-click switch to `handwritten`. +- Nothing is ever hard-blocked or silently overridden. + +### 8.2 Yield rules — the compatibility work + +Every skin decorates with its own furniture. Some of it contradicts a hand and must yield when +`[data-hand]` is present on the root. Because `documentDataAttrs` already emits `data-skin`, adding +`data-hand` makes combined selectors natural: + +```css +.scripto-doc[data-skin='corporate'][data-hand] h2 { background: none; color: var(--ink) } +``` + +**Audit all 29.** Most need nothing; write an override block only where the skin genuinely fights +the hand. Known cases to check: + +| Skin | What must yield | +| --- | --- | +| `corporate` | Filled heading bars → a drawn underline | +| `technical` | Boxed sidebars → drawn boxes | +| `swiss` / `brutalist` | Heavy machine rules → drawn rules, or hold the skin's rules deliberately | +| `terminal` / `dark` | Ink colour must invert to a light chalk/gel ink; `chalk-white` is the sane default | +| `ledger` / `invoice` | **Keep tabular figures monospace** — handwritten numerals in a totals column are unreadable | +| `newsprint` | The kicker rule → a drawn stroke | +| `memo` / `letter` | The header band → a drawn rule | +| `poster` | Display sizes need jitter amplitude scaled *down* — large type magnifies wobble | + +**That last point is a general rule:** jitter amplitude must scale **inversely** with font size, or +headings look drunk while body text looks fine. Bake it into the bucket CSS via `em`-relative units, +not `px`. + +### 8.3 Templates + +All 58+ templates must render acceptably with a hand applied. Add a **dev-only contact-sheet route** +(`/app?dev=hand-matrix`, excluded from the SSG route table) that renders every template at thumbnail +size with `hand: 'casual'` — one screen, one glance, obvious breakage. Fix what breaks; for templates +where a hand is genuinely wrong (`invoice`, `contract`, ATS résumés), rely on §8.1's affinity badge +rather than special-casing. + +### 8.4 Applying smoothly — the transition contract + +Turning a hand on or off swaps the font, which changes metrics, which reflows the document. Done +naively it is a jarring flash-and-jump. Required sequence: + +1. **Load the font first.** `await document.fonts.load('1em ')` **before** touching config. + Never apply a hand that has not loaded — the fallback flash followed by a metric jump is the + single worst thing this feature can do. +2. Show a brief inline pending state on the Handwriting toggle while loading. +3. Apply in **one frame**, and run the transition through the existing + `src/preview/motion/useStageTransition.ts` — reuse the vocabulary, do not invent a second + transition system. +4. `neatness`, `slant` and `aging` are **CSS-variable-only** changes (§3.2) — instant, no re-wrap, + no re-pagination. Verify by dragging the slider on a 20-page document. +5. Under `prefers-reduced-motion`, cross-fade only. +6. If the font fails to load (offline, blocked), **do not apply the hand.** Toast honestly and leave + the document as it was. + +--- + +## 9. The viral moment — design for it deliberately + +The output has to be worth posting, and posting it has to bring people back. Four mechanics, all +frontend: + +1. **The artefact is the ad.** A handwritten PDF page is recognisable at thumbnail size in a + timeline — which is exactly what the Stage system already optimises for. When a hand is active, + set the **"Made with Scripto" attribution footer in the same hand**. Small, charming, and it + signs every shared document without being an ad. +2. **Shareable hand recipes.** `HandConfig` is tiny. Encode it into the existing deep-link system + (`src/hooks/useDeepLinks.ts`, already allowlist-validated) as `/app?hand=`, so + someone can post *"here's my exact setup"* and a stranger lands in the editor with it applied. + Reuse the existing validation pattern — **never `eval` or trust the parameter shape.** +3. **"I made a tool that writes in my handwriting."** §7.2 is the story. Path B — draw your alphabet + with your finger, get a real font, export a PDF — is a 20-second screen recording that explains + itself with no voiceover. Build it, then record it. +4. **Feed the content machine** (`ARCHITECTURE.md` §14 — pages generate automatically): + a `/handwriting` use-case page (+ Arabic variant), the six new skin pages, the new template pages, + and a blog post. Add a **before/after slider** on the landing page: the same Markdown as a plain + PDF and as a handwritten one. + +**Honest framing:** market it as *notes, letters, journals, cards and worksheets* — not as +"make documents look authentic." That framing is both truthful and, per §12, the right boundary. + +--- + +## 10. Skins, stages, templates and presets + +### 10.1 Six new skins — each needs a stage (compile-enforced) + +Add to `DocumentSkin`, `SKIN_OPTIONS` (group `expressive`, except `worksheet` → `academic`; +all six `handAffinity: 'native'`), a `document.css` block, an i18n label, **and a `STAGES` entry** — +`stages.ts` is `Record`, so `tsc` fails until every one exists. + +| Skin | Character | Stage (screen-only) | Motion | +| --- | --- | --- | --- | +| `handwritten` | The default hand — casual, college-ruled | **Desk** — warm wood ground, soft lamp pool, pen shadow at the edge | `type` | +| `journal-hand` | Personal diary, aged paper, sepia ink | **Nightstand** — dim ground, warm lamp vignette | `bloom` | +| `field-notes` | Pocket notebook, small, dense, graph | **Field** — canvas texture, notebook elastic band | `snap` | +| `chalkboard` | Chalk on slate, white/pastel ink | **Classroom** — dark slate ground, chalk dust bloom | `draw` | +| `letter-hand` | Personal correspondence, fountain ink | **Writing desk** — linen ground, envelope shadow behind the sheet | `wipe` | +| `worksheet` | Practice lines, teaching material | **Schoolroom** — pale ground, pencil-tray edge | `cascade` | + +⚠️ Name it `journal-hand`, not `journal` — an academic `journal` skin already exists. + +Reuse existing `StageFrame` values where they fit; add new ones only where genuinely needed, and +keep them in `stage.css` — **never on the sheet.** + +### 10.2 Templates + +Seven, each declaring its hand in front-matter: **Handwritten letter**, **Journal entry**, +**Lecture notes** (Cornell), **Recipe card** (index card), **Handwriting worksheet** (practice +lines — for teachers), **Handwriting capture sheet** (§7.1), and an **Arabic handwritten letter** +using `ruqaa`. Add `nameKey`/`descKey` to `EN_STRINGS` **and** `STRINGS.ar`. + +### 10.3 Presets + +Add hand-aware entries to `DOCUMENT_PRESETS`, and confirm the `ExportPreset` flow round-trips a +`hand` block through save → JSON export → import. + +--- + +## 11. The UI + +A **Handwriting** section in `ConfigPanel`, collapsed by default, opening with a single `Switch`. +When off, everything below is hidden and nothing loads. + +- **Hand** — a visual picker rendering the sample phrase *in each hand*, not a text dropdown. + Grouped by script; Arabic hands only shown for RTL/auto documents. +- **Ink** — colour swatches with the pen name. +- **Paper** — thumbnail grid of the stationeries. +- **Neatness**, **Slant** — `Slider`s updating a CSS variable live (§3.2). Slant hidden for Arabic. +- **Aging** — `Slider`. +- **Hand-drawn elements** — `Switch` (lazy-loads `roughjs`). +- **Two hands** — heading hand selector. +- **My handwriting** — §7 upload / draw flow. + +Plus a `⌘K` command, hand-affinity badges in the skin picker and Skin Rail (§8.1), and hover-preview +in the rail for the six new skins. + +--- + +## 12. Guardrails — handwriting, signatures and authenticity + +Handwriting plus aging plus signatures is, taken together, a document-forgery kit. This feature is +legitimate and creative and should ship — but keep these boundaries: + +- **Do not build handwriting replication from a photo or writing sample.** §7 is the user's *own* + hand, produced by their own deliberate effort. Never "match this handwriting" from an image. +- **Signature blocks are signature *lines*** — a labelled ruled space to sign, not a rendered + imitation of a real person's signature. +- **Do not ship preset combinations framed as making a document look like a genuine aged or official + record.** Aging is a stylistic control, not an authenticity simulator. +- Leave the "Made with Scripto" attribution default **on**. +- Keep the framing creative — notes, letters, journals, worksheets, cards. + +--- + +## 13. Verification + +```bash +npx tsc -b --force && npm run lint && npm run build && npm run test +SCRIPTO_VISUAL=1 npm run test +``` + +**New tests (required):** +- **Determinism** — the PRNG yields byte-identical bucket sequences across two runs for one seed. +- **Skip list** — the plugin leaves `pre`, `code`, `.katex` and `svg` subtrees untouched (assert node + counts unchanged). +- **Arabic integrity** — an Arabic paragraph splits only at spaces; no word contains a nested `.hw`. +- **Rule snapping** — `snapMarginsToRule` returns an exact multiple of the pitch. +- **Affinity exhaustiveness** — every one of the 29 skins declares a `handAffinity`. +- **Visual fixture** — `tests/visual/fixtures/handwriting.md` (ruled + drawn elements + a code block + + a table) in the Paged.js harness. + +**Manual:** +- [ ] **`hand: 'none'` is a total no-op** — PDF byte-identical to `main`, and **zero** handwriting + font / `roughjs` / `opentype.js` requests in the network tab. +- [ ] A 5-page ruled document: text sits on the rules on **every** page (§4.2). +- [ ] Code, math and Mermaid stay legible in a handwriting document. +- [ ] Arabic Ruqʿah exports correctly RTL with connected letterforms intact. +- [ ] Exported PDF text is still **selectable and searchable** — nothing rasterised. +- [ ] The hand-matrix contact sheet (§8.3): no template is visibly broken. +- [ ] Neatness slider on a 20-page document: instant, no re-pagination. +- [ ] Toggling a hand on/off: no fallback flash, no metric jump (§8.4). +- [ ] 20k-word document degrades gracefully with the toast. +- [ ] Offline: a used hand works; a new one fails with an honest message. +- [ ] Reduced motion, LTR + RTL, light + dark, 375 px. + +--- + +## 14. Out of scope + +Anything needing a server. Also explicitly **not** now: rasterising SVG-filter ink texture into the +PDF (it destroys the text layer — prototype it screen-only and opt-in if at all), scan/photocopy +simulation, glyph-level DOM splitting (§3.3), and Arabic in the §7.2 draw-your-own flow. + +--- + +## 15. Suggested order + +**Release 1 — "Handwriting"** +1. §2 data model + prove `hand: 'none'` is a no-op +2. §6 font registry + on-demand loading (nothing works without it) +3. §5.2 sketchy Mermaid — ten lines, biggest demo payoff, do it early +4. §3 variation engine + determinism tests +5. §4 stationery, then **§4.2 rule-pitch locking — budget real time for this** +6. §5 hand-drawn elements via `roughjs` +7. §8 the 29-skin affinity + yield audit, §8.4 smooth application +8. §10 skins/stages/templates, §11 UI + +**Release 2 — "Your own hand"** +9. §7.1 font import +10. §7.2 draw-your-alphabet (`perfect-freehand` + `opentype.js`) +11. §9 viral mechanics + content + +Ship the two releases separately. Do not batch. + +--- + +## 16. Final report + +What shipped; screenshots of the 6 new skins, the stationery grid, and the §8.3 hand matrix; the +`hand: 'none'` PDF diff result; the §3.5 wrap-time measurement; PDF size delta with a hand active; +bundle delta with `drawnElements` off vs on; and anything deferred, with the reason. diff --git a/docs/V4_VISUAL_EDITOR_PROMPT.md b/docs/V4_VISUAL_EDITOR_PROMPT.md new file mode 100644 index 0000000..9729bfa --- /dev/null +++ b/docs/V4_VISUAL_EDITOR_PROMPT.md @@ -0,0 +1,485 @@ +# Scripto — The Visual Document Editor + +> **The third brief.** `V2_FRONTEND_PROMPT.md` (Stage system, page-break editor, export presets, +> `App.tsx` decomposition) is delivered. `V3_HANDWRITING_PROMPT.md` is the handwriting engine. +> **This one is the largest and the most dangerous** — it changes what Scripto *is*. +> +> **Goal:** *"A professional visual document editor powered by Markdown"* — not *"a Markdown editor +> with a PDF preview."* Click anything in the preview, edit it, and the Markdown updates. +> +> **Still frontend-only. No backend.** +> +> §1 contains five architectural decisions. **Four of them contradict the obvious approach, and +> getting any one wrong makes the feature unshippable.** Read §1 before anything else. + +--- + +## 0. What already exists — do not rebuild it + +A generic version of this brief would say "create a document AST", "build a theme system", "add +source mapping". **All three already exist here.** Building parallel versions is the main way this +project can fail. + +| The generic instruction | The reality in this repo | +| --- | --- | +| "Create a document AST" | **mdast already is it.** react-markdown parses to mdast → hast. `mdast-util-*`, `remark-parse`, `remark-stringify` and `mdast-util-to-markdown` are **already in `node_modules`** (transitively — declare them directly if you import them). | +| "Add source position mapping" | **`src/markdown/plugins/rehypeSourceLine.ts`** already stamps `data-source-line` on every block element, and `useScrollSync` already consumes it. It needs *extending* (§2.1), not writing. | +| "Build a theme system" | `src/pdf/documentStyle.ts` maps `PdfConfig` → CSS custom properties + `data-*`; `document.css` reads them. It needs *widening* from 4 tokens to a full set (§6). | +| "Templates all look the same" | **Audit before rebuilding.** Commit `e96999e` ("give every skin its own component language") and `42cb1e3` ("pair every template with a skin") landed recently. There are now **29 skins in 6 groups**. Screenshot all 29 first and find what is *actually* still undifferentiated. | +| "Add a directive system" | `remark-directive` is already a dependency and already powers callouts (`:::tip`) and page directives (`::page-break`, `:::keep-together`, `:::landscape`). | +| "Add undo/redo" | CodeMirror 6 already owns a full history. §1.4 explains why you must not build a second one. | + +**Also still true — the five invariants from `V2_FRONTEND_PROMPT.md` §0.** Re-read them. The one +this brief stresses hardest: **`.scripto-doc` is cloned verbatim into the PDF, HTML and Word +exports.** Every decision in §1 follows from it. + +--- + +## 1. The five decisions + +### 1.1 mdast is the document model + +Do not invent a new AST. mdast is a mature, positioned, spec'd document model with a complete +utility ecosystem, and it is already what the renderer consumes. A parallel model would need to stay +in sync with it forever — a permanent bug source for zero benefit. + +**The document model is: the Markdown string + its mdast parse.** Nothing else is authoritative. + +### 1.2 ⭐ Positional splicing — never whole-document re-serialization + +**This is the decision that determines whether the feature ships.** + +The obvious design is `edit → mutate AST → remark-stringify → new Markdown`. **Do not do this.** +`remark-stringify` normalises the *entire* document on every write: + +| The user wrote | It comes back as | +| --- | --- | +| `* item` | `- item` (bullet marker normalised) | +| `_emphasis_` | `*emphasis*` | +| `Setext heading\n===` | `# Setext heading` | +| A hand-aligned table | Re-aligned to its own convention | +| Their line wrapping | Re-wrapped | +| Their raw HTML | Reformatted | + +So the first click on a checkbox silently rewrites the user's whole file. Their diff explodes. Their +git history is noise. **This is the single most common reason bidirectional Markdown editors fail**, +and the brief you are replacing asks for exactly this ("Do NOT introduce ugly, unreadable generated +Markdown" — while specifying the architecture that guarantees it). + +**Do this instead.** Every mdast node carries +`position: { start: { line, column, offset }, end: { … } }`. An edit is therefore a **byte-range +splice on the Markdown string**: + +```ts +const next = markdown.slice(0, node.position.start.offset) + + replacement + + markdown.slice(node.position.end.offset) +``` + +Everything outside that range stays **byte-identical**. The user's formatting, spacing and style are +untouched. The diff is one line. + +**The rule, stated once:** *serialize only what is new; never re-serialize what already exists.* +When inserting a genuinely new node, use `mdast-util-to-markdown` on **that node alone** and splice +the result. Never stringify the root. + +### 1.3 Overlay editing — not `contenteditable` + +Making `.scripto-doc` editable is tempting and wrong, for three reasons: + +1. It is **cloned into the PDF/HTML/Word export**. `cleanClone()` in `src/io/exporters.ts` already + strips `contenteditable` — someone has already been bitten by this. Do not add more of it. +2. `contenteditable` + React + a Markdown round-trip is a well-known disaster: the browser injects + `
`, `
`, `` and pasted formatting that has no Markdown representation. +3. React will fight the browser over DOM ownership on every re-render. + +**Instead:** clicking a text node opens a **positioned overlay** — a plain `