summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 20:46:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 20:46:39 +0200
commit37225762109ad8d11e073f8661359860d678f84a (patch)
tree45ff4cb1fb2c244c5ed144bd41268c5e23dfd0fc /packages
parent6c71341422712aee3d4271ecf805146396b9d0e2 (diff)
downloadmeshbay-37225762109ad8d11e073f8661359860d678f84a.tar.gz
fix(client): the folder picker had no background, and its field no layout
Two defects from one screenshot, and a test for the class behind the first. **The modal was transparent.** `.ftp-panel` asked for `var(--bg-panel, var(--bg))` and this stylesheet defines neither — the palette is `--bg-base`, `--bg-surface`, `--bg-raised`. An unknown custom property makes the whole declaration invalid while the rule around it still applies, so the panel simply had no background and the page showed straight through it. Three more of mine were the same: `--bg-hover`, `--bg-input`, `--danger`. **The field had no layout.** It was built on `.settings-row`, which is `display:flex; justify-content:space-between` — so a label, a hint and a value inside one end up spread across a single line in source order, which is how it read as three unrelated fragments per app. It is its own block now, and the chosen folders are a table borrowing `.shared-dirs-tbl`: these are lists, and a wrapped run of chips gives nothing to scan and nowhere to put a per-row remove. The operator is looking at two lists of directories on one page and they should read alike. `test_css_variables.py` is the general form. CSS fails silently and generously here, and nothing checked. It found four more that predate this branch: the unread-count badge (`--danger`) had white text on nothing, a transfer link had no colour, a notification card had no rounding and no unread marker. Fixed, and `--warn` and `--accent-bg` are promoted from literal fallbacks to real palette entries at exactly their current light values. Two of its own regexes were wrong before they were right — a scoped definition written inline, and one preceded by a comment, were both reported as undefined. A third check comparing the two palettes fired on `--border-focus`, a focus ring the themes share deliberately; a heuristic that has to be explained away on its first run is worse than no test, so it is gone rather than exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css66
-rw-r--r--packages/meshbay-hub/tests/test_css_variables.py78
13 files changed, 201 insertions, 29 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
index 1af6573..6e6dfad 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -215,8 +215,19 @@ function FolderTreePicker({
}
/**
- * The button-plus-modal pairing every settings pane wants, so none of them
- * has to hold `open` state of its own.
+ * The chosen folders, as a table, plus the button that opens the picker.
+ *
+ * Not a `.settings-row`: that class is `display:flex; justify-content:
+ * space-between`, so a label, a hint and a value laid out inside one end up
+ * spread across a single line in whatever order they were written — which is
+ * how the first version of this read as three unrelated fragments per app.
+ *
+ * A table rather than a row of chips because these are lists now. Videos and
+ * Music can hold several folders, Photos routinely does, and a wrapped run of
+ * chips gives no column to scan and nowhere to put a per-row control. One
+ * folder per line, removable where it sits, in the same shape as the shared
+ * directories table above it — the operator is looking at two lists of
+ * directories on one page and they should read alike.
*/
function FolderPickerField({
label, hint, roots, dirs, mode = 'single', requireWritable = false,
@@ -226,21 +237,52 @@ function FolderPickerField({
const multi = mode === 'multi';
const chosen = multi ? (value || []) : (value ? [value] : []);
+ const removeAt = (path) => {
+ if (!multi) { onChange(''); return; }
+ onChange(chosen.filter((p) => p !== path));
+ };
+
return html`
- <div class="settings-row">
- <label class="settings-label">${label}</label>
- ${hint && html`<p class="settings-hint">${hint}</p>`}
- <div class="ftp-field">
- <div class="ftp-field-value">
- ${chosen.length
- ? chosen.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
- : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
- </div>
+ <div class="folder-field">
+ <div class="folder-field-head">
+ <h4 class="folder-field-label">${label}</h4>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ </div>
+
+ ${chosen.length > 0 && html`
+ <table class="shared-dirs-tbl folder-field-tbl">
+ <tbody>
+ ${chosen.map((path) => html`
+ <tr key=${path}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${path}
+ </span>
+ </td>
+ <td class="sdt-col-actions">
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${disabled}
+ title=${t('folder_tree.remove')}
+ onClick=${() => removeAt(path)}>\u{2715}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ <div class="folder-field-actions">
+ ${chosen.length === 0 && html`
+ <span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
<button class="btn btn-small btn-secondary" disabled=${disabled}
onClick=${() => setOpen(true)}>
- <${Icon} name="folder" /> ${t('folder_tree.choose')}
+ <${Icon} name="folder" />
+ ${' '}${chosen.length && multi ? t('folder_tree.add')
+ : t('folder_tree.choose')}
</button>
</div>
+
${open && html`
<${FolderTreePicker}
roots=${roots} dirs=${dirs} mode=${mode}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index edaa70a..2e7d3c5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -821,6 +821,8 @@ export default {
'folder_tree.title_single': 'Ordner auswählen',
'folder_tree.title_multi': 'Ordner auswählen',
'folder_tree.choose': 'Auswählen…',
+ 'folder_tree.remove': 'Entfernen',
+ 'folder_tree.add': 'Ordner hinzufügen…',
'folder_tree.confirm': 'Übernehmen',
'folder_tree.expand': 'Aufklappen',
'folder_tree.collapse': 'Zuklappen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index d1aca93..350a597 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -609,6 +609,8 @@ export default {
'folder_tree.title_single': 'Choose a folder',
'folder_tree.title_multi': 'Choose folders',
'folder_tree.choose': 'Choose…',
+ 'folder_tree.remove': 'Remove',
+ 'folder_tree.add': 'Add a folder…',
'folder_tree.confirm': 'Use these',
'folder_tree.expand': 'Expand',
'folder_tree.collapse': 'Collapse',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 880de2e..22a9461 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -817,6 +817,8 @@ export default {
'folder_tree.title_single': 'Elegir una carpeta',
'folder_tree.title_multi': 'Elegir carpetas',
'folder_tree.choose': 'Elegir…',
+ 'folder_tree.remove': 'Quitar',
+ 'folder_tree.add': 'Añadir una carpeta…',
'folder_tree.confirm': 'Usar',
'folder_tree.expand': 'Expandir',
'folder_tree.collapse': 'Contraer',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index eb977f3..3da68b2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -835,6 +835,8 @@ export default {
'folder_tree.title_single': 'Choisir un dossier',
'folder_tree.title_multi': 'Choisir des dossiers',
'folder_tree.choose': 'Choisir…',
+ 'folder_tree.remove': 'Retirer',
+ 'folder_tree.add': 'Ajouter un dossier…',
'folder_tree.confirm': 'Utiliser',
'folder_tree.expand': 'Déplier',
'folder_tree.collapse': 'Replier',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 2f58015..335ed68 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -831,6 +831,8 @@ export default {
'folder_tree.title_single': 'Scegli una cartella',
'folder_tree.title_multi': 'Scegli le cartelle',
'folder_tree.choose': 'Scegli…',
+ 'folder_tree.remove': 'Rimuovi',
+ 'folder_tree.add': 'Aggiungi una cartella…',
'folder_tree.confirm': 'Usa',
'folder_tree.expand': 'Espandi',
'folder_tree.collapse': 'Comprimi',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f22ce21..4dd2f30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -815,6 +815,8 @@ export default {
'folder_tree.title_single': 'フォルダーを選択',
'folder_tree.title_multi': 'フォルダーを選択',
'folder_tree.choose': '選択…',
+ 'folder_tree.remove': '外す',
+ 'folder_tree.add': 'フォルダーを追加…',
'folder_tree.confirm': '決定',
'folder_tree.expand': '展開',
'folder_tree.collapse': '折りたたむ',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 306cf44..cb36d38 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -833,6 +833,8 @@ export default {
'folder_tree.title_single': 'Kies een map',
'folder_tree.title_multi': 'Kies mappen',
'folder_tree.choose': 'Kiezen…',
+ 'folder_tree.remove': 'Verwijderen',
+ 'folder_tree.add': 'Map toevoegen…',
'folder_tree.confirm': 'Gebruiken',
'folder_tree.expand': 'Uitklappen',
'folder_tree.collapse': 'Inklappen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index f2b3faf..91d0d90 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -859,6 +859,8 @@ export default {
'folder_tree.title_single': 'Wybierz folder',
'folder_tree.title_multi': 'Wybierz foldery',
'folder_tree.choose': 'Wybierz…',
+ 'folder_tree.remove': 'Usuń',
+ 'folder_tree.add': 'Dodaj folder…',
'folder_tree.confirm': 'Użyj',
'folder_tree.expand': 'Rozwiń',
'folder_tree.collapse': 'Zwiń',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index e608fbd..2b26a0b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -818,6 +818,8 @@ export default {
'folder_tree.title_single': 'Escolher uma pasta',
'folder_tree.title_multi': 'Escolher pastas',
'folder_tree.choose': 'Escolher…',
+ 'folder_tree.remove': 'Remover',
+ 'folder_tree.add': 'Adicionar uma pasta…',
'folder_tree.confirm': 'Usar',
'folder_tree.expand': 'Expandir',
'folder_tree.collapse': 'Recolher',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 68d794e..f1d7f9c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -802,6 +802,8 @@ export default {
'folder_tree.title_single': '选择文件夹',
'folder_tree.title_multi': '选择文件夹',
'folder_tree.choose': '选择…',
+ 'folder_tree.remove': '移除',
+ 'folder_tree.add': '添加文件夹…',
'folder_tree.confirm': '使用',
'folder_tree.expand': '展开',
'folder_tree.collapse': '折叠',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 1354340..0441e14 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -24,6 +24,11 @@
--nav-brand: #23b1f0;
--sidebar-bg: #f1f5f9;
--sidebar-hover: #e2e8f0;
+ /* Used with a literal fallback in a handful of rules before they were
+ defined here — same values, so nothing moves; now themeable, and the
+ names in those rules are real. */
+ --warn: #d97706;
+ --accent-bg: rgba(59, 130, 246, 0.1);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}
@@ -36,6 +41,8 @@
--text-secondary: #94a3b8;
--text-dim: #64748b;
--border: #334155;
+ --warn: #f59e0b;
+ --accent-bg: rgba(59, 130, 246, 0.18);
--error-bg: #450a0a;
--nav-bg: #020617;
--sidebar-bg: #1e293b;
@@ -1910,7 +1917,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
white-space: nowrap;
}
a.transfer-name {
- color: var(--link);
+ /* `--link` is not defined here; the palette's link colour is `--accent`. */
+ color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
@@ -2138,7 +2146,11 @@ a.transfer-name {
position: absolute;
top: -6px;
right: -8px;
- background: var(--danger);
+ /* `--danger` is not one of this stylesheet's variables — the palette calls
+ it `--error`. The declaration was invalid, so the unread-count badge had
+ white text on no background at all. Pre-existing; found while fixing four
+ more of the same in the folder picker. */
+ background: var(--error);
color: #fff;
font-size: 0.65rem;
padding: 1px 5px;
@@ -2160,7 +2172,8 @@ a.transfer-name {
align-items: center;
gap: 8px;
padding: 8px 12px;
- border-radius: var(--radius);
+ /* `--radius` was never defined — 8px is what the other cards use. */
+ border-radius: 8px;
background: var(--bg-surface);
margin-bottom: 4px;
cursor: pointer;
@@ -2170,7 +2183,8 @@ a.transfer-name {
background: var(--bg-raised);
}
.notif-unread {
- border-left: 3px solid var(--primary);
+ /* `--primary` is not defined here either; it is `--accent`. */
+ border-left: 3px solid var(--accent);
font-weight: 600;
}
.notif-kind {
@@ -2578,7 +2592,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.sdt-add-input {
flex: 1 1 auto; min-width: 0; padding: 5px 8px;
border: 1px solid var(--border); border-radius: 4px;
- background: var(--bg-input, transparent); color: var(--text);
+ background: var(--bg-surface); color: var(--text);
font-family: inherit; font-size: 0.9em;
}
.sdt-col-toggle { width: 90px; text-align: center; }
@@ -2591,9 +2605,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
line-height: 1; vertical-align: middle;
}
.sdt-action-btn + .sdt-action-btn { margin-left: 6px; }
-.sdt-action-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
+.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); }
.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
-.sdt-action-danger:hover:not(:disabled) { color: var(--danger, #ef4444); border-color: var(--danger, #ef4444); }
+.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); }
.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
.sdt-row-ejected { opacity: 0.5; }
@@ -2603,7 +2617,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
line-height: 1;
}
-.root-eject-btn:hover { background: var(--bg-hover); }
+.root-eject-btn:hover { background: var(--bg-raised); }
.root-eject-btn { margin-left: 8px; }
/* An ejected root in the Files table: still listed, deliberately — its files
are frozen, not gone — but not somewhere you can walk into. */
@@ -4076,7 +4090,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
padding: 16px;
}
.ftp-panel {
- background: var(--bg-panel, var(--bg)); color: var(--text);
+ background: var(--bg-surface); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
width: min(520px, 100%); max-height: min(70vh, 640px);
display: flex; flex-direction: column; padding: 16px;
@@ -4093,8 +4107,11 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
padding: 4px 0; margin: 8px 0;
}
.ftp-row { display: flex; align-items: center; gap: 2px; }
-.ftp-row.chosen { background: var(--bg-hover); }
-.ftp-row.blocked { opacity: 0.4; }
+.ftp-row.chosen { background: var(--bg-raised); }
+/* 0.4 was low enough that a greyed-out root read as an artefact rather than
+ a disabled control — the names were barely legible in the screenshot that
+ prompted this. */
+.ftp-row.blocked { opacity: 0.55; }
.ftp-twisty {
flex: 0 0 auto; width: 20px; height: 20px; padding: 0;
background: none; border: none; color: var(--text-dim);
@@ -4111,11 +4128,14 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.ftp-label .icon { width: 15px; height: 15px; flex-shrink: 0; }
.ftp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ftp-badge {
- flex: 0 0 auto; font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
- border: 1px solid var(--border); color: var(--text-dim);
+ flex: 0 0 auto; margin-left: 8px;
+ font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
+ border: 1px solid var(--border); background: var(--bg-raised);
+ color: var(--text-dim); white-space: nowrap;
text-transform: uppercase; letter-spacing: 0.03em;
}
-.ftp-badge.warn { color: var(--danger, #ef4444); border-color: currentColor; }
+.ftp-badge.rw { color: var(--accent); border-color: currentColor; }
+.ftp-badge.warn { color: var(--error); border-color: currentColor; }
.ftp-check { margin-left: auto; flex: 0 0 auto; }
.ftp-selection {
display: flex; flex-wrap: wrap; gap: 4px;
@@ -4123,8 +4143,20 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
}
.ftp-chip {
font-size: 0.82em; padding: 2px 6px; border-radius: 3px;
- background: var(--bg-hover); border: 1px solid var(--border);
+ background: var(--bg-raised); border: 1px solid var(--border);
}
.ftp-actions { display: flex; justify-content: flex-end; gap: 8px; }
-.ftp-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.ftp-field-value { flex: 1 1 200px; display: flex; flex-wrap: wrap; gap: 4px; }
+/* An app's chosen folders. Deliberately not a .settings-row: that is a
+ space-between flex row, and a label, a hint and a list laid out inside one
+ end up spread across a single line in source order. */
+.folder-field { padding: 8px 0; }
+.folder-field-head { margin-bottom: 6px; }
+.folder-field-label {
+ margin: 0 0 2px; font-size: 0.9em; font-weight: 600; color: var(--text);
+}
+.folder-field-head .settings-hint { margin-bottom: 0; }
+/* Borrows .shared-dirs-tbl so the two lists of directories on this page read
+ as the same kind of thing. */
+.folder-field-tbl { margin-bottom: 8px; }
+.folder-field-tbl .sdt-col-dir { font-size: 0.9em; }
+.folder-field-actions { display: flex; align-items: center; gap: 10px; }
diff --git a/packages/meshbay-hub/tests/test_css_variables.py b/packages/meshbay-hub/tests/test_css_variables.py
new file mode 100644
index 0000000..ec576d6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_css_variables.py
@@ -0,0 +1,78 @@
+"""
+Every `var(--x)` names a variable this stylesheet defines.
+
+CSS fails silently and generously: an unknown custom property makes the whole
+declaration invalid, and the rule around it still applies. So a panel written
+`background: var(--bg-panel)` when the palette calls it `--bg-surface` does not
+error, does not warn, and does not look obviously wrong in a diff — it just has
+no background, and the page shows straight through the modal.
+
+That is not hypothetical. It shipped in the folder picker, and the same file
+already carried one from before: `.notif-badge` asked for `--danger` where the
+palette says `--error`, so the unread count was white text on nothing. Found by
+a person looking at a screenshot, which is the only thing that was going to
+find it.
+
+A fallback (`var(--x, #ef4444)`) is a lesser version of the same mistake: the
+declaration is valid and renders, but the name is still fiction, and the next
+reader is told a variable exists that does not. Those are reported separately.
+
+There was a third check here, comparing the dark palette against the light one
+for anything a theme must not inherit. It fired on `--border-focus`, which is
+a focus ring deliberately shared by both themes — correct code. A heuristic
+that has to be explained away on its first run is worse than no test, so it is
+gone rather than exempted.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STYLE = (Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
+ / "static" / "style.css")
+
+pytestmark = pytest.mark.skipif(not STYLE.exists(),
+ reason="the stylesheet is not in this checkout")
+
+# Where a custom property is defined. Two forms, and both had to be learned
+# the hard way while writing this: a scoped one written inline
+# (`.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; }`), which an
+# anchored pattern misses, and one preceded by an explanatory comment, which a
+# `[{;]`-prefixed pattern misses because the character before it is `/`. Either
+# mistake reports correct code as broken, which is the fastest way to have a
+# test like this ignored.
+DEFINE = re.compile(r"(?:^|[{;])\s*(--[A-Za-z0-9_-]+)\s*:", re.M)
+# `var(--name` and `var(--name, fallback`.
+USE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?")
+
+
+def _text() -> str:
+ return STYLE.read_text(encoding="utf-8")
+
+
+def test_every_variable_used_without_a_fallback_is_defined():
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ assert defined, "no custom properties found — did the palette move?"
+
+ missing = sorted({name for name, fallback in USE.findall(source)
+ if not fallback and name not in defined})
+ assert not missing, (
+ "used but never defined, so every declaration naming one of these is "
+ "invalid and silently does nothing:\n " + "\n ".join(missing))
+
+
+def test_a_fallback_does_not_excuse_an_unknown_name():
+ """
+ `var(--danger, #ef4444)` renders, so it is not the same bug — but it is the
+ same mistake, and it will read as intentional to the next person. Reported
+ so the name gets corrected rather than the fallback relied on.
+ """
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ guessed = sorted({name for name, fallback in USE.findall(source)
+ if fallback and name not in defined})
+ assert not guessed, (
+ "used with a fallback but not defined anywhere — rename to the real "
+ "variable:\n " + "\n ".join(guessed))