From 64a9700613ce66f8ebd62c6189841407ef468743 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 17:38:16 +0000 Subject: [PATCH 1/2] Make the playground usable on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was not. The inspector holds a fixed 290px beside a card up to 640 wide, which on a 375px screen pushed the card off the left edge and left the whole page scrolling sideways — so the landing page's own invitation to open the playground led somewhere broken. Below 60rem the columns become rows, the card is scaled to the room it actually has, and the inspector becomes a sheet the View menu opens rather than a column that is always there. 60rem because the widest card a stack ships with is 640 and the inspector wants 290 beside it, so anything under about 960 cannot hold both — a landscape phone very much included. The rules live in a web-only stylesheet layered over the theme rather than in the theme itself, which belongs to the desktop, where the window is never 375 pixels wide. The zoom comes from measuring the stage, not the window: the menu bar, the status bar and any open sheet have taken their share first, and a landscape phone has plenty of width and almost no height. Part.tsx now divides a drag by the scale it measures off the part, so a part still lands where the finger left it. At 1:1 that divisor is one and the desktop is unchanged; the drag also rounds to whole pixels now, which it should always have done. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- apps/desktop/src/components/Part.tsx | 23 +++-- apps/web/src/App.tsx | 123 ++++++++++++++++++++++++--- apps/web/src/main.tsx | 2 + apps/web/src/mobile.css | 96 +++++++++++++++++++++ 4 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/mobile.css diff --git a/apps/desktop/src/components/Part.tsx b/apps/desktop/src/components/Part.tsx index ccea32e..ad9424a 100644 --- a/apps/desktop/src/components/Part.tsx +++ b/apps/desktop/src/components/Part.tsx @@ -39,20 +39,29 @@ export function Part({ const [left, top, width, height] = part.rect; const [offset, setOffset] = useState<{ dx: number; dy: number } | null>(null); const origin = useRef({ x: 0, y: 0 }); + /** + * How many screen pixels the card is currently drawing per card pixel. + * + * One, until something shrinks the card to fit — which the website does on + * a phone. A drag reports where the part *ended up in card space*, so the + * pointer's travel has to be divided by this or the part would lag behind + * the finger by exactly the amount the card was scaled. + */ + const scale = useRef(1); const [draft, setDraft] = useMirror(part.text); useEffect(() => { if (offset === null) return undefined; const move = (event: MouseEvent) => setOffset({ - dx: event.clientX - origin.current.x, - dy: event.clientY - origin.current.y, + dx: (event.clientX - origin.current.x) / scale.current, + dy: (event.clientY - origin.current.y) / scale.current, }); const up = (event: MouseEvent) => { - const dx = event.clientX - origin.current.x; - const dy = event.clientY - origin.current.y; + const dx = (event.clientX - origin.current.x) / scale.current; + const dy = (event.clientY - origin.current.y) / scale.current; setOffset(null); - if (dx !== 0 || dy !== 0) onMove(part, left + dx, top + dy); + if (dx !== 0 || dy !== 0) onMove(part, Math.round(left + dx), Math.round(top + dy)); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); @@ -96,6 +105,10 @@ export function Part({ event.preventDefault(); onSelect(part); origin.current = { x: event.clientX, y: event.clientY }; + // Measured at the moment the drag starts, from the part's own + // drawn size against the size it claims to be. + const drawn = event.currentTarget.getBoundingClientRect().width; + scale.current = width > 0 && drawn > 0 ? drawn / width : 1; setOffset({ dx: 0, dy: 0 }); }} > diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e7f569..395fee6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,7 +8,7 @@ * the example stacks are fetched from the site itself. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { Assistant } from '../../desktop/src/components/Assistant'; import { Card } from '../../desktop/src/components/Card'; @@ -71,6 +71,82 @@ function withFont(): Promise { return fontRegistered; } +/** + * Below this the interface stacks and the panels become sheets. Matches the + * breakpoint in `mobile.css`; they have to agree. + * + * 60rem, because the widest card a stack ships with is 640px and the + * inspector holds 290 beside it — so anything under about 960 cannot show + * the two side by side, landscape phones very much included. + */ +const NARROW = '(max-width: 60rem)'; + +/** + * Whether the screen is narrow enough for the stacked layout. + * + * Watched rather than read once: a phone that turns on its side is a + * different layout, and so is a desktop window dragged narrow. + */ +function useNarrowScreen(): boolean { + const [narrow, setNarrow] = useState( + () => typeof window !== 'undefined' && window.matchMedia(NARROW).matches, + ); + useEffect(() => { + const query = window.matchMedia(NARROW); + const update = () => setNarrow(query.matches); + query.addEventListener('change', update); + return () => query.removeEventListener('change', update); + }, []); + return narrow; +} + +/** + * How much the card has to shrink to fit the room it has. + * + * The stage is measured rather than the window, because the window is not + * what the card sits in: the menu bar, the status bar and any open sheet + * have all taken their share first. A landscape phone is the case that + * makes this matter — plenty of width, almost no height. + * + * Only ever shrinks. A card smaller than the screen is drawn at its own + * size, because artwork drawn one bit deep looks wrong blown up. The answer + * goes to CSS as `--card-zoom`, and `Part.tsx` measures the same scale back + * off the element when a drag starts. + */ +function useCardZoom( + stage: HTMLDivElement | null, + card: { width: number; height: number }, + active: boolean, +): number { + const [room, setRoom] = useState({ width: 0, height: 0 }); + + useEffect(() => { + // Taken as state rather than through a ref, because the stage does not + // exist on the first render — the window shows a notice until the + // runtime is up — and a ref would still be empty when the effect ran. + if (stage === null) return undefined; + const watch = new ResizeObserver(([entry]) => { + if (entry === undefined) return; + const { width, height } = entry.contentRect; + setRoom((was) => + was.width === width && was.height === height ? was : { width, height }, + ); + }); + watch.observe(stage); + return () => watch.disconnect(); + }, [stage]); + + if (!active || card.width <= 0 || card.height <= 0) return 1; + if (room.width === 0 || room.height === 0) return 1; + // The hard shadow the card casts needs a few pixels of its own. + const margin = 6; + return Math.min( + 1, + Math.max(0.2, (room.width - margin) / card.width), + Math.max(0.2, (room.height - margin) / card.height), + ); +} + /** Hands the user a file, which is what "save" means on a page. */ function download(name: string, text: string, type: string) { const url = URL.createObjectURL(new Blob([text], { type })); @@ -119,6 +195,14 @@ export function App() { // every command and re-encoding a card of artwork each time would be // absurd. Cleared when a different stack is opened. const [pictures, setPictures] = useState(new Map()); + const narrow = useNarrowScreen(); + const [stage, setStage] = useState(null); + const cardZoom = useCardZoom(stage, view.cardSize, narrow); + // On a phone the inspector is a sheet over half the screen, so it starts + // out of the way and is asked for. On a desktop it is a column, always + // there, and this is ignored. + const [inspectorOpen, setInspectorOpen] = useState(false); + const showInspector = narrow ? inspectorOpen : true; // The two file pickers, hidden until a menu item clicks them. const stackPicker = useRef(null); @@ -459,6 +543,19 @@ export function App() { }, ], }, + ...(narrow + ? [ + { + title: 'View', + entries: [ + { + label: inspectorOpen ? 'Hide Inspector' : 'Show Inspector', + run: () => setInspectorOpen((open) => !open), + }, + ] as MenuEntry[], + }, + ] + : []), { title: 'Examples', entries: EXAMPLES.map((name) => ({ @@ -487,11 +584,11 @@ export function App() { ]; return ( -
+
-
+
)} - - run(() => api.setProperty(kind, id, property, value)) - } - onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))} - /> + {showInspector && ( + + run(() => api.setProperty(kind, id, property, value)) + } + onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))} + /> + )}
Date: Sun, 2 Aug 2026 17:46:10 +0000 Subject: [PATCH 2/2] Fetch the exporters only when somebody exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An SVG renderer, a font database and a text shaper come with the PDF and Decker exporters, and together they were most of what HyperLab compiled to: 1.6 MB over the wire, paid for by everyone who only wanted to click through Cluedo. They are now a WebAssembly module of their own, fetched the first time an export is asked for. The runtime that opens a stack is 326 KB. The seam is the stack's own single-file JSON, which persistence already reads and writes. A new stack_text command hands the text over — distinct from save_stack_json, which marks the document clean because a download *is* the save, and exporting a PDF is not saving. The export module parses it and works from that, so the two modules share no state. Driving the page confirms the split holds: opening Cluedo fetches the runtime and neither the exporters nor the typeface; exporting fetches both, once, and the PDF still comes out with its artwork labels. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr --- .github/workflows/ci.yml | 4 +- .gitignore | 1 + CONTRIBUTING.md | 1 + Cargo.lock | 16 +++- apps/web/package.json | 2 +- apps/web/src/App.tsx | 68 ++++++++-------- apps/web/src/api.ts | 25 +++--- apps/web/src/backend/exporters.ts | 78 ++++++++++++++++++ apps/web/src/backend/index.ts | 6 -- apps/web/src/backend/worker.ts | 4 - crates/web-export/Cargo.toml | 32 ++++++++ crates/web-export/src/lib.rs | 127 ++++++++++++++++++++++++++++++ crates/web/Cargo.toml | 2 - crates/web/src/api.rs | 52 ++---------- docs/website.md | 24 +++++- 15 files changed, 335 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/backend/exporters.ts create mode 100644 crates/web-export/Cargo.toml create mode 100644 crates/web-export/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccbe7f6..0bf44de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,9 @@ jobs: cache-dependency-path: apps/web/package-lock.json - name: The runtime holds to its promises on the wasm target too - run: cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets + run: | + cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets + cargo clippy -p hyperlab-web-export --target wasm32-unknown-unknown --all-targets - name: Pack the example stacks as single files run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples diff --git a/.gitignore b/.gitignore index d941b4e..0df1fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ apps/desktop/src-tauri/target/ # The website's generated assets: the WebAssembly package wasm-pack writes, # and the example stacks packed as single files. Both are built in CI. apps/web/wasm-pkg/ +apps/web/wasm-export-pkg/ apps/web/public/examples/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6a33c0..e8f2b77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ desktop's components, or `apps/web` itself: ```sh cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets +cargo clippy -p hyperlab-web-export --target wasm32-unknown-unknown --all-targets cd apps/web npm run prepare:assets # the wasm package and the example stacks diff --git a/Cargo.lock b/Cargo.lock index e4fdcf5..965990d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,8 +580,6 @@ dependencies = [ "hyperlab-ai", "hyperlab-ai-providers", "hyperlab-assistant", - "hyperlab-decker", - "hyperlab-export", "hyperlab-graph", "hyperlab-hyperscript", "hyperlab-mcp", @@ -595,6 +593,20 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "hyperlab-web-export" +version = "0.2.0" +dependencies = [ + "console_error_panic_hook", + "hyperlab-decker", + "hyperlab-export", + "hyperlab-persistence", + "hyperlab-stack", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "icu_collections" version = "2.1.1" diff --git a/apps/web/package.json b/apps/web/package.json index c3d24b9..ac3e5b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "description": "The HyperLab website: a retro landing page and the full application in the browser.", "license": "MIT", "scripts": { - "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack", + "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack && wasm-pack build ../../crates/web-export --target web --out-dir ../../apps/web/wasm-export-pkg --out-name hyperlab_web_export --no-pack", "examples": "cargo run -p hyperlab-persistence --example pack_single_files -- public/examples", "prepare:assets": "npm run wasm && npm run examples", "dev": "vite", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 395fee6..7bbf559 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -46,29 +46,30 @@ const EXAMPLES = [ ] as const; /** - * The typeface handed to the exporters, fetched the first time one runs. + * The typeface the exporters draw a picture's words with, fetched the first + * time one runs. * * A page cannot read the machine's fonts, so words drawn inside a stack's - * artwork would come out of a PDF or a deck missing. This is fetched only - * when someone actually exports, so the 400 KB never lands on an ordinary - * visit. A failure is not fatal: the export goes ahead without the labels, - * which is what the desktop does on a machine with no fonts either. + * artwork would come out of a PDF or a deck missing. Fetched only when + * somebody actually exports, so the 400 KB never lands on an ordinary + * visit, and kept afterwards. */ -let fontRegistered: Promise | null = null; +let fontBytes: Promise | null = null; -function withFont(): Promise { - fontRegistered ??= fetch('fonts/LiberationSans-Regular.ttf') +function theFont(): Promise { + fontBytes ??= fetch('fonts/LiberationSans-Regular.ttf') .then((response) => { if (!response.ok) throw new Error(`${response.status}`); return response.arrayBuffer(); }) - .then((buffer) => api.addFont(new Uint8Array(buffer))) - .catch(() => { - // Tried once; a second export should try again rather than inherit + .then((buffer) => new Uint8Array(buffer)) + .catch((reason: unknown) => { + // Tried once; a later export should try again rather than inherit // this failure for the life of the page. - fontRegistered = null; + fontBytes = null; + throw reason; }); - return fontRegistered; + return fontBytes; } /** @@ -379,30 +380,33 @@ export function App() { ); } - /** The whole stack as a PDF, one page per card. */ + /** + * The whole stack as a PDF, one page per card. + * + * The exporters are a WebAssembly module of their own, fetched here on + * first use, so the notice goes up before the wait rather than after it. + */ function exportPdf() { - withFont() - .then(() => api.exportPdf()) - .then( - (bytes) => { - downloadBytes(`${view.stackName}.pdf`, bytes, 'application/pdf'); - setNotice(`Downloaded ${view.stackName}.pdf`); - }, - (reason: unknown) => setError(String(reason)), - ); + setNotice(`Exporting ${view.stackName}.pdf…`); + api.exportPdf(theFont).then( + (bytes) => { + downloadBytes(`${view.stackName}.pdf`, bytes, 'application/pdf'); + setNotice(`Downloaded ${view.stackName}.pdf`); + }, + (reason: unknown) => setError(String(reason)), + ); } /** The stack as a Decker deck. */ function exportDeck() { - withFont() - .then(() => api.exportDeck()) - .then( - ({ source, notes }) => { - download(`${view.stackName}.deck`, source, 'text/plain'); - setNotice(`Downloaded ${view.stackName}.deck${leftBehind(notes)}`); - }, - (reason: unknown) => setError(String(reason)), - ); + setNotice(`Exporting ${view.stackName}.deck…`); + api.exportDeck(theFont).then( + ({ source, notes }) => { + download(`${view.stackName}.deck`, source, 'text/plain'); + setNotice(`Downloaded ${view.stackName}.deck${leftBehind(notes)}`); + }, + (reason: unknown) => setError(String(reason)), + ); } /** The map as a PNG. Drawn in the window, because only it knows the shape. */ diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 458bcc3..3a3b9ef 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -191,24 +191,27 @@ export const exportWebPage = (): Promise<{ source: string; notes: string[] }> => call('export_web'); /** - * Offers a typeface for the words drawn inside pictures. + * The stack as single-file JSON, for the export module to work from. * - * A browser has no system fonts to find, so without one a picture's labels - * are missing from a PDF or a deck. The page fetches a font the first time - * either export is asked for; see {@link withFont} in `App.tsx`. + * Distinct from {@link saveStackText}, which marks the document clean + * because a download *is* the save. Exporting a PDF is not saving. */ -export const addFont = async (bytes: Uint8Array): Promise => { - await (await runtime()).call('add_font', {}, bytes); -}; +export const stackText = (): Promise => call('stack_text'); /** The whole stack as a PDF, one page per card, as the bytes themselves. */ -export const exportPdf = async (): Promise => - (await runtime()).call('export_pdf'); +export const exportPdf = async (font: () => Promise): Promise => { + const { toPdf } = await import('./backend/exporters'); + return toPdf(await stackText(), font); +}; /** The stack as a Decker deck, plus a line for everything Lil and a deck * have no equivalent for. */ -export const exportDeck = (): Promise<{ source: string; notes: string[] }> => - call('export_deck'); +export const exportDeck = async ( + font: () => Promise, +): Promise<{ source: string; notes: string[] }> => { + const { toDeck } = await import('./backend/exporters'); + return toDeck(await stackText(), font); +}; /** An empty snapshot, for the moment before the first one arrives. */ export const emptyView: StackView = { diff --git a/apps/web/src/backend/exporters.ts b/apps/web/src/backend/exporters.ts new file mode 100644 index 0000000..208aa4b --- /dev/null +++ b/apps/web/src/backend/exporters.ts @@ -0,0 +1,78 @@ +/** + * The PDF and Decker exporters, fetched the first time they are wanted. + * + * They are a WebAssembly module of their own because of what they weigh: an + * SVG renderer, a font database and a text shaper, which together are most + * of what HyperLab compiles to. Kept in the main module they were + * downloaded by everybody, including the people who only wanted to click + * through Cluedo. + * + * So the split is along the line where the cost is: opening a stack loads + * the runtime, and exporting one loads this. The stack crosses between them + * as the same single-file JSON it would have been downloaded as, so the two + * modules share no state. + */ + +/** The module, once it has been asked for. */ +let loaded: Promise | null = + null; + +/** The typeface, handed over once per page rather than once per export. */ +let fontGiven = false; + +function load() { + loaded ??= (async () => { + const [module, { default: wasmUrl }] = await Promise.all([ + import('../../wasm-export-pkg/hyperlab_web_export'), + import('../../wasm-export-pkg/hyperlab_web_export_bg.wasm?url'), + ]); + await module.default({ module_or_path: wasmUrl }); + module.init(); + return module; + })(); + return loaded; +} + +/** + * Gives the exporters a typeface for the words drawn inside pictures. + * + * A browser has none of its own to find, so without this a picture's labels + * are missing from whatever comes out. A failure is not fatal — the export + * goes ahead without the labels, which is what a desktop with no fonts + * would also produce — so this never rejects. + */ +async function withFont( + module: Awaited>, + font: () => Promise, +): Promise { + if (fontGiven) return; + try { + module.add_font(await font()); + fontGiven = true; + } catch { + // Tried once, and a later export is welcome to try again. + } +} + +/** The stack as a PDF, one page per card. */ +export async function toPdf( + stackJson: string, + font: () => Promise, +): Promise { + const module = await load(); + await withFont(module, font); + return module.to_pdf(stackJson); +} + +/** The stack as a Decker deck, plus whatever had no equivalent there. */ +export async function toDeck( + stackJson: string, + font: () => Promise, +): Promise<{ source: string; notes: string[] }> { + const module = await load(); + await withFont(module, font); + return JSON.parse(module.to_deck(stackJson)) as { + source: string; + notes: string[]; + }; +} diff --git a/apps/web/src/backend/index.ts b/apps/web/src/backend/index.ts index c6324a5..e065c63 100644 --- a/apps/web/src/backend/index.ts +++ b/apps/web/src/backend/index.ts @@ -231,14 +231,8 @@ async function startFallbackBackend(): Promise { async call(command: string, args?: unknown, bytes?: Uint8Array): Promise { const argsJson = JSON.stringify(args ?? {}); let value: string | undefined; - if (command === 'export_pdf') { - // Bytes, not JSON — the one command that answers with a file. - return module.export_pdf(argsJson) as T; - } if (command === 'import_image_bytes') { value = module.import_image_bytes(argsJson, bytes ?? new Uint8Array()); - } else if (command === 'add_font') { - value = module.add_font(argsJson, bytes ?? new Uint8Array()); } else if (command === 'ai_ask') { value = await module.ai_ask(argsJson); } else { diff --git a/apps/web/src/backend/worker.ts b/apps/web/src/backend/worker.ts index 13a405b..1ffb597 100644 --- a/apps/web/src/backend/worker.ts +++ b/apps/web/src/backend/worker.ts @@ -128,10 +128,6 @@ async function run(message: CallMessage): Promise { switch (message.command) { case 'import_image_bytes': return { value: wasm.import_image_bytes(message.args, given) }; - case 'add_font': - return { value: wasm.add_font(message.args, given) }; - case 'export_pdf': - return { bytes: wasm.export_pdf(message.args) }; case 'ai_ask': return { value: await wasm.ai_ask(message.args) }; default: { diff --git a/crates/web-export/Cargo.toml b/crates/web-export/Cargo.toml new file mode 100644 index 0000000..83df668 --- /dev/null +++ b/crates/web-export/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "hyperlab-web-export" +description = "Turning a stack into a PDF or a Decker deck, in a browser, as a module of its own." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +hyperlab-decker.workspace = true +hyperlab-export.workspace = true +hyperlab-persistence.workspace = true +hyperlab-stack.workspace = true +serde.workspace = true +serde_json.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" +wasm-bindgen = "0.2" + +[lints] +workspace = true + +[package.metadata.wasm-pack.profile.release] +# Same reasoning as `hyperlab-web`: wasm-opt downloads a binaryen release +# mid-build, and a failed download fails the build. +wasm-opt = false diff --git a/crates/web-export/src/lib.rs b/crates/web-export/src/lib.rs new file mode 100644 index 0000000..21cf241 --- /dev/null +++ b/crates/web-export/src/lib.rs @@ -0,0 +1,127 @@ +//! Turning a stack into a PDF or a Decker deck, in a browser. +//! +//! A module of its own, and the reason is weight. Drawing a picture into a +//! PDF page or a deck's bitmap needs an SVG renderer, a font database and a +//! text shaper; together they are most of what HyperLab compiles to in a +//! browser. Left in the main module they were paid for on every visit, +//! including by everyone who only wanted to click through Cluedo. +//! +//! So they live here, and the page fetches this module the first time +//! somebody actually exports something — the same bargain the typeface +//! already made. +//! +//! The seam is the stack's own single-file JSON, which +//! [`hyperlab_persistence`] already reads and writes. The main module hands +//! over the text; this one parses it and exports it. Nothing else crosses, +//! so the two modules share no state and cannot disagree about any. + +#![warn(missing_docs)] + +#[cfg(any(target_arch = "wasm32", test))] +use hyperlab_persistence::stack_from_single_file; +#[cfg(target_arch = "wasm32")] +use serde::Serialize; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +/// What a translation produced, and what it could not bring across. +/// +/// Only the WebAssembly boundary has any use for it; the host build of this +/// crate exists to be tested, not to be called. +#[cfg(target_arch = "wasm32")] +#[derive(Serialize)] +struct Exported { + source: String, + notes: Vec, +} + +/// Reads the stack the page sent. +#[cfg(any(target_arch = "wasm32", test))] +fn parse(stack_json: &str) -> Result { + stack_from_single_file(stack_json).map_err(|error| error.to_string()) +} + +// ------------------------------------------------------------------- wasm + +/// Wakes the module up: a panic here should say what it was rather than +/// reaching the page as "unreachable". +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn init() { + console_error_panic_hook::set_once(); +} + +/// Offers a typeface for the words drawn inside pictures. +/// +/// A browser has no system fonts, so without this a picture's labels are +/// missing from whatever comes out. Both exporters are told. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn add_font(bytes: Vec) { + hyperlab_export::add_font(bytes.clone()); + hyperlab_decker::add_font(bytes); +} + +/// The stack as a PDF, one page per card. +/// +/// Answers with the bytes themselves: a PDF is not text, and base64 through +/// a JSON channel would cost a third of the file for nothing. +/// +/// # Errors +/// +/// Returns the exporter's own message if the stack cannot be read or a +/// picture cannot be converted. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn to_pdf(stack_json: &str) -> Result, JsValue> { + let stack = parse(stack_json).map_err(|error| JsValue::from_str(&error))?; + hyperlab_export::to_pdf(&stack).map_err(|error| JsValue::from_str(&error.to_string())) +} + +/// The stack as a Decker deck, with a line for everything Lil and a deck +/// have no equivalent for. +/// +/// # Errors +/// +/// Returns a message if the stack cannot be read. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn to_deck(stack_json: &str) -> Result { + let stack = parse(stack_json).map_err(|error| JsValue::from_str(&error))?; + let translated = hyperlab_decker::deck(&stack); + serde_json::to_string(&Exported { + source: translated.source, + notes: translated.notes, + }) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The seam this module is reached through, checked on the host: the + /// text the main module sends must come back out as the same stack. + #[test] + fn a_stack_arrives_through_its_own_single_file_json() { + let original = hyperlab_stack::Stack::new("Notes"); + let text = hyperlab_persistence::single_file_string(&original).unwrap(); + + let parsed = parse(&text).expect("the text the other module sends"); + assert_eq!(parsed, original); + + // And the exporters can work with what came across. + assert!(hyperlab_export::to_pdf(&parsed).is_ok()); + assert!(!hyperlab_decker::deck(&parsed).source.is_empty()); + } + + #[test] + fn text_that_is_not_a_stack_is_refused_with_a_reason() { + let error = parse("{ not json").expect_err("that is not a stack"); + assert!( + error.contains("not valid HyperLab JSON"), + "unhelpful: {error}" + ); + } +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 3189544..ee515ca 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -19,8 +19,6 @@ hyperlab-ai.workspace = true # native clients stay out of the build. hyperlab-ai-providers = { path = "../ai-providers", version = "0.2.0", default-features = false } hyperlab-assistant.workspace = true -hyperlab-decker.workspace = true -hyperlab-export.workspace = true hyperlab-graph.workspace = true hyperlab-hyperscript.workspace = true hyperlab-mcp.workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 2e24e3c..cb625c9 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -911,53 +911,17 @@ pub fn export_web(_args: &str) -> ApiResult { }) } -/// Offers a typeface for the words drawn inside pictures. +/// The stack as single-file JSON, without touching whether it is dirty. /// -/// A browser has no system fonts to find, so without this a picture's -/// labels are missing from a PDF or a deck. The page fetches a font the -/// first time either export is asked for and hands the bytes over here; -/// both exporters keep their own registry, and both are told. +/// What the export module is handed. [`save_stack_json`] cannot serve here: +/// a download *is* the save, so it marks the document clean, and exporting +/// a PDF is not saving the stack. #[wasm_bindgen] -pub fn add_font(_args: &str, bytes: Vec) -> ApiResult { - hyperlab_export::add_font(bytes.clone()); - hyperlab_decker::add_font(bytes); - reply(&()) -} - -/// The whole stack as a PDF, one page per card. -/// -/// Answers with the bytes rather than a string: a PDF is not text, and -/// base64 through a JSON channel would cost a third of the file for -/// nothing. The worker posts the buffer across instead. -/// -/// # Errors -/// -/// Returns the exporter's own message if a picture cannot be converted. -#[wasm_bindgen] -pub fn export_pdf(_args: &str) -> Result, JsValue> { - DOC.with(|cell| { - let held = cell.borrow(); - let doc = held.as_ref().ok_or_else(|| fail("init() has not run"))?; - hyperlab_export::to_pdf(doc.runtime.stack()).map_err(fail) - }) -} - -/// The stack as a Decker deck, with a line for everything Lil and a deck -/// have no equivalent for. -#[wasm_bindgen] -pub fn export_deck(_args: &str) -> ApiResult { - #[derive(Serialize)] - struct Exported { - source: String, - notes: Vec, - } +pub fn stack_text(_args: &str) -> ApiResult { with_doc(|doc| { - let translated = hyperlab_decker::deck(doc.runtime.stack()); - serde_json::to_string(&Exported { - source: translated.source, - notes: translated.notes, - }) - .map_err(|error| error.to_string()) + let text = hyperlab_persistence::single_file_string(doc.runtime.stack()) + .map_err(|error| error.to_string())?; + serde_json::to_string(&text).map_err(|error| error.to_string()) }) } diff --git a/docs/website.md b/docs/website.md index 75b70b1..fddd9c9 100644 --- a/docs/website.md +++ b/docs/website.md @@ -92,10 +92,26 @@ hyperlab_decker::add_font(bytes); ``` The site fetches Liberation Sans (OFL, in `public/fonts/`) the first time -someone exports, so the 400 KB never lands on an ordinary visit, and hands -it to both. A registered font is loaded *in addition* to the machine's own -and only stands in for the generic families where the platform found -nothing — so a desktop export is byte-for-byte what it always was. +someone exports and hands it to both. A registered font is loaded *in +addition* to the machine's own, and only stands in for the generic families +where they resolve to nothing as they stand — which is every browser, and +also a stock Linux box with no Arial. A machine that can already draw +sans-serif keeps whatever it was drawing it with. + +### Why the exporters are a module of their own + +An SVG renderer, a font database and a text shaper come with them, and +together they are most of what HyperLab compiles to: with the exporters in +the main module the runtime was 1.6 MB over the wire, paid for by everyone +who only wanted to click through Cluedo. Split out, the runtime is 326 KB +and `crates/web-export` is fetched the first time somebody exports +something. + +The seam is the stack's own single-file JSON. `stack_text` hands the text +over — distinct from `save_stack_json`, which marks the document clean +because a download *is* the save — and the export module parses it and works +from that. Nothing else crosses, so the two modules share no state and +cannot disagree about any. ## Keys never leave the browser