Skip to content

Dev - #16

Merged
atom-tech9 merged 28 commits into
mainfrom
dev
Sep 1, 2026
Merged

Dev#16
atom-tech9 merged 28 commits into
mainfrom
dev

Conversation

@atom-tech9

Copy link
Copy Markdown
Owner

Summary

Type of Change

  • feat — new feature
  • fix — bug fix
  • docs — documentation only
  • refactor — code change that neither fixes a bug nor adds a feature
  • chore — tooling / maintenance
  • test — adding or updating tests
  • perf — performance improvement
  • ci — CI / build changes

Screenshots

Checklist

  • npm run typecheck passes
  • npm run lint passes
  • npm run build passes
  • Follows the project code style (no any, immutable updates, logical CSS)
  • i18n keys added for any new user-facing strings (en and ar)
  • RTL layout verified for UI changes
  • No console.log (uses @/lib/logger)
  • Documentation updated if needed

…/useCommands

Phase G of the v2.0 brief. No behaviour change beyond two deliberate fixes.

- hooks/useDialogs.ts   one reducer replaces 15 overlay useStates; open/close/
  toggle and the per-name thunk lookups are identity-stable so effects and
  memos never re-run on a dialog toggle.
- hooks/useAiActions.ts all AI assist behaviour (staged suggestions, prompt
  driven actions, ghost completion).
- hooks/useDeepLinks.ts the ?template=/?skin= allowlist effect.
- hooks/useCommands.ts  builds the 40-entry command list from injected handlers.

App.tsx: 1150 -> 832 lines.

Fixed along the way:
- the AI abort controller was never aborted on unmount, leaving a request in
  flight after navigation/lock; useAiActions now aborts on cleanup.
- the editor activity rAF was never cancelled on unmount.
- command-palette AI entries bypassed handleAiAction, so those invocations were
  missing from the 'AI Action' analytics event; every entry point now routes
  through the one dispatcher.
Phase A of the v2.0 brief. A hard split between the screen-only Stage and the
sacred Sheet: every ground, texture, light, paper treatment, ruler and motion
lives OUTSIDE `.scripto-doc`, which the export path still clones verbatim.

New module `src/preview/`:
- stage/stages.ts    21 stage descriptors as DATA, typed
                     Record<DocumentSkin, StageDescriptor> so tsc proves all 21
                     exist. Visuals are CSS keyed by [data-stage='x'].
- stage/stage.css    token layer (depth/ground/edge/motion scales) + the 21
                     stages. Pure CSS gradients: 4.94 KB gzipped, 0 image bytes
                     (budget was 12 KB).
- motion/vocabulary  the 10 shared primitives (rise/wipe/draw/type/snap/spring/
                     glow/cascade/bloom/stamp) plus a small per-skin signature
                     table, so stages share primitives without bespoke code.
                     transform + opacity only; never applied to `.scripto-doc`.
- motion/useStageTransition  interruptible skin-to-skin transition, veil run and
                     the brief stage-name label.
- chrome/            PreviewToolbar (Flow/Pages/Focus, zoom, stage level),
                     SkinRail (hover to preview, click to commit, full keyboard
                     listbox), PageRuler, FocusOverlay, PreviewEmpty.

Preview.tsx is now a thin adapter; PreviewHandle.getDocElement() is unchanged.

The stage sizes itself with container queries rather than viewport units: the
preview pane is often a third of the window, and `vw` sizing left the sheet
absurdly narrow next to an open settings panel. The rail, the toolbar labels and
the zoom stepper all respond to the pane's own width.

Also fixes tests/visual/harness.ts, which produced no output at all on Chrome
>=132: that release removed the old headless mode, and the new one ignores
--virtual-time-budget, so --dump-dom snapshotted the page long before Paged.js
finished. The harness now drives Chrome over the DevTools Protocol and polls for
the probe. The suite went from 0/25 to 19/25 — and the 6 remaining failures
reproduce identically on bc6eba0, so they are pre-existing layout bugs the
harness simply could not surface before, not regressions from this work.
Phase B of the v2.0 brief. A UI layer on top of the directive system that
`remarkPageDirectives` already ships — no new pipeline.

