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" {...@@ -85,6 +85,9 @@ extern "C" {
85 pub fn fetch_dictionary(name: &str);85 pub fn fetch_dictionary(name: &str);
86 #[wasm_bindgen(js_name = storeFiles)]86 #[wasm_bindgen(js_name = storeFiles)]
87 fn store_files(changes: js_sys::Array);87 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);
88}91}
8992
90/// Where the browser keeps its notebooks, which a first visit makes one in.93/// Where the browser keeps its notebooks, which a first visit makes one in.
...@@ -201,6 +204,8 @@ thread_local! {...@@ -201,6 +204,8 @@ thread_local! {
201 /// When changed files are next written out.204 /// When changed files are next written out.
202 static STORE_DUE: Cell<Option<Instant>> = const { Cell::new(None) };205 static STORE_DUE: Cell<Option<Instant>> = const { Cell::new(None) };
203 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };206 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()) };
204}209}
205210
206/// Whether the browser runs on a Mac, where Command takes the editing chords.211/// Whether the browser runs on a Mac, where Command takes the editing chords.
...@@ -916,9 +921,12 @@ pub async fn start(...@@ -916,9 +921,12 @@ pub async fn start(
916 notebook::fs::mount(&root);921 notebook::fs::mount(&root);
917 restore(folder.get(1).into());922 restore(folder.get(1).into());
918 }923 }
919 let state = open(fonts)924 let mut state = open(fonts)
920 .await925 .await
921 .map_err(|error| JsValue::from_str(&error.to_string()))?;926 .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;
922 // Text no run tags is in the browser's language, as typed in it.930 // Text no run tags is in the browser's language, as typed in it.
923 if let Some(spelling) = &state.spelling {931 if let Some(spelling) = &state.spelling {
924 spelling.untagged(canvas::language::lcid(&input_language()));932 spelling.untagged(canvas::language::lcid(&input_language()));
...@@ -933,6 +941,16 @@ pub async fn start(...@@ -933,6 +941,16 @@ pub async fn start(
933 Ok(())941 Ok(())
934}942}
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
936/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`.954/// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`.
937fn restore(files: js_sys::Array) {955fn restore(files: js_sys::Array) {
938 for entry in files.iter() {956 for entry in files.iter() {
...@@ -1356,6 +1374,7 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {...@@ -1356,6 +1374,7 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
1356 }1374 }
1357 if host(|host| std::mem::take(&mut host.redraw)) {1375 if host(|host| std::mem::take(&mut host.redraw)) {
1358 state.frame()?;1376 state.frame()?;
1377 remember_look(state);
1359 }1378 }
1360 let space = state.session.as_ref().map(|session| session.space);1379 let space = state.session.as_ref().map(|session| session.space);
1361 if TYPED.take() || LOOKED.replace(space) != space {1380 if TYPED.take() || LOOKED.replace(space) != space {
...@@ -1384,6 +1403,20 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {...@@ -1384,6 +1403,20 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
1384 Ok(())1403 Ok(())
1385}1404}
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
1387/// Fetches the fallback faces `text` calls for that are not yet asked for.1420/// Fetches the fallback faces `text` calls for that are not yet asked for.
1388fn fetch_fallbacks(text: &str) {1421fn fetch_fallbacks(text: &str) {
1389 for character in text.chars().map(u32::from) {1422 for character in text.chars().map(u32::from) {
crates/snowbound/web/glue.js+4
...@@ -329,6 +329,10 @@ export function setCursor(name) {...@@ -329,6 +329,10 @@ export function setCursor(name) {
329 if (canvas.style.cursor !== name) canvas.style.cursor = name;329 if (canvas.style.cursor !== name) canvas.style.cursor = name;
330}330}
331331
332export function keepLook(look) {
333 localStorage.setItem("snowbound-look", look);
334}
335
332export function setTitle(title) {336export function setTitle(title) {
333 document.title = title;337 document.title = title;
334}338}
crates/snowbound/web/index.html+150-24
...@@ -4,9 +4,33 @@...@@ -4,9 +4,33 @@
4<meta charset="utf-8">4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">5<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
6<title>Snowbound</title>6<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>
7<style>16<style>
8 html, body { margin: 0; height: 100%; overflow: hidden; background: #f3f3f3; }17 /* The colours are ui::Theme's; a section's are Theme::section's for its hue, the first
9 @media (prefers-color-scheme: dark) { html, body { background: #202022; } }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); }
10 #page { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; outline: none; }34 #page { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; outline: none; }
11 /* The text area takes keys and input methods at the caret, unseen. */35 /* The text area takes keys and input methods at the caret, unseen. */
12 #input {36 #input {
...@@ -33,10 +57,50 @@...@@ -33,10 +57,50 @@
33 dialog.ask p { margin: 0; white-space: pre-line; }57 dialog.ask p { margin: 0; white-space: pre-line; }
34 dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; }58 dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; }
35 dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; }59 dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; }
36 #status {60
37 position: fixed; inset: 0; display: grid; place-items: center;61 /* The loading shell: the app's first frame, Full Page View's toolbar over the page (the
38 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;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);
39 }88 }
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); }
40</style>104</style>
41</head>105</head>
42<body>106<body>
...@@ -45,10 +109,47 @@...@@ -45,10 +109,47 @@
45<div id="a11y" class="hidden"></div>109<div id="a11y" class="hidden"></div>
46<textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea>110<textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea>
47<input id="files" type="file" hidden>111<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>
49<script type="module">132<script type="module">
50 import init, * as snowbound from "./snowbound_web.js";133 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
52 // Open, metric-compatible faces for the fonts OneNote pages use, fetched beside the module.153 // Open, metric-compatible faces for the fonts OneNote pages use, fetched beside the module.
53 const FONTS = [154 const FONTS = [
54 "Carlito-Regular", "Carlito-Bold", "Carlito-Italic", "Carlito-BoldItalic",155 "Carlito-Regular", "Carlito-Bold", "Carlito-Italic", "Carlito-BoldItalic",
...@@ -57,18 +158,27 @@...@@ -57,18 +158,27 @@
57 "Tinos-Regular", "Tinos-Bold", "Tinos-Italic", "Tinos-BoldItalic",158 "Tinos-Regular", "Tinos-Bold", "Tinos-Italic", "Tinos-BoldItalic",
58 "Cousine-Regular", "Cousine-Bold", "Cousine-Italic", "Cousine-BoldItalic",159 "Cousine-Regular", "Cousine-Bold", "Cousine-Italic", "Cousine-BoldItalic",
59 ];160 ];
60 const bytes = (url) =>161 const MODULE = "snowbound_web_bg.wasm";
61 fetch(url).then((response) => {162 // Bytes by file, which release_web.py writes in; else the server's Content-Length.
62 if (!response.ok) throw new Error(`${url}: ${response.status}`);163 const SIZES = {};
63 return response.arrayBuffer();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 },
64 });178 });
65 // Servers that don't type .wasm as application/wasm can't stream it.179 return new Response(response.body.pipeThrough(counter), { headers: headers ?? response.headers });
66 const module = fetch("snowbound_web_bg.wasm").then((response) =>180 };
67 response.headers.get("Content-Type")?.startsWith("application/wasm")181
68 ? response
69 : response.arrayBuffer(),
70 );
71 const status = document.getElementById("status");
72 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.182 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.
73 const owned = await new Promise((resolve) => {183 const owned = await new Promise((resolve) => {
74 if (!navigator.locks) return resolve(true);184 if (!navigator.locks) return resolve(true);
...@@ -78,25 +188,41 @@...@@ -78,25 +188,41 @@
78 });188 });
79 });189 });
80 if (!owned) {190 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.");
82 throw new Error("Snowbound is open in another tab");192 throw new Error("Snowbound is open in another tab");
83 }193 }
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 }
84 try {198 try {
85 const fetched = (url) => bytes(url).then((data) => new Uint8Array(data));
86 const [, fonts, dictionaries] = await Promise.all([199 const [, fonts, dictionaries] = await Promise.all([
87 init({ module_or_path: module }),200 // Typed here, so the module compiles as it streams in from any server.
88 Promise.all(FONTS.map((name) => fetched(`fonts/${name}.ttf`))),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)))),
89 // The spelling dictionaries the site has, fetched as pages need them; without the204 // The spelling dictionaries the site has, fetched as pages need them; without the
90 // list, words go unmarked.205 // list, words go unmarked.
91 fetch("dictionaries/index.json").then((response) => response.json()).catch(() => []),206 fetch("dictionaries/index.json").then((response) => response.json()).catch(() => []),
92 ]);207 ]);
208 status.textContent = "Starting Snowbound";
93 await snowbound.start(snowbound, fonts, dictionaries);209 await snowbound.start(snowbound, fonts, dictionaries);
94 status.remove();
95 console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`);
96 } catch (error) {210 } 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}`);
98 throw error;215 throw error;
99 }216 }
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`);
100</script>226</script>
101</body>227</body>
102</html>228</html>
tools/release_web.py+27-1
...@@ -8,8 +8,10 @@ static folder and publishes it to the share's web/ folder....@@ -8,8 +8,10 @@ static folder and publishes it to the share's web/ folder.
8import argparse8import argparse
9import gzip9import gzip
10import hashlib10import hashlib
11import itertools
11import json12import json
12import os13import os
14import re
13from pathlib import Path15from pathlib import Path
14import shutil16import shutil
15import subprocess17import subprocess
...@@ -22,6 +24,8 @@ ROOT = Path(__file__).resolve().parents[1]...@@ -22,6 +24,8 @@ ROOT = Path(__file__).resolve().parents[1]
22TARGET = ROOT / 'target/wasm'24TARGET = ROOT / 'target/wasm'
23WEB = ROOT / 'crates/snowbound/web'25WEB = ROOT / 'crates/snowbound/web'
24FONTS = ROOT / 'crates/canvas/assets/fonts'26FONTS = 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']
25PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')29PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')
26# copyparty lists the folder itself, so the app is at its index.html.30# copyparty lists the folder itself, so the app is at its index.html.
27URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'31URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'
...@@ -91,18 +95,40 @@ def build(out):...@@ -91,18 +95,40 @@ def build(out):
91 '--out-dir', out, module])95 '--out-dir', out, module])
92 bound = out / 'snowbound_web_bg.wasm'96 bound = out / 'snowbound_web_bg.wasm'
93 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])97 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])
94 shutil.copy(WEB / 'index.html', out)
95 dictionaries(out / 'dictionaries')98 dictionaries(out / 'dictionaries')
96 (out / 'fonts').mkdir()99 (out / 'fonts').mkdir()
97 for font in sorted(FONTS.glob('*')):100 for font in sorted(FONTS.glob('*')):
98 if font.suffix in ('.ttf', '.txt'):101 if font.suffix in ('.ttf', '.txt'):
99 shutil.copy(font, out / 'fonts')102 shutil.copy(font, out / 'fonts')
100 fallbacks(out / 'fonts')103 fallbacks(out / 'fonts')
104 page(out)
101 for path in sorted(out.rglob('*')):105 for path in sorted(out.rglob('*')):
102 if path.is_file():106 if path.is_file():
103 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')107 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
106def dictionaries(folder):132def dictionaries(folder):
107 """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate,133 """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate,
108 each beside its readme, the texts of the licences they come under in licenses/, and their134 each beside its readme, the texts of the licences they come under in licenses/, and their