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(); })();