- pdf/pageBreaks.ts   pure, tested Markdown mutations: insert/remove a
                      `::page-break`, wrap a run in `:::keep-together` or
                      `:::landscape`, and map a body line to a document line
                      through the front-matter offset.
- preview/print/      usePageBlocks reads the paginated DOM back to source lines
                      via the `data-source-line` anchors scroll sync already
                      uses; PageBreakLayer draws the affordances.
- PrintPreview        "Break here" between blocks, a removable chip on each
                      existing break, click / shift-click block selection with
                      Keep together + Landscape, an inline landscape prompt on
                      any block too wide for the page, and the fitToPage result
                      surfaced as a warning strip ("N scaled / N still clipped").

Every action rewrites the Markdown through the normal setter, so undo, the
editor and the export stay in step; the rendered DOM is never authored.

An insert snaps to the nearest block boundary rather than splitting a paragraph
in half — which also makes the edit exactly two lines, so insert then remove
restores the document byte for byte. Covered by 15 unit tests and verified live:
inserting took the sample from 5 to 6 pages, and removing it returned the
document to its original 3,373 characters.

Also fixes a bug found while verifying: `.page-break` rules are zero-height by
design, so the visibility guard rejected them before they could be recognised as
markers, and no break was ever removable.
Phase C of the v2.0 brief; the last feature RENDERING_AUDIT.md §8 listed as
unstarted.

- ExportPreset { id, name, config, createdAt } at `scripto:export-presets`.
- lib/presets.ts holds the one merge rule every preset path now shares, so the
  built-in themes and user presets can never drift apart. A named margin preset
  wins over the config's current margins, exactly as applyPreset always did.
- A preset captures the presentation half of the config and never the document
  `meta`: a title and author belong to a document, not to a house style. Import
  strips `meta` back out of anyone else's file too.
- PresetsDialog: save the current settings, apply, rename, delete, and move
  presets between machines as .json — the closest thing to a shared house style
  without a backend. Reachable from the settings panel, the theme gallery and
  ⌘K.
- Imported files are untrusted: every entry is narrowed individually and
  anything malformed is dropped rather than poisoning the library.

12 unit tests cover the merge rule, capture and the import validation.
Phase D of the v2.0 brief. The hypothesis in PAGEDJS_RTL_DEBUG_PROMPT.md was
wrong: the trigger has nothing to do with RTL.

Paged.js 0.4.3 lays out two pages and then stops dead whenever an <img> has to
be placed at a page boundary. It is a stall, not a runaway loop. Bisected from
the `checklists` fixture down to a minimal repro; ruled out RTL, task lists,
`break-inside: avoid`, `overflow-x`, undetermined image boxes, display mode,
image preloading, trailing content and an explicitly sized wrapper. Replacing the
`<img>` with any non-replaced element paginates cleanly every time — the chunker
cannot cope with the replaced element itself.

Shipped here:
- tests/visual/fixtures/image-stall.md, the minimal repro.
- An `it.fails` tripwire that passes while the bug exists and starts failing the
  moment it is fixed, capped at 25s so the suite stays fast.
- `paginate()` takes a per-call timeout.
- docs/PAGEDJS_IMAGE_STALL.md: every hypothesis tested, why no fix has shipped,
  and the exact next step.

No workaround is shipped on purpose. The obvious one — swapping the img for a
div with a background-image — needs `print-color-adjust: exact` to survive a
real Save-as-PDF, and getting that wrong would make images silently disappear
from exported PDFs. That is a worse bug than the one being fixed, and it needs
verification in a real print dialog first. The 45s watchdog already turns the
stall into a clean error, so users are not left with a frozen tab.
The 21 skins only ever differed in headings, blockquotes and table headers:
callouts had no per-skin rule in 14 of them, code blocks in 14, rules in 13 and
lists in 15-18. Every skin therefore drew the same rounded pastel callout, which
is why 21 skins read as about three.

