From b7d27416cdf9bc065e74e8c70fefc6ab9f2376ee Mon Sep 17 00:00:00 2001 From: Souta Date: Tue, 15 Sep 2026 16:18:18 +0900 Subject: [PATCH] One look, and a way back out of a site Two complaints, and the first was that the dashboard and a directory on a host were a light card UI and a dark file explorer that happened to share a suffix. They now share a palette. `dashboard.css` had four colours of its own, written as hex values no scheme had a say in, so choosing a dark theme turned the daemon's pages dark and left the dashboard white. Those are gone: every colour in it is a `var()`, the theme supplies `--bad` and `--good` from base08 and base0B -- which is what every base16 scheme paints an error and a string in -- and the palette arrives inline from the daemon and over `/_control/theme` in the extension. Sixteen hex values copied into TypeScript would be a second place for a theme to be wrong, and only one of them would be the one anybody looked at. Two smaller seams closed with it. The background moves to `html`, where the listing's stylesheet already put it: on `body` the colour stops at the content box and a short page shows the browser's white below the fold. And the dashboard asks for the palette on connect rather than only on the settings view, which is where this first lived and where almost nobody goes. The second complaint: from inside `panza.ssh-browser` there was no way back to the list of sites. It is a different origin, so the back button is the only route and only if you arrived by it. Every directory page now carries "all sites" in its header. Pages that are somebody's file get nothing added to them, which is the line this must not cross and does not. Found while wiring it: the service worker names the fields it relays one at a time -- which is what keeps a page from being handed whatever the daemon adds -- so the palette reached `getTheme` and stopped there. The e2e caught it as black text against themed text, which is the only place that could have. Two checks: the two pages resolve to the same background and foreground, and a directory says where the list of sites is. 230 tests, 65 e2e checks. Signed-off-by: Souta --- crates/ssh-browser/assets/dashboard.css | 33 ++++++++------ crates/ssh-browser/src/origin/mod.rs | 57 ++++++++++++++++++++++--- crates/ssh-browser/src/theme/mod.rs | 8 ++++ e2e/run.mjs | 32 ++++++++++++++ extension/src/background.ts | 18 +++++++- extension/src/dashboard.ts | 49 +++++++++++++++++++++ 6 files changed, 177 insertions(+), 20 deletions(-) diff --git a/crates/ssh-browser/assets/dashboard.css b/crates/ssh-browser/assets/dashboard.css index 33c82d9..ebe00cd 100644 --- a/crates/ssh-browser/assets/dashboard.css +++ b/crates/ssh-browser/assets/dashboard.css @@ -5,17 +5,24 @@ * file rather than a `"); @@ -3252,6 +3279,9 @@ body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;m header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\ display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\ header b{font-size:12px;font-weight:600;letter-spacing:.04em}\ +header .home{border-right:1px solid var(--line);color:var(--dim);font-size:11px;\ +margin-right:6px;padding-right:8px;text-decoration:none;white-space:nowrap}\ +header .home:hover{color:var(--accent)}\ header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\ font-size:11px;overflow-wrap:anywhere}\ #tree{padding:4px 0 40px}\ @@ -3394,7 +3424,19 @@ fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec /// `levels` runs from the alias base down to where the reader is, each already sorted, so /// the page opens with the whole path expanded and the rest of every level beside it. They /// come out of the cache the path walk already filled, so the depth costs no round trips. -fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec)], theme: &str) -> String { +/// +/// `home` is the root of the suffix, and the header links to it. A reader who has walked into +/// `panza.ssh-browser` has no way back to the list of sites: it is a different origin, so the +/// back button is the only route and only if they arrived by it. This is the daemon's own page +/// so a link on it costs nobody anything — and it stays off the pages that are somebody's +/// file, which get nothing added to them, ever. +fn autoindex( + alias: &str, + rel: &str, + levels: &[(String, Vec)], + theme: &str, + home: &str, +) -> String { let shown = if rel.is_empty() { "/" } else { rel }; let mut s = String::from(""); s.push_str(""); @@ -3403,7 +3445,12 @@ fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) // The palette first, then the layout that reads it. s.push_str(&theme::css_for(theme)); s.push_str(LISTING_CSS); - s.push_str("</style></head><body><header><b>"); + s.push_str("</style></head><body><header><a class=\"home\" href=\""); + s.push_str(&escape(home)); + // A word rather than a glyph. This header already carries an alias and a path in small + // type; a house drawn in it would be one more thing to decode, and "all sites" says both + // where it goes and what is there. + s.push_str("\" title=\"every site this daemon serves\">all sites</a><b>"); s.push_str(&escape(alias)); s.push_str("</b><span>"); s.push_str(&escape(shown)); @@ -4227,7 +4274,7 @@ mod tests { /// the ancestors or the site scan. Both of those need a remote; these do not. fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String { let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))]; - autoindex(alias, rel, &levels, theme::DEFAULT) + autoindex(alias, rel, &levels, theme::DEFAULT, "http://ssh-browser/") } #[test] diff --git a/crates/ssh-browser/src/theme/mod.rs b/crates/ssh-browser/src/theme/mod.rs index 3305550..d4533ca 100644 --- a/crates/ssh-browser/src/theme/mod.rs +++ b/crates/ssh-browser/src/theme/mod.rs @@ -261,6 +261,12 @@ impl Scheme { format!("--k-code:{}", c(0xE)), format!("--k-media:{}", c(0xB)), format!("--k-plain:{}", c(0x3)), + // base08 is what every scheme paints an error in, base0B what it paints a string + // in. The dashboard had its own red and green, written as two hex values no + // palette had a say in — which is why choosing a dark theme turned the daemon's + // pages dark and left the dashboard white. + format!("--bad:{}", c(0x8)), + format!("--good:{}", c(0xB)), ] .join(";") } @@ -371,6 +377,8 @@ mod tests { "--k-code", "--k-media", "--k-plain", + "--bad", + "--good", ]; for theme in all() { let css = css_for(&theme.name); diff --git a/e2e/run.mjs b/e2e/run.mjs index ef83378..8325c04 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -769,6 +769,38 @@ async function main() { ]); await spare.close(); + // One look, not two. The dashboard and a directory on a host are both ssh-browser, and + // until the palette was shared they were a light card UI and a dark file explorer that + // happened to live under one suffix. Compared as resolved colours rather than as CSS + // text, because what matters is what the two pages became. + const aTree = await browser.newPage(); + // `assets/`, not the alias root: the root holds an `index.html`, so it is served as that + // page and there is no tree on it to compare. + await aTree.goto(`http://${ALIAS}.${SUFFIX}/assets/`, { waitUntil: "domcontentloaded" }); + const onTree = await aTree.evaluate(() => { + return { + // On `html`, which is where both stylesheets put it -- a colour on `body` stops at the + // content box and leaves the browser's white below a short page. + background: getComputedStyle(document.documentElement).backgroundColor, + colour: getComputedStyle(document.body).color, + }; + }); + // And the way back out, which an origin of its own otherwise has none of. + const home = await aTree.getAttribute("header .home", "href"); + await aTree.close(); + + const dashboardBody = await dashboard.evaluate(() => ({ + background: getComputedStyle(document.documentElement).backgroundColor, + colour: getComputedStyle(document.body).color, + })); + + check("a directory and the dashboard are painted the same", () => + assert.deepEqual(onTree, dashboardBody), + ); + check("and a directory says how to get back to the list of sites", () => + assert.equal(home, `http://${SUFFIX}/`), + ); + check("the fallback is the dashboard's own page", () => { assert.notEqual(onSpare, null, "no site card on the loopback listener"); assert.deepEqual(onSpare?.look, onDashboard?.look); diff --git a/extension/src/background.ts b/extension/src/background.ts index 53051d9..b62d99a 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -59,6 +59,12 @@ export interface Reply { suffix?: string; open?: OpenAlias[]; current?: string; + /// The palette the daemon renders a listing with, as the `:root` block carrying it. + /// + /// Relayed rather than reconstructed. Sixteen hex values copied into TypeScript would be a + /// second place for a theme to be wrong, and only one of them would be the one anybody had + /// looked at. + css?: string; themes?: { name: string; label: string }[]; hosts?: KnownHost[]; unusable?: { host: string; why: string }[]; @@ -435,8 +441,16 @@ async function getTheme(): Promise<Reply> { if (!res.ok) { return { ok: false, detail: `${res.status}: ${await res.text()}` }; } - const body = (await res.json()) as { current: string; themes: { name: string; label: string }[] }; - return { ok: true, detail: "", current: body.current, themes: body.themes }; + const body = (await res.json()) as { + current: string; + css: string; + themes: { name: string; label: string }[]; + }; + // `css` too. Naming the fields one at a time is what keeps a page from being handed + // whatever the daemon happens to add — and it is also why the palette reached this function + // and stopped here, leaving the dashboard black on white beside the pages it links to. + // Adding a field is one line; noticing the missing one took a screenshot. + return { ok: true, detail: "", current: body.current, css: body.css, themes: body.themes }; } async function setTheme(name: string): Promise<Reply> { diff --git a/extension/src/dashboard.ts b/extension/src/dashboard.ts index 052d792..560526b 100644 --- a/extension/src/dashboard.ts +++ b/extension/src/dashboard.ts @@ -54,6 +54,8 @@ interface Reply { unusable?: { host: string; why: string }[]; url?: string; current?: string; + /// The current palette, as the `:root` block that carries it. + css?: string; themes?: { name: string; label: string; variant: string }[]; /// Under https: completed and failed TLS handshakes. /// @@ -439,6 +441,14 @@ async function renderConfig(): Promise<void> { void (async () => { const chose = await send({ kind: "setTheme", name: select.value }); say(chose.detail, !chose.ok); + // This page repaints with it too. Choosing a dark theme and watching everything but the + // page you chose it on go dark is how the two looks drifted apart in the first place. + if (chose.ok) { + const now = await send({ kind: "theme" }); + if (now.ok && now.css !== undefined) { + await applyTheme(now.css); + } + } })(); }); picker.append(select); @@ -744,11 +754,48 @@ async function start(): Promise<void> { el("daemon").textContent = `${reply.detail} on 127.0.0.1:${port}`; say(""); + // Here rather than only on the settings view, which is where this lived and where almost + // nobody goes. A dashboard themed only after you had been to settings is a dashboard that + // does not match the pages it links to. + const themed = await send({ kind: "theme" }); + if (themed.ok && themed.css !== undefined) { + await applyTheme(themed.css); + } + if (await refresh()) { route(); } } +/// Paint this page with the palette the daemon renders listings with. +/// +/// The stylesheet both halves share carries no colours of its own, so without this the +/// dashboard is the browser's black on white while a directory on a host is whatever theme +/// was chosen — two products sharing a suffix, which is what souta was looking at. +/// +/// Remembered between runs, and applied before the daemon is asked. A dashboard that flashed +/// white and then went dark on every open would be worse than one that never changed. +async function applyTheme(css?: string): Promise<void> { + if (css === undefined) { + const seen = (await chrome.storage.local.get("themeCss")) as { themeCss?: string }; + css = seen.themeCss; + } else { + await chrome.storage.local.set({ themeCss: css }); + } + if (css === undefined) { + return; + } + // After the linked stylesheet, so the palette wins wherever both have something to say. + // One element reused, or picking a theme twice would leave two. + let held = document.getElementById("palette"); + if (held === null) { + held = document.createElement("style"); + held.id = "palette"; + document.head.append(held); + } + held.textContent = css; +} + window.addEventListener("hashchange", route); // The port is read back first because it says *which* daemon to look for; checking before @@ -758,5 +805,7 @@ void (async () => { if (typeof port === "number") { currentPort = port; } + // Before `start`, so the page is already the right colour when it first paints. + await applyTheme(); await start(); })();