From a88c37885f64c12babc1c31ac0c12ec41858a9fa Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 23:08:02 -0700 Subject: [PATCH] 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 --- crates/snowbound/src/web.rs | 35 ++++++- crates/snowbound/web/glue.js | 4 + crates/snowbound/web/index.html | 174 +++++++++++++++++++++++++++----- tools/release_web.py | 28 ++++- 4 files changed, 215 insertions(+), 26 deletions(-) diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 2581e0ce290e64592bbaaf6b085dae4e17322664..4693e2476dac0db7d6932e164f156700959dc37e 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -85,6 +85,9 @@ extern "C" { pub fn fetch_dictionary(name: &str); #[wasm_bindgen(js_name = storeFiles)] fn store_files(changes: js_sys::Array); + /// Keeps what `index.html`'s loading shell paints on the next visit. + #[wasm_bindgen(js_name = keepLook)] + fn keep_look(look: &str); } /// Where the browser keeps its notebooks, which a first visit makes one in. @@ -201,6 +204,8 @@ thread_local! { /// When changed files are next written out. static STORE_DUE: Cell> = const { Cell::new(None) }; static LANGUAGE: RefCell = const { RefCell::new(String::new()) }; + /// The look last handed to `keepLook`. + static LOOK: RefCell = const { RefCell::new(String::new()) }; } /// Whether the browser runs on a Mac, where Command takes the editing chords. @@ -916,9 +921,12 @@ pub async fn start( notebook::fs::mount(&root); restore(folder.get(1).into()); } - let state = open(fonts) + let mut state = open(fonts) .await .map_err(|error| JsValue::from_str(&error.to_string()))?; + // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases + // the rest in. + state.full_page = true; // Text no run tags is in the browser's language, as typed in it. if let Some(spelling) = &state.spelling { spelling.untagged(canvas::language::lcid(&input_language())); @@ -933,6 +941,16 @@ pub async fn start( Ok(()) } +/// The loading shell is gone: the tab row, page list and sidebar ease in as Full Page View +/// leaves. +#[wasm_bindgen] +pub fn loaded() { + send(UserEvent::Then(Box::new(|state| { + state.full_page = false; + Ok(()) + }))); +} + /// Puts back the files IndexedDB kept: `[path, bytes or null for a folder, modified]`. fn restore(files: js_sys::Array) { for entry in files.iter() { @@ -1356,6 +1374,7 @@ fn turn(state: &mut State) -> Result<(), Box> { } if host(|host| std::mem::take(&mut host.redraw)) { state.frame()?; + remember_look(state); } let space = state.session.as_ref().map(|session| session.space); if TYPED.take() || LOOKED.replace(space) != space { @@ -1384,6 +1403,20 @@ fn turn(state: &mut State) -> Result<(), Box> { Ok(()) } +/// Keeps the open section's hue and the page's colour, in the appearance they show in, for +/// the loading shell to frame its page in. +fn remember_look(state: &State) { + let hue = draw::hue(crate::section_color(state.section_color())); + let [red, green, blue] = draw::srgb_bytes(state.paper().color); + let dark = matches!(host(|host| host.theme), Theme::Dark); + let look = + format!(r##"{{"hue":{hue:.0},"paper":"#{red:02x}{green:02x}{blue:02x}","dark":{dark}}}"##); + if LOOK.with_borrow(|kept| *kept != look) { + keep_look(&look); + LOOK.set(look); + } +} + /// Fetches the fallback faces `text` calls for that are not yet asked for. fn fetch_fallbacks(text: &str) { for character in text.chars().map(u32::from) { diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 89aab5d2da80ccf66e7c689abb7403eb5377596e..f081872e9f0fceb44befc7f896d88f911fe0e0cf 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -329,6 +329,10 @@ export function setCursor(name) { if (canvas.style.cursor !== name) canvas.style.cursor = name; } +export function keepLook(look) { + localStorage.setItem("snowbound-look", look); +} + export function setTitle(title) { document.title = title; } diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index 3b1672ec9b0b57e2a108111c1bf2ca99788e6037..f2e9d1945099d19ca3a6d8c499b80bd4ecfe77ab 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -4,9 +4,33 @@ Snowbound + @@ -45,10 +109,47 @@ -
Loading Snowbound…
+ +
+ +

Loading Snowbound

+
diff --git a/tools/release_web.py b/tools/release_web.py index 73d08b6def2b734f89b52ca132722fb958a15cac..f086a989e3bd8276d0d117f82f05a22aafd4adee 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -8,8 +8,10 @@ static folder and publishes it to the share's web/ folder. import argparse import gzip import hashlib +import itertools import json import os +import re from pathlib import Path import shutil import subprocess @@ -22,6 +24,8 @@ ROOT = Path(__file__).resolve().parents[1] TARGET = ROOT / 'target/wasm' WEB = ROOT / 'crates/snowbound/web' FONTS = ROOT / 'crates/canvas/assets/fonts' +# Where the loading shell's icons are looked up by name, in order. +ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') # copyparty lists the folder itself, so the app is at its index.html. URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' @@ -91,18 +95,40 @@ def build(out): '--out-dir', out, module]) bound = out / 'snowbound_web_bg.wasm' run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) - shutil.copy(WEB / 'index.html', out) dictionaries(out / 'dictionaries') (out / 'fonts').mkdir() for font in sorted(FONTS.glob('*')): if font.suffix in ('.ttf', '.txt'): shutil.copy(font, out / 'fonts') fallbacks(out / 'fonts') + page(out) for path in sorted(out.rglob('*')): if path.is_file(): print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') +def page(out): + """Writes index.html with its loading shell's icons inlined, and the sizes of the files + it fetches before the app starts, for its progress bar.""" + count = itertools.count() + + def inline(match): + name = match[2] + svg = next(folder / f'{name}.svg' for folder in ICONS if (folder / f'{name}.svg').exists()).read_text() + # Each copy's gradients keep ids of their own in the one document. + prefix = f'i{next(count)}-' + svg = re.sub(r'id="([^"]+)"', rf'id="{prefix}\1"', svg) + svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg) + return f'{svg.strip()}' + html = re.sub(r']*) icon="([^"]+)">', inline, (WEB / 'index.html').read_text()) + sizes = {str(path.relative_to(out)): path.stat().st_size + for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]} + marker = 'const SIZES = {};' + if marker not in html: + sys.exit(f'index.html lacks `{marker}`') + (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};')) + + def dictionaries(folder): """The Hunspell dictionaries in DICTIONARIES as UTF-8, gzipped for the glue to inflate, each beside its readme, the texts of the licences they come under in licenses/, and their -- 2.54.0