Introduces a component token layer on `.scripto-doc` — radius, hairline width,
accent edge, fill strength, shadow, rule weight, label font/spacing/case — that
the shared components consume. A skin now redeclares a handful of values instead
of restating the components, so Brutalist gets hard 3px borders and offset
shadows, Swiss a single heavy bar, Blueprint drawn boxes with `// LABEL`,
Terminal `[NOTE]` machine labels, Newsprint printed rules, Elegant hairlines and
letterspacing, Playful stickers. +1.5 KB gzipped for all 21.

Also fixes a real bug found while auditing: the Terminal and Dark skins declared
a light-on-dark palette unconditionally, but the sheet they render on is white
and print.css forces the paginated preview back to dark-on-white. The live
preview showed nearly unreadable pale text for a PDF that was perfectly legible
— a straight violation of "preview === PDF". Both now carry a light-sheet
palette by default and their light-on-dark one under `.dark`, exactly as the
base `.scripto-doc` rules already do. The dark atmosphere lives on the Stage,
where it costs the export nothing.

Exported PDFs change by design (this was the agreed scope). The visual layout
suite reports the same 19 passed / 6 pre-existing failures as before, so nothing
regressed in pagination.
…eaching

21 -> 29 skins. Each one exists because a real document needs it, not to pad
the count, and each carries its own stage, marketing page and Arabic label.

- invoice    lining tabular figures, uppercase column heads, the last row ruled
             off as a total, money aligned to the end edge.
- contract   justified serif in numbered clauses, centred uppercase title,
             nothing decorative near the terms.
- letter     letterspaced masthead over a single accent rule, then generous
             paragraphs and room for a signature.
- journal    justified and hyphenated, small-caps section heads, and the opening
             quote set as an abstract.
- thesis     chapter openers over a rule, indented continuation paragraphs,
             wider leading.
- changelog  versions as accent chips over tight dashed lists.
- rfc        monospaced status block, uppercase numbered sections, double rule.
- handout    large type and one topic per printed page (`break-before: page` on
             each section, in the print stylesheet only).

The build picked all eight up automatically: 29 prerendered skin pages, 112
pages total, 110 sitemap URLs.

Deliberately not shipped: a two-column journal body. Multi-column fragmentation
is exactly the kind of thing the paginator already struggles with (see
docs/PAGEDJS_IMAGE_STALL.md), and a trustworthy PDF matters more than a second
column. The journal skin earns its identity through typography instead.

Visual layout suite unchanged: 19 passed, 6 pre-existing failures, 1 expected.
…t real

Two problems. First, 37 of the 54 templates declared no skin at all — only the
newest 17 ever did — so opening most templates produced a generically-styled
document and left the user to go and find the right look themselves. Every
template except Blank now declares its skin, accent and, where it matters,
margins, numbering and a table of contents. With the eight new skins the mapping
is close to one-to-one: Invoice to `invoice`, NDA to `contract`, letters to
`letter`, academic to `journal`, RFC to `rfc`, changelog to `changelog`,
onboarding to `handout`, the Mermaid diagrams to `blueprint`.

Second, the content was placeholder soup — {Topic}, Task A, "…" — which is
exactly what a template should not be. A template's job is to show what a good
document of that kind looks like, so the reader can paste their own facts over
something already shaped correctly.

Rewritten with credible, specific content: meeting notes with real decisions and
owners, a status update with believable metrics, OKRs with actual start and
target numbers, a retrospective that carries an item forward from last time, an
ADR with a status block and a rollback plan, a bug report with a real root cause
and the offending snippet, an invoice with line items and VAT, and a press
release with a dateline, quotes and boilerplate.
…ents

Two bugs, both found from a real document a user pasted in.

**Double-numbered headings.** `startsWithManualNumber` only recognised an
author's own numbering when it was followed by `.` or `)`, or was
dotted-multipart. A document numbering its sections `# 1 · META`, `# 2 · TikTok`
was therefore not recognised, so auto-numbering prepended its own counter and
spent a counter on each — rendering "2.  1 · META", "3.  2 · TikTok", with every
following number one out. The detector now also accepts `·`, `—`, `–`, `-`, `:`
and `|` as separators.

It stays deliberately generous, because the failure modes are not symmetric:
treating a number as manual when it wasn't merely leaves that heading
unnumbered, while missing one produces visibly broken output. The same predicate
drives `bakeHeadingNumbers`, so the preview and the PDF agree either way.

