authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 23:08:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 01:50:33-07:00
loga88c37885f64c12babc1c31ac0c12ec41858a9fa
tree814f88b514fabf92b3f5c2eb947d7ce1c02f584a
parent3d4eca3eeaffcc2e4765d5596660637ab07eaa8d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the web build loads behind a shell of the app, with a progress bar

index.html paints Full Page View's toolbar, faded and folding as the app's does, over the section frame and page, in the last section's hue and page colour the app kept. A bar tracks the module and fonts as they stream (sizes from release_web.py, which also inlines the icons), then "Starting Snowbound" until the first frame, which matches the shell. The shell fades out and the tab row, page list and remembered sidebar ease in as Full Page View leaves. No WebGPU or WebGL 2, or a failed download, says so in the same spot. Assisted-by: claude-opus-5.5

4 files changed, 215 insertions(+), 26 deletions(-)

crates/snowbound/src/web.rs+34-1
......@@ -85,6 +85,9 @@ extern "C" {
8585 pub fn fetch_dictionary(name: &str);
8686 #[wasm_bindgen(js_name = storeFiles)]
8787 fn store_files(changes: js_sys::Array);
88 /// Keeps what `index.html`'s loading shell paints on the next visit.
89 #[wasm_bindgen(js_name = keepLook)]
90 fn keep_look(look: &str);
8891}
8992
9093/// Where the browser keeps its notebooks, which a first visit makes one in.
......@@ -201,6 +204,8 @@ thread_local! {
201204 /// When changed files are next written out.
202205 static STORE_DUE: Cell<Option<Instant>> = const { Cell::new(None) };
203206 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };
207 /// The look last handed to `keepLook`.
208 static LOOK: RefCell<String> = const { RefCell::new(String::new()) };
204209}
205210
206211/// Whether the browser runs on a Mac, where Command takes the editing chords.
......@@ -916,9 +921,12 @@ pub async fn start(
916921 notebook::fs::mount(&root);
917922 restore(folder.get(1).into());
918923 }
919 let state = open(fonts)
924 let mut state = open(fonts)
920925 .await
921926 .map_err(|error| JsValue::from_str(&error.to_string()))?;
927 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
928 // the rest in.
929 state.full_page = true;
922930 // Text no run tags is in the browser's language, as typed in it.
923931 if let Some(spelling) = &state.spelling {
924932 spelling.untagged(canvas::language::lcid(&input_language()));
......@@ -933,6 +941,16 @@ pub async fn start(
933941 Ok(())
934942}
935943
944/// The loading shell is gone: the tab row, page list and sidebar ease in as Full Page View
945/// leaves.
946#[wasm_bindgen]
947pub fn loaded() {
948 send(UserEvent::Then(Box::new(|state| {
949 state.full_page = false;
950 Ok(())
951 })));
952}
953
936954/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`.
937955fn restore(files: js_sys::Array) {
938956 for entry in files.iter() {
......@@ -1356,6 +1374,7 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
13561374 }
13571375 if host(|host| std::mem::take(&mut host.redraw)) {
13581376 state.frame()?;
1377 remember_look(state);
13591378 }
13601379 let space = state.session.as_ref().map(|session| session.space);
13611380 if TYPED.take() || LOOKED.replace(space) != space {
......@@ -1384,6 +1403,20 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
13841403 Ok(())
13851404}
13861405
1406/// Keeps the open section's hue and the page's colour, in the appearance they show in, for
1407/// the loading shell to frame its page in.
1408fn remember_look(state: &State) {
1409 let hue = draw::hue(crate::section_color(state.section_color()));
1410 let [red, green, blue] = draw::srgb_bytes(state.paper().color);
1411 let dark = matches!(host(|host| host.theme), Theme::Dark);
1412 let look =
1413 format!(r##"{{"hue":{hue:.0},"paper":"#{red:02x}{green:02x}{blue:02x}","dark":{dark}}}"##);
1414 if LOOK.with_borrow(|kept| *kept != look) {
1415 keep_look(&look);
1416 LOOK.set(look);
1417 }
1418}
1419
13871420/// Fetches the fallback faces `text` calls for that are not yet asked for.
13881421fn fetch_fallbacks(text: &str) {
13891422 for character in text.chars().map(u32::from) {
crates/snowbound/web/glue.js+4
......@@ -329,6 +329,10 @@ export function setCursor(name) {
329329 if (canvas.style.cursor !== name) canvas.style.cursor = name;
330330}
331331
332export function keepLook(look) {
333 localStorage.setItem("snowbound-look", look);
334}
335
332336export function setTitle(title) {
333337 document.title = title;
334338}
crates/snowbound/web/index.html+150-24
......@@ -4,9 +4,33 @@
44<meta charset="utf-8">
55<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
66<title>Snowbound</title>
7<script>
8 // The open section's hue and the page's colour as the app last showed them (`keepLook`).
9 try {
10 const look = JSON.parse(localStorage.getItem("snowbound-look"));
11 const root = document.documentElement.style;
12 root.setProperty("--hue", look.hue);
13 if (look.dark === matchMedia("(prefers-color-scheme: dark)").matches) root.setProperty("--paper", look.paper);
14 } catch {}
15</script>
716<style>
8 html, body { margin: 0; height: 100%; overflow: hidden; background: #f3f3f3; }
9 @media (prefers-color-scheme: dark) { html, body { background: #202022; } }
17 /* The colours are ui::Theme's; a section's are Theme::section's for its hue, the first
18 new section's by default. */
19 :root {
20 --hue: 220; --strip: #ebedf0; --base: #fcfcfd; --text: #1f2328; --dim: #7b828c;
21 --chip: #cfd3d9; --accent: #0079a6; --paper: #fff;
22 --frame: hsl(var(--hue) 55% 82%), hsl(calc(var(--hue) + 10) 55% 76%);
23 --edge: hsl(var(--hue) 30% 52%);
24 }
25 @media (prefers-color-scheme: dark) {
26 :root {
27 --strip: #272a2b; --base: #191b1c; --text: #dddee0; --dim: #6b7078; --chip: #383c3d;
28 --paper: #1f2022;
29 --frame: hsl(var(--hue) 30% 36%), hsl(calc(var(--hue) + 10) 30% 30%);
30 --edge: hsl(var(--hue) 30% 16%);
31 }
32 }
33 html, body { margin: 0; height: 100%; overflow: hidden; background: var(--strip); }
1034 #page { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; outline: none; }
1135 /* The text area takes keys and input methods at the caret, unseen. */
1236 #input {
......@@ -33,10 +57,50 @@
3357 dialog.ask p { margin: 0; white-space: pre-line; }
3458 dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; }
3559 dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; }
36 #status {
37 position: fixed; inset: 0; display: grid; place-items: center;
38 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;
60
61 /* The loading shell: the app's first frame, Full Page View's toolbar over the page (the
62 sizes are main.rs's and ui::shell's), every tool faded as one that doesn't apply. */
63 #shell {
64 position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--strip);
65 transition: opacity 120ms; user-select: none; cursor: default;
66 }
67 #shell.gone { opacity: 0; }
68 #tools { display: flex; gap: 6px; height: 34px; padding: 8px 6px 0; box-sizing: border-box; overflow: hidden; }
69 #tools span { display: flex; flex-shrink: 0; height: 22px; }
70 #tools .full, #tools .font { gap: 1px; }
71 #tools .folded, #tools .fold > .full { display: none; }
72 #tools .fold > .folded { display: flex; }
73 #tools .space { flex: 1 1 0; min-width: 0; }
74 #tools i { display: grid; place-items: center; width: 22px; height: 22px; color: var(--text); }
75 /* Centred however narrow the box, as the kit centres icons. */
76 #tools i > svg { width: 16px; height: 16px; margin: 0 -8px; opacity: 0.35; }
77 #tools i.arrow { width: 10px; color: var(--dim); }
78 #tools i.more { width: 14px; color: var(--dim); }
79 #tools i.d { width: 6px; justify-items: start; }
80 #tools i.d::before { content: ""; width: 1px; height: 22px; background: var(--chip); }
81 #tools i.combo {
82 box-sizing: border-box; justify-items: end; padding-right: 13px; border-radius: 4px;
83 border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent); background: var(--base);
84 }
85 #frame {
86 position: relative; flex: 1; background: linear-gradient(var(--frame));
87 box-shadow: inset 1px 1px var(--edge), inset -1px 0 var(--edge);
3988 }
89 /* The page's outline straddles its edge, a shade of the section's edge. */
90 #paper {
91 position: absolute; inset: 6px; display: grid; place-content: center; justify-items: center;
92 gap: 10px; background: var(--paper); font: 13px system-ui, sans-serif; color: var(--dim);
93 --outline: color-mix(in srgb, color-mix(in srgb, var(--edge) 45%, black) 30%, transparent);
94 box-shadow: inset 0 0 0 1px var(--outline), 0 0 0 1px var(--outline);
95 }
96 #status { max-width: 360px; margin: 0; text-align: center; white-space: pre-line; }
97 #bar { width: 200px; height: 4px; border-radius: 2px; background: var(--chip); overflow: hidden; }
98 #bar::before {
99 content: ""; display: block; height: 100%; border-radius: inherit; background: var(--accent);
100 transform-origin: left; transform: scaleX(var(--done, 0)); transition: transform 100ms;
101 }
102 #shell.failed #bar { display: none; }
103 #shell.failed #status { color: var(--text); }
40104</style>
41105</head>
42106<body>
......@@ -45,10 +109,47 @@
45109<div id="a11y" class="hidden"></div>
46110<textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea>
47111<input id="files" type="file" hidden>
48<div id="status">Loading Snowbound…</div>
112<!-- release_web.py inlines each <i>'s icon; data-fold is a group's fold priority (main.rs). -->
113<div id="shell">
114 <div id="tools" aria-hidden="true">
115 <span data-fold="6"><span class="full"><i icon="undo"></i><i icon="redo"></i></span><span class="folded"><span><i icon="undo"></i><i class="arrow" icon="chevron-down"></i></span></span></span>
116 <span><i class="d"></i><span><i icon="paste"></i><i class="arrow" icon="chevron-down"></i></span></span>
117 <span class="font"><i class="d"></i><span><i icon="styles"></i><i class="arrow" icon="chevron-down"></i></span><i class="combo" style="width: 120px" icon="chevron-down"></i><i class="combo" style="width: 44px" icon="chevron-down"></i><i icon="bold"></i><i icon="italic"></i><i icon="underline"></i>
118 <span data-fold="8"><span class="full"><span><i icon="strikethrough"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="highlighter"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="font-color"></i><i class="arrow" icon="chevron-down"></i></span><i icon="format-painter"></i></span><span class="folded"><span><i icon="highlighter"></i><i class="arrow" icon="chevron-down"></i></span></span></span></span>
119 <span data-fold="7"><span class="full"><i class="d"></i><span><i icon="bullets"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="numbering"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="align-left"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"><span><i icon="bullets"></i><i class="arrow" icon="chevron-down"></i></span></span></span>
120 <span data-fold="9"><span class="full"><i class="d"></i><i icon="checkbox"></i><i icon="star"></i><i icon="question"></i><i class="more" icon="chevron-down"></i></span><span class="folded"><span><i icon="checkbox"></i><i class="arrow" icon="chevron-down"></i></span></span></span>
121 <span data-fold="4"><span class="full"><i class="d"></i><span><i icon="table"></i><i class="arrow" icon="chevron-down"></i></span><i icon="picture"></i><i icon="link"></i><i icon="calendar"></i><i icon="equation"></i><span><i icon="symbol"></i><i class="arrow" icon="chevron-down"></i></span><i class="more" icon="chevron-down"></i></span><span class="folded"><span><i icon="plus"></i><i class="arrow" icon="chevron-down"></i></span></span></span>
122 <span data-fold="0"><span class="full"><i class="d"></i><i icon="attachment"></i></span><span class="folded"></span></span>
123 <span data-fold="3"><span class="full"><i class="d"></i><i icon="record-audio"></i><i icon="record-video"></i></span><span class="folded"></span></span>
124 <span data-fold="2"><span class="full"><i class="d"></i><i icon="select"></i><span><i icon="pen"></i><i class="arrow" icon="chevron-down"></i></span><i icon="eraser"></i><i icon="lasso"></i><span><i icon="shapes"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"></span></span>
125 <span data-fold="1"><span class="full"><i class="d"></i><span><i icon="page-color"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"></span></span>
126 <span class="space"></span>
127 <span><i icon="sync-done"></i></span>
128 <span><i class="more" icon="chevron-down"></i></span>
129 </div>
130 <div id="frame"><div id="paper"><p id="status" role="status">Loading Snowbound</p><div id="bar"></div></div></div>
131</div>
49132<script type="module">
50133 import init, * as snowbound from "./snowbound_web.js";
51134
135 const shell = document.getElementById("shell");
136 const status = document.getElementById("status");
137 const bar = document.getElementById("bar");
138 const tools = document.getElementById("tools");
139 // The toolbar's groups fold as the app's do, the lowest priority first, while the row
140 // overflows.
141 const groups = [...tools.querySelectorAll("[data-fold]")].sort((a, b) => a.dataset.fold - b.dataset.fold);
142 const fold = () => {
143 for (const group of groups) group.classList.remove("fold");
144 for (const group of groups) if (tools.scrollWidth > tools.clientWidth) group.classList.add("fold");
145 };
146 fold();
147 addEventListener("resize", fold);
148 const fail = (message) => {
149 shell.classList.add("failed");
150 status.textContent = message;
151 };
152
52153 // Open, metric-compatible faces for the fonts OneNote pages use, fetched beside the module.
53154 const FONTS = [
54155 "Carlito-Regular", "Carlito-Bold", "Carlito-Italic", "Carlito-BoldItalic",
......@@ -57,18 +158,27 @@
57158 "Tinos-Regular", "Tinos-Bold", "Tinos-Italic", "Tinos-BoldItalic",
58159 "Cousine-Regular", "Cousine-Bold", "Cousine-Italic", "Cousine-BoldItalic",
59160 ];
60 const bytes = (url) =>
61 fetch(url).then((response) => {
62 if (!response.ok) throw new Error(`${url}: ${response.status}`);
63 return response.arrayBuffer();
161 const MODULE = "snowbound_web_bg.wasm";
162 // Bytes by file, which release_web.py writes in; else the server's Content-Length.
163 const SIZES = {};
164 const sizes = new Map([MODULE, ...FONTS.map((name) => `fonts/${name}.ttf`)].map((url) => [url, SIZES[url]]));
165 let received = 0;
166 /** `url`'s response, its body counted into the bar as it arrives. */
167 const counted = async (url, headers) => {
168 const response = await fetch(url);
169 if (!response.ok) throw new Error(`${url}: ${response.status}`);
170 if (!sizes.get(url)) sizes.set(url, Number(response.headers.get("Content-Length")));
171 const counter = new TransformStream({
172 transform(chunk, controller) {
173 received += chunk.byteLength;
174 const total = [...sizes.values()].reduce((sum, size) => sum + (size || 0), 0);
175 bar.style.setProperty("--done", Math.min(received / total, 1));
176 controller.enqueue(chunk);
177 },
64178 });
65 // Servers that don't type .wasm as application/wasm can't stream it.
66 const module = fetch("snowbound_web_bg.wasm").then((response) =>
67 response.headers.get("Content-Type")?.startsWith("application/wasm")
68 ? response
69 : response.arrayBuffer(),
70 );
71 const status = document.getElementById("status");
179 return new Response(response.body.pipeThrough(counter), { headers: headers ?? response.headers });
180 };
181
72182 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.
73183 const owned = await new Promise((resolve) => {
74184 if (!navigator.locks) return resolve(true);
......@@ -78,25 +188,41 @@
78188 });
79189 });
80190 if (!owned) {
81 status.textContent = "Snowbound is open in another tab. Close it, then reload this one.";
191 fail("Snowbound is open in another tab. Close it, then reload this one.");
82192 throw new Error("Snowbound is open in another tab");
83193 }
194 if (!navigator.gpu && !document.createElement("canvas").getContext("webgl2")) {
195 fail("This browser can’t draw Snowbound: it has neither WebGPU nor WebGL 2.\nOpen it in a current Chrome, Edge, Safari or Firefox.");
196 throw new Error("No WebGPU or WebGL 2");
197 }
84198 try {
85 const fetched = (url) => bytes(url).then((data) => new Uint8Array(data));
86199 const [, fonts, dictionaries] = await Promise.all([
87 init({ module_or_path: module }),
88 Promise.all(FONTS.map((name) => fetched(`fonts/${name}.ttf`))),
200 // Typed here, so the module compiles as it streams in from any server.
201 counted(MODULE, { "Content-Type": "application/wasm" }).then((module) => init({ module_or_path: module })),
202 Promise.all(FONTS.map((name) =>
203 counted(`fonts/${name}.ttf`).then((response) => response.arrayBuffer()).then((data) => new Uint8Array(data)))),
89204 // The spelling dictionaries the site has, fetched as pages need them; without the
90205 // list, words go unmarked.
91206 fetch("dictionaries/index.json").then((response) => response.json()).catch(() => []),
92207 ]);
208 status.textContent = "Starting Snowbound";
93209 await snowbound.start(snowbound, fonts, dictionaries);
94 status.remove();
95 console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`);
96210 } catch (error) {
97 status.textContent = `Snowbound could not start: ${error.message ?? error}`;
211 // A download that failed rejects with a TypeError, or a status `counted` names.
212 fail(error instanceof TypeError || /: \d{3}$/.test(error.message)
213 ? "Snowbound couldn’t download. Check your connection, then reload."
214 : `Snowbound couldn’t start: ${error.message ?? error}`);
98215 throw error;
99216 }
217 // The first frame draws the canvas, under the shell by the second.
218 for (const _ of [0, 1]) await new Promise(requestAnimationFrame);
219 shell.classList.add("gone");
220 removeEventListener("resize", fold);
221 setTimeout(() => {
222 shell.remove();
223 snowbound.loaded();
224 }, 120);
225 console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`);
100226</script>
101227</body>
102228</html>
tools/release_web.py+27-1
......@@ -8,8 +8,10 @@ static folder and publishes it to the share's web/ folder.
88import argparse
99import gzip
1010import hashlib
11import itertools
1112import json
1213import os
14import re
1315from pathlib import Path
1416import shutil
1517import subprocess
......@@ -22,6 +24,8 @@ ROOT = Path(__file__).resolve().parents[1]
2224TARGET = ROOT / 'target/wasm'
2325WEB = ROOT / 'crates/snowbound/web'
2426FONTS = ROOT / 'crates/canvas/assets/fonts'
27# Where the loading shell's icons are looked up by name, in order.
28ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets']
2529PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')
2630# copyparty lists the folder itself, so the app is at its index.html.
2731URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'
......@@ -91,18 +95,40 @@ def build(out):
9195 '--out-dir', out, module])
9296 bound = out / 'snowbound_web_bg.wasm'
9397 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])
94 shutil.copy(WEB / 'index.html', out)
9598 dictionaries(out / 'dictionaries')
9699 (out / 'fonts').mkdir()
97100 for font in sorted(FONTS.glob('*')):
98101 if font.suffix in ('.ttf', '.txt'):
99102 shutil.copy(font, out / 'fonts')
100103 fallbacks(out / 'fonts')
104 page(out)
101105 for path in sorted(out.rglob('*')):
102106 if path.is_file():
103107 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')
104108
105109
110def page(out):
111 """Writes index.html with its loading shell's icons inlined, and the sizes of the files
112 it fetches before the app starts, for its progress bar."""
113 count = itertools.count()
114
115 def inline(match):
116 name = match[2]
117 svg = next(folder / f'{name}.svg' for folder in ICONS if (folder / f'{name}.svg').exists()).read_text()
118 # Each copy's gradients keep ids of their own in the one document.
119 prefix = f'i{next(count)}-'
120 svg = re.sub(r'id="([^"]+)"', rf'id="{prefix}\1"', svg)
121 svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg)
122 return f'<i{match[1]}>{svg.strip()}</i>'
123 html = re.sub(r'<i([^>]*) icon="([^"]+)"></i>', inline, (WEB / 'index.html').read_text())
124 sizes = {str(path.relative_to(out)): path.stat().st_size
125 for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]}
126 marker = 'const SIZES = {};'
127 if marker not in html:
128 sys.exit(f'index.html lacks `{marker}`')
129 (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};'))
130
131
106132def dictionaries(folder):
107133 """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate,
108134 each beside its readme, the texts of the licences they come under in licenses/, and their