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)], 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("");
+ s.push_str("all sites");
s.push_str(&escape(alias));
s.push_str("");
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 {
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 {
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 (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 {
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 {
+ 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();
})();