**A partial document record could crash the app.** `importDocs` shallow-merged
untrusted JSON over the defaults, so a file carrying `meta: { title: 1 }` — or
no `meta` — reached the UI and threw on `config.meta.title.toLowerCase()`. The
same was true of any half-written localStorage record, and because the state
survived the reload, the error boundary's "Try again" could not recover: the
only way out was clearing site data.

Validation now happens at the boundary, in a pure `lib/documentRecord.ts`:
records without string content are dropped, ids and timestamps are replaced when
unusable, and configs are completed field by field including nested meta,
margins and custom size. `loadLibrary` also repoints `activeId` when it does not
match a surviving document. Imported records get fresh ids so they cannot
collide with existing ones.

Covered by 8 new tests for the numbering separators and 10 for the record
validation.
The rail's cards were a generic mini-sheet — three grey lines on the stage's
ground — so with 29 skins they all read the same, which is the opposite of what
a skin picker is for.

Each card is now a real `.scripto-doc` in that skin, laid out at full size and
scaled down. The thumbnail is the skin itself rather than a drawing of it:
Modern's bold sans, Classic's serif, Résumé's letterspaced caps, Blueprint's
mono `//`, Terminal's `>`, Corporate's accent — all visible at a glance. A new
skin gets a correct thumbnail with no extra work.

Also:
- The catalogue gained a `group`, and the rail is now sectioned into Essentials,
  Business, Technical, Editorial, Academic and Expressive. 29 items in one flat
  strip was no longer scannable.
- Each card carries its name, with a flyout on hover giving the full name and
  the stage it opens onto.
- Options moved from `<button>` to `role="option"` elements: a button inside a
  listbox was the wrong role, and it could not legally contain the heading and
  paragraph the miniature needs.
- Ends of the list fade so a scrollable rail looks scrollable, and the selected
  card is marked by both a ring and its label.

The miniatures are safe next to the export path: every consumer of the live
document reaches it through `PreviewHandle.getDocElement()`, and the only
`.scripto-doc` query in app code is scoped to a paginated page.
The rail was a permanent 92px strip pinned beside the settings panel. With 29
skins it forced 9px labels and miniatures too small to read, it made a third
column of chrome next to the editor and the settings panel, and it took that
width away from the preview for the whole session in exchange for a picker
people use occasionally.

It is now a panel opened from the toolbar, which also shows the current skin's
name. Inside: a search field, sticky group headings, and a two-up grid of cards
large enough to read. Each card names the skin and the stage it opens onto, and
is still a real `.scripto-doc` in that skin — the card is the skin itself rather
than a drawing of it, so a new skin needs no artwork. The last line fades so the
crop reads as intentional.

Hover still previews without writing to config, click commits and closes, arrow
keys walk the grid, Enter applies, Escape and click-away close and restore the
committed skin. The preview keeps its full width when the palette is shut.

Also adds roughjs, perfect-freehand and opentype.js for the handwriting engine.
roughjs dedupes against the copy Mermaid already ships, so it costs no extra
bundle; the other two are lazy-loaded only by the flows that need them.
Handwriting as its own axis rather than a skin: hand x ink x stationery x
neatness x slant x variation x aging, composing with all 29 skins instead of
needing 20 more of them.

Data model
- `HandConfig` on `PdfConfig`, with front-matter mapping for `hand`, `ink`,
  `stationery`, `neatness`, `aging` and `drawn`, validated against the unions
  the same way every other key is.
- `hand: 'none'` is a total no-op, and there are tests that say so: no
  attribute, no custom property, no stylesheet match, no font request.

Determinism
- All jitter is a pure function of `(seed, wordIndex)` via mulberry32 — never
  Math.random. Fresh randomness would change word widths on every pagination,
  which means different page breaks each time Print Preview opens, a preview
  that no longer matches the PDF, and a document that shimmers as you type.
- A word's bucket does not depend on how many words precede it, so editing the
  top of a document cannot reshuffle the bottom.

Variation
- `rehypeHandwriting` wraps words — never characters. Per-character spans
  destroy Latin kerning, and for Arabic they are catastrophic: it is a connected
  script, and splitting a word breaks shaping into unreadable isolated forms.
