fix: theme toggle now properly handles auto mode

Remove data-theme attribute when mode="auto" instead of setting it to
"auto". Pico's OS-dark selector is :root:not([data-theme]), so any
value (even "auto") prevents dark-mode Pico styles. Add Pico form
element background vars and color-scheme: light to match active
theme.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 17:18:58 +02:00
co-authored by Claude Haiku 4.5
parent 2c06806814
commit a7a1b05817
6 changed files with 47 additions and 10 deletions
+15 -1
View File
@@ -8,7 +8,9 @@
Theme selectors mirror Pico's own so equal specificity + later source order Theme selectors mirror Pico's own so equal specificity + later source order
wins: light = :root:not([data-theme=dark]) ; dark = the two dark selectors. wins: light = :root:not([data-theme=dark]) ; dark = the two dark selectors.
`data-theme="auto"` is not `light`, so it follows the OS preference. The "system" state renders with **no** data-theme attribute (base.html
removes it) — Pico's OS-dark block is `:root:not([data-theme])`, so a literal
data-theme="auto" would keep Pico light while this file went dark.
========================================================================== */ ========================================================================== */
/* --- Tokens: light (paper) ----------------------------------------------- */ /* --- Tokens: light (paper) ----------------------------------------------- */
@@ -73,9 +75,15 @@
--pico-form-element-placeholder-color: var(--im-text-faint); --pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent); --pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent); --pico-form-element-focus-color: var(--im-accent);
/* Pico swaps the *background* on :focus/:active. Remap it too, or a focused
field falls back to Pico's own palette instead of ours. */
--pico-form-element-active-background-color: var(--im-surface);
--pico-form-element-selected-background-color: var(--im-surface-3);
--pico-code-background-color: var(--im-surface-3); --pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text); --pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(24, 30, 36, .42); --pico-modal-overlay-background-color: rgba(24, 30, 36, .42);
/* Native widgets: <select> popup, scrollbars, autofill. */
color-scheme: light;
} }
/* --- Tokens: dark (ink) -------------------------------------------------- */ /* --- Tokens: dark (ink) -------------------------------------------------- */
@@ -140,9 +148,12 @@
--pico-form-element-placeholder-color: var(--im-text-faint); --pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent); --pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent); --pico-form-element-focus-color: var(--im-accent);
--pico-form-element-active-background-color: var(--im-surface-3);
--pico-form-element-selected-background-color: var(--im-surface-3);
--pico-code-background-color: var(--im-surface-3); --pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text); --pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(4, 8, 11, .62); --pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
color-scheme: dark;
} }
} }
@@ -206,9 +217,12 @@
--pico-form-element-placeholder-color: var(--im-text-faint); --pico-form-element-placeholder-color: var(--im-text-faint);
--pico-form-element-active-border-color: var(--im-accent); --pico-form-element-active-border-color: var(--im-accent);
--pico-form-element-focus-color: var(--im-accent); --pico-form-element-focus-color: var(--im-accent);
--pico-form-element-active-background-color: var(--im-surface-3);
--pico-form-element-selected-background-color: var(--im-surface-3);
--pico-code-background-color: var(--im-surface-3); --pico-code-background-color: var(--im-surface-3);
--pico-code-color: var(--im-text); --pico-code-color: var(--im-text);
--pico-modal-overlay-background-color: rgba(4, 8, 11, .62); --pico-modal-overlay-background-color: rgba(4, 8, 11, .62);
color-scheme: dark;
} }
/* --- Typography + global rhythm ----------------------------------------- */ /* --- Typography + global rhythm ----------------------------------------- */
+16 -3
View File
@@ -1,6 +1,10 @@
{% import "partials/icons.html" as ico %} {% import "partials/icons.html" as ico %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="auto"> {# No data-theme attribute in the "auto" state on purpose: Pico's OS-dark block
is keyed on `:root:not([data-theme])`, so any value at all — even "auto" —
pins Pico to its light theme while app.css switches to its dark tokens. That
mismatch is what produced white-on-white focused inputs. #}
<html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -18,14 +22,23 @@
dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>', dark: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/></svg>',
auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>' auto: '<svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="12" cy="12" r="8"/><path d="M12 4v16a8 8 0 0 0 0-16Z" fill="currentColor" stroke="none"/></svg>'
}, },
init() { // "auto" must *remove* the attribute, not set it to "auto" — see the
// comment on <html> above.
apply() {
if (this.current === 'auto') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', this.current); document.documentElement.setAttribute('data-theme', this.current);
}
},
init() {
this.apply();
}, },
cycle() { cycle() {
const order = ['light', 'dark', 'auto']; const order = ['light', 'dark', 'auto'];
this.current = order[(order.indexOf(this.current) + 1) % order.length]; this.current = order[(order.indexOf(this.current) + 1) % order.length];
localStorage.setItem('imptune_theme', this.current); localStorage.setItem('imptune_theme', this.current);
document.documentElement.setAttribute('data-theme', this.current); this.apply();
} }
}); });
@@ -70,7 +70,7 @@
{% for item in driver_data %} {% for item in driver_data %}
<option value="{{ item.driver.id }}" <option value="{{ item.driver.id }}"
{% if p.driver_id == item.driver.id %}selected{% endif %}> {% if p.driver_id == item.driver.id %}selected{% endif %}>
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %} {{ item.driver.label }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
+3 -2
View File
@@ -18,6 +18,7 @@
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' ' ~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
~ (printers | map(attribute='port_name') | join(' ')) ~ ' ' ~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' ' ~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
~ (printers | selectattr('driver_id') | map(attribute='driver.label') | join(' ')) ~ ' '
~ client_name %} ~ client_name %}
<section class="card card-flush" <section class="card card-flush"
data-search="{{ gsearch | lower }}" data-search="{{ gsearch | lower }}"
@@ -44,7 +45,7 @@
</thead> </thead>
<tbody> <tbody>
{% for p in printers %} {% for p in printers %}
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}" <tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename ~ ' ' ~ p.driver.label if p.driver_id else '')) | lower }}"
x-data x-data
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())"> x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
<td> <td>
@@ -54,7 +55,7 @@
<td> <td>
{% if p.driver_id %} {% if p.driver_id %}
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span> <span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
<span class="cell-sub">{{ p.driver.original_filename }}</span> <span class="cell-sub">{{ p.driver.label }}</span>
{% else %} {% else %}
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span> <span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
{% endif %} {% endif %}
+1 -1
View File
@@ -65,7 +65,7 @@
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option> <option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
{% for item in driver_data %} {% for item in driver_data %}
<option value="{{ item.driver.id }}"> <option value="{{ item.driver.id }}">
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %} {{ item.driver.label }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
+11 -2
View File
@@ -7,9 +7,11 @@ def test_theme_cycles_on_click(page, live_server: str) -> None:
"""Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto.""" """Clicking theme button cycles data-theme attribute: auto -> light -> dark -> auto."""
page.goto(f"{live_server}/", wait_until="domcontentloaded") page.goto(f"{live_server}/", wait_until="domcontentloaded")
# Initial state: auto (default from base.html) # Initial state: auto — the attribute must be ABSENT, not "auto". Pico's
# OS-dark block is keyed on `:root:not([data-theme])`, so any value pins
# Pico to its light theme while app.css follows the OS into dark.
initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')") initial_theme = page.evaluate("document.documentElement.getAttribute('data-theme')")
assert initial_theme == "auto" assert initial_theme is None
# Click once -> light # Click once -> light
page.click("button[aria-label='auto']") page.click("button[aria-label='auto']")
@@ -27,6 +29,13 @@ def test_theme_cycles_on_click(page, live_server: str) -> None:
) )
assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark" assert page.evaluate("document.documentElement.getAttribute('data-theme')") == "dark"
# Click again -> auto, which removes the attribute again
page.click("button[aria-label='dark']")
page.wait_for_function(
"!document.documentElement.hasAttribute('data-theme')",
timeout=2000,
)
def test_theme_persists_across_reload(page, live_server: str) -> None: def test_theme_persists_across_reload(page, live_server: str) -> None:
"""After clicking theme toggle, the chosen theme is restored on reload.""" """After clicking theme toggle, the chosen theme is restored on reload."""