- Jitter is 16 (or 32) CSS bucket classes, not an inline style per word: a
  100-page document would otherwise carry ~50,000 style attributes into the DOM,
  the export clone and the paginator. Neatness, slant and aging are therefore
  single custom-property changes — instant, with no re-wrap and no
  re-pagination.
- Code, maths and Mermaid keep their own typeface and are presented as artefacts
  taped into the page. Setting them in a handwriting font is the mistake every
  handwriting theme makes.
- Mermaid draws its sketchy variant natively, seeded from the same document seed.

Rule locking
- Ruled paper locks the whole vertical rhythm to the rule pitch in absolute
  units, and snaps the top margin to a whole rule. A derived unitless leading
  was tried first and abandoned: the rounding error accumulates down the page
  until the text visibly drifts off the lines.

Everything is written by hand
- Headings, table headers and cells, callout titles and bodies, list items and
  their markers, definitions, blockquotes and footnotes all take the hand.
  Inheriting from the root was not enough — skins set `font-family` directly on
  those elements, and a direct declaration beats an inherited one.

Arabic
- Aref Ruqaa, Lateef and Mirza, filtered to RTL documents, with the slant
  control hidden because slant is meaningless for Arabic. Verified rendering
  RTL with connected letterforms across headings, lists, tables and callouts.

Fonts load on demand, never from the global URL in index.html, and a hand is
never applied before its face can be measured — the fallback flash and the
metric jump that follows are the worst thing this feature can do, and if
Paged.js measures during that window the PDF paginates against the wrong font.
A hand arriving from front-matter or a template loads too, not just one picked
in the UI. Failure is reported plainly rather than silently falling back.

51 new tests. roughjs dedupes against Mermaid's copy; perfect-freehand and
opentype.js are installed for Release 2 and lazy-loaded only by the flows that
need them.
… numbering

Hand-drawn furniture
- Rules, bullets, checkboxes, table borders, callout boxes, quote brackets,
  highlighter swipes and link underlines are inline SVG strokes, three variants
  each, chosen by an nth-child rotation. Deterministic without a line of
  JavaScript, so it survives the export clone and prints as vector.
- Boxes use `border-image` rather than a stretched background: with
  `preserveAspectRatio: none` a wide, short element pulled the box's vertical
  strokes into black slabs that swallowed the table's first column.
- Mermaid draws its sketchy variant natively, seeded from the document seed.

Six handwriting-native skins, each with a stage (compile-enforced): handwritten,
journal-hand, field-notes, chalkboard, letter-hand, worksheet. 29 -> 35 skins,
118 prerendered pages. Chalkboard sets `print-color-adjust: exact`, since light
chalk ink on an unprinted slate would be invisible.

Fonts
- Seven more Arabic hands: Amiri, Scheherazade New, Katibeh, Noto Nastaliq Urdu,
  Gulzar, Harmattan and Rakkas — ten in total.
- Sixteen more document faces across both scripts, grouped by script in the
  picker and fetched on selection, never from the global stylesheet in
  index.html.

Right-to-left correctness
- Arabic documents number in Arabic-Indic digits, headings and ordered lists
  alike, in the preview and in the baked PDF numbering.
- Numbers are bidi-isolated: "1." was being reordered to ".1" in an RTL
  paragraph, because the full stop is a neutral character.
- The paginator's progress messages were hardcoded English and bypassed i18n
  entirely, so an Arabic user watched "Laying out pages…" go by in English.

Rule masking behind headings is now a toggle, off by default. Masking only the
heading words — not the auto-number, not the rule running on past the text —
read worse than not masking at all.

The print preview now states the two dialog settings that decide whether the
export matches it: margins none, scale 100%. Verified via Page.printToPDF that
the geometry is correct at those settings; a dialog that overrides them makes
the fixed-size page clip rather than scale, and nothing in the app could say so.
…abic

**Arabic handwriting fell back to a default face when exported.** Google Fonts
serves subsetted `@font-face` blocks keyed by `unicode-range`, and
`document.fonts.load(font)` without sample text only resolves the subset
covering its default probe string — which for an Arabic family is the Latin
one. The face reported as loaded while none of its Arabic glyphs were, so the
preview and the PDF quietly rendered in the fallback. Every load and check now
passes text in the family's own script. Verified end to end: an exported PDF
now comes out in Aref Ruqaa with connected letterforms.

**The error boundary was hardcoded to English.** It already translated its
heading, but both call sites passed a literal English `fallbackTitle` that
overrode it — so an Arabic user saw "Scripto hit an unexpected error." The prop
is now a translation key.
A prompt for a fresh session to verify this work commit by commit, written to be
adversarial rather than confirmatory: it tells the reader to assume nothing in
it is true, treats each commit message as a claim to be checked, and asks for
evidence rather than impressions.

Includes the baseline worktree setup so "did this regress?" can be answered
properly, the expected gate counts, the six known pre-existing visual failures
so nobody chases them, and the two harness traps that cost real time here —
Chrome >=132 silently ignoring --virtual-time-budget, and the editor flushing
its library over a seeded localStorage on pagehide.
Phase E of the v2 brief. Résumé and AI-output traffic is heavily mobile, and the
app was giving that traffic a degraded desktop layout: view switching hidden in
a 32px header control, the formatting toolbar pinned to the top of the screen
where no thumb reaches, and the settings panel sliding in as a side drawer that
left a sliver of document visible.

- A bottom tab bar owns navigation on a phone: Write, Preview, Settings and a
  primary PDF action, all at least 48px, inset for the home indicator. The
  header's segmented control now hides below `lg` rather than duplicating it.
- The formatting toolbar moves to the foot of the editor pane on a phone, above
  the tab bar, and scrolls horizontally as one row instead of stacking three.
  Its targets are 44px on touch and return to the compact sizes on desktop.
- Settings became a bottom sheet with a grab handle, capped in `svh`. A
  percentage max-height does not resolve reliably against a flex-sized parent,
  and the sheet grew past its pane and covered the tab bar.

Not verified: Save-as-PDF on real iOS Safari and Android Chrome. That is the
riskiest part of the product on mobile and it needs actual devices; it is called
out in docs/QA_VERIFICATION_PROMPT.md rather than assumed working.
Phase F of the v2 brief. The library — base64 images and all — was serialised
into a single localStorage string on every debounce, against a quota of a few
megabytes. One pasted screenshot could exhaust it, and the app could only
apologise afterwards.

- `lib/docStore.ts` wraps IndexedDB, degrading quietly to localStorage where it
  is unavailable (private windows, hardened profiles, site data disabled). Six
  tests cover that path, because a throw there would take the editor down
  instead of falling back.
- Migration is non-destructive: the library is copied across, read back and
  verified before the migration is marked done, and the old key is then left in
  place — unwritten but readable — so a downgrade still finds the documents.
  Verified both ways: a fresh profile migrates cleanly, and an existing
  localStorage library is copied with the original key kept.
- The first paint still reads localStorage synchronously, so opening the app is
  never blocked on a database handshake.
- Version history: up to 20 past versions per document, captured at most every
  few minutes and only when the content actually changed, restorable from a
  dialog and ⌘K. It never leaves the device.

**The vault now covers both stores.** It encrypted `scripto:*` localStorage
entries only; leaving the library in IndexedDB would have meant the whole
document set sat readable on disk while the UI said "locked". Snapshot, wipe and
restore all handle both, and restore still reads vaults written in the old flat
shape.

Found by testing rather than by reading: writes resolved on the IndexedDB
*request* succeeding rather than the *transaction* committing. A wipe therefore
returned before it was durable — and `lockNow` reloads the page the instant it
returns, which would abort the transaction and leave the documents in the clear.
Writes now resolve on `oncomplete`. The wipe was verified against a live
database before and after the fix: it genuinely did not clear before.
Phase H of the v2 brief. Actions were tracked but the funnel was not, so there
was no way to tell where a first-time user was lost: they arrived, and either a
PDF came out or it did not, with nothing measured in between.

Added: Export Dialog Opened, Print Dialog Reached, Time To First Export, Paste
Detected (rich / plain / image), Page Break Inserted, Stage Viewed, Skin
Previewed and Mobile Export Attempted.

Two of these answer questions the product has been guessing at. Paste Detected
finally measures the paste-to-Markdown path, which has shipped for a while with
nobody knowing whether it is used. Skin Previewed records whether a commit
followed a hover, which is the only way to tell whether the palette's
hover-preview earns its place.

Every property stays flat and structural — a skin id, a page count, a paste
kind, a duration in seconds. No document content, no PII.

The targets are written down next to the events, because a funnel nobody has
agreed a target for is just a dashboard: activation is a first export within the
first session, target above 35%; the north star is weekly returning exporters.
Adds handwritten letter, journal entry, Cornell lecture notes, recipe
card, handwriting worksheet, capture sheet and an Arabic ruqaa letter --
each declaring its hand, ink and stationery in front-matter, with EN and
AR names.

The menu template was entirely braced placeholders, so it read as a form
to fill in rather than a document. Replaced with a real menu whose values
happen to be editable.
Under the Arabic interface the whole app is <html dir="rtl">. In print,
the sheet stack's overflow then resolves to the *left*, so the page was
positioned off the left edge of the paper and Chrome shrank everything to
compensate. The result was small, left-clipped output that read as a font
problem -- Aref Ruqaa was embedded and drawing correctly the whole time.

Forcing the print root to LTR fixes it. Each document keeps its own
direction from the dir attribute documentStyle writes onto .scripto-doc,
so Arabic still lays out RTL where it matters.

Verified over CDP against the real export path (window.print stubbed so
the app's own print setup stays applied): with the print root left RTL,
text runs x=-17.4..62.7 on a 595pt page; with the fix, x=40.0..360.5,
matching an English-UI export of the same document.
Two ways the Save-as-PDF dialog could hand back a different document from
the one on screen:

Background graphics ships unticked, which silently dropped every painted
surface -- ruled and graph stationery, aged parchment, callout and code
fills, table banding, skin accents. Every sheet now opts in with
print-color-adjust: exact, so the checkbox has nothing left to change.
Verified over CDP against the real export path: the page content stream
is byte-identical with the option on and off (sha da89eac1, 49047 bytes).
Browser headers/footers are likewise inert -- @page already carries
margin: 0, so there is no margin box for them to occupy.

break-after: page was set on every sheet including the last, so the
browser emitted one extra blank page and a 14-page preview saved as a
15-page PDF. Breaking only between sheets fixes it; the dialog and the
in-app preview now agree.
…issing

Paged.js 0.4.3 stalls when a replaced element has to be placed at a page
boundary. flattenImages swaps every <img> for an equivalent
background-image box before pagination, sized from intrinsic dimensions
so it survives the editor pane and the printed page having different
widths. The image-stall fixture goes from an unbounded hang to 3 pages in
under 3 seconds.

The workaround was written up months ago and held back because Chrome
ships 'Background graphics' off, which would have dropped images from
PDFs silently. That objection is closed: the sheets and the flattened
boxes both carry print-color-adjust: exact, verified by driving the real
export with printBackground: false -- the PDF carries the picture as a
64x64 XObject and renders it.

Two ordering bugs surfaced on the way, either of which alone would have
defeated the swap:

  preloadImages ran after buildExportContent, so the DOM transforms saw
  images that had not decoded and reported no intrinsic size.

  The renderer marks images loading="lazy". Anything below the fold
  never loads, and an export clone is never scrolled, so those images had
  no intrinsic size at all. preloadImages now forces eager loading.

Three of the six visual failures were the harness, not the product:
unclosed fences run to end of document, an indented fence strips that
indent from its content, and KaTeX's screen-reader MathML twin is hidden
by clipping rather than by size, so it measured as forty overflows.

Visual suite: 19/25 with one expected failure -> 26/26.
Release 2 of the handwriting brief, both paths, entirely client-side.

Path A imports a font file: magic-byte validation before the bytes ever
reach the font system, a 5 MB cap, FontFace registration, and IndexedDB
storage. Each hand gets its own CSS family so several can coexist, and
config carries which one a document means.

Path B draws the alphabet in the browser. Pointer strokes are captured as
vectors from the first event and outlined by perfect-freehand, so the
drawn path *is* the glyph contour -- there is no raster-tracing step
anywhere. opentype.js assembles them and writes a real .otf. Side
bearings come from the drawn bounding box, so a narrow i advances less
than a wide m; no kerning pairs, which the brief scopes out. Progress
autosaves, so a half-finished alphabet survives a reload.

Arabic is deliberately excluded from Path B and the UI says why: four
positional forms per letter plus shaping is a different project, and
shipping something broken would be worse than not shipping it.

Verified the generated font round-trips through real Chrome, not just
jsdom: 2088 bytes, OTTO magic, FontFace accepts it and document.fonts
reports it usable.
The font builder only runs when someone draws an alphabet, but importing
it eagerly put ~600 kB of opentype.js in front of every visitor. Lazy so
the editor chunk drops from 1.5 MB to 1.3 MB and the builder loads as a
240 kB chunk on first open.
The affinity data existed on all 35 skins but was never surfaced. Badges
appear only while the document is handwritten -- on a typeset document
they would label a setting that is off -- and 'good' is left unbadged so
the grid does not fill with a word that carries no information.

Checked in a real browser: a handwritten template shows 25 badges across
35 cards with all three labels; a typeset one shows none.
…imports

A HandConfig is small enough to live in a URL, so /app?hand=<recipe>
lets someone post their exact setup and have a stranger land in the
editor with it applied. Every field is checked against the same
allowlists the pickers use, and the custom hand is never encoded -- it
names a font only its author's browser has, so a recipe carrying it would
arrive broken.

The separator is ~ rather than the obvious . because a full stop is also
the decimal point, which silently split 0.35 into two fields. Sliders
travel as whole percentages so no value can contain a separator.

Four hand-aware DOCUMENT_PRESETS: field notes, keepsake letter, Cornell
lecture notes, and Arabic ruqaa. The hand axis has eight controls and a
good combination is not obvious from the pickers alone, which is exactly
what a preset is for.

Preset files are shared, so an imported hand block is now narrowed to
values we ship rather than cast and trusted -- the same treatment stored
documents already got. Stored configs go through the same sanitiser.
…ten in it

Audited all 35 skins per section 8.2. Most needed nothing -- a skin that
decorates with type and spacing already sits happily under a hand. The
ones that fight it now yield only when data-hand is present, so a typeset
document keeps its skin exactly as designed:

  Filled and heavy heading furniture (corporate, memo, letter, poster,
  brutalist, newsprint) thins to a rule in the ink. A solid accent bar
  under handwriting reads as a printed heading someone wrote on top of,
  and a filled title block inverts the text, which no pen can do.

  Terminal, dark and chalkboard force a light ink whatever the picker
  says: a dark sheet with dark ink is an unreadable page.

  Ledger, invoice and contract keep their figures monospace and drop the
  jitter inside cells. Handwritten numerals in a totals column stop
  lining up and the reader can no longer scan it.

Jitter amplitude already scaled inversely with type size in em units, so
that part of 8.2 needed nothing.

Two fixes found while auditing:

  HTML export inlined only document.css, so a handwritten document
  arrived typeset -- every hand, paper and drawn rule dropped on the way
  out, and no font link for the hand.

  The attribution footer is now set in the document's own hand. A
  handwritten page with a line of sans-serif under it reads as a stamp;
  in the same hand it reads as the writer signing their work.
Paste-to-Markdown has shipped since MarkdownEditor.tsx:228 and nobody
knows it exists. This is the page for it: targets "chatgpt to pdf",
"claude output to pdf" and "ai answer formatting", and explains the one
thing that actually differs -- pasting a chat answer into Word imports
rendered HTML as styled text, while Scripto converts it back to Markdown
so the structure survives a later change of skin or paper size.

Registered in USE_CASES and USE_CASES_AR, so the prerender picks it up:
125 pages -> 127.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scripto Ready Ready Preview Sep 1, 2026 8:29pm UTC

Request Review

@atom-tech9
atom-tech9 merged commit 37e9b61 into main Sep 1, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — 7adf4530 Deployed Sep 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants