feat(ui): per-surface card presentation modes (C/M/D/R)

Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.

Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.

CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.

Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.

i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
This commit is contained in:
2026-05-10 23:49:14 +03:00
parent e65dcb41f4
commit 75ca487be1
15 changed files with 808 additions and 18 deletions
@@ -37,6 +37,7 @@ router = APIRouter()
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
_NOTIFICATION_PREFS_KEY = "notification_preferences"
_CARD_MODES_KEY = "card_modes"
class DaylightTimezonePreference(BaseModel):
@@ -163,6 +164,90 @@ async def put_notification_preferences(
return body
# ---------------------------------------------------------------------------
# Card presentation modes (per-surface comfortable/compact/dense)
# ---------------------------------------------------------------------------
_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"}
@router.get(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def get_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, Any]:
"""Read the saved card-mode preferences. Returns an empty object when
nothing has been saved yet — the frontend falls back to the default
mode ("compact") for every surface in that case."""
value = db.get_setting(_CARD_MODES_KEY)
return value if value is not None else {}
@router.put(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def put_card_modes(
_: AuthRequired,
body: dict[str, Any] = Body(...),
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Save card-mode preferences. The body must be a JSON object shaped
like ``{"version": 1, "surfaces": {"<surface>": "<mode>", …}}``.
The surface registry is intentionally open (any string accepted) so
new card surfaces can adopt the toggle without a server migration.
Invalid mode values are rejected to prevent a bad client from
poisoning the stored value."""
if not isinstance(body, dict):
raise HTTPException(status_code=422, detail="Body must be a JSON object")
if not isinstance(body.get("version"), int):
raise HTTPException(
status_code=422,
detail="Body must include a numeric 'version' field",
)
surfaces = body.get("surfaces", {})
if not isinstance(surfaces, dict):
raise HTTPException(
status_code=422,
detail="'surfaces' must be an object mapping surface keys to modes",
)
for key, mode in surfaces.items():
if not isinstance(key, str) or not key:
raise HTTPException(
status_code=422,
detail=f"Surface keys must be non-empty strings (got {key!r})",
)
if mode not in _VALID_CARD_MODES:
raise HTTPException(
status_code=422,
detail=(
f"Surface {key!r} has invalid mode {mode!r}; "
f"expected one of {sorted(_VALID_CARD_MODES)}"
),
)
db.set_setting(_CARD_MODES_KEY, body)
return {"ok": True}
@router.delete(
"/api/v1/preferences/card-modes",
tags=["Preferences"],
)
async def delete_card_modes(
_: AuthRequired,
db: Database = Depends(get_database),
) -> dict[str, bool]:
"""Delete saved card-mode preferences — every surface reverts to the
frontend default on next load."""
db.set_setting(_CARD_MODES_KEY, {})
return {"ok": True}
# ---------------------------------------------------------------------------
# Daylight timezone (global)
# ---------------------------------------------------------------------------
+1
View File
@@ -10,6 +10,7 @@
@import './advanced-calibration.css';
@import './dashboard.css';
@import './dashboard-customize.css';
@import './card-modes.css';
@import './streams.css';
@import './patterns.css';
@import './automations.css';
+22
View File
@@ -43,6 +43,28 @@
--space-lg: 20px;
--space-xl: 40px;
/* ── Card grid sizing ──────────────────────────────────────────────
Tokens for the auto-fill card grids (devices, displays, dashboard
targets, integrations, autostart). Defaults reproduce the values
that were inline before tokenization, so this layer is a no-op
until the card-mode toggle wires `[data-card-mode=…]` overrides.
· `*-min` — minmax() column width for the main module
cards (devices, displays, dashboard targets/scenes).
· `*-min-narrow` — column width for slimmer dashboard-module
rows (integrations, autostart).
· `*-gap` / `*-gap-narrow` — corresponding row/column gap. */
--card-grid-min: 380px;
--card-grid-gap: 14px;
--card-grid-min-narrow: 320px;
--card-grid-gap-narrow: 12px;
/* Capture-template / source-card grids (sources, streams, templates,
color strips) have their own column proportions so they stay
distinct from device/target cards. */
--templates-grid-min: 350px;
--templates-grid-gap: 20px;
/* Border radius */
--radius: 8px;
--radius-sm: 4px;
@@ -0,0 +1,223 @@
/* ──────────────────────────────────────────────────────────────────────
* Card presentation modes — `[data-card-mode="comfortable|compact|dense"]`
*
* Sister to the dashboard `[data-density]` system (which governs section
* header/gap only). This file targets the **card grids and the cards
* themselves**: column min-width, gap, internal padding, and which
* mod-* blocks render visibly.
*
* Apply the attribute to the grid container OR any ancestor (the page
* tab, the section). All tokens cascade.
*
* <section data-card-mode="dense">
* <div class="devices-grid">…</div>
* </section>
*
* <div class="dashboard-section" data-density="dense" data-card-mode="dense">
* …
* </div>
*
* Defaults (= `compact`) live in base.css :root; this file only overrides
* for `comfortable` and `dense`. Default mode is implicit; the attribute
* may be omitted on grids that haven't migrated yet.
* ────────────────────────────────────────────────────────────────────── */
/* ── Comfortable: roomier columns, expanded card padding ─────────── */
[data-card-mode="comfortable"] {
--card-grid-min: 440px;
--card-grid-gap: 18px;
--card-grid-min-narrow: 360px;
--card-grid-gap-narrow: 16px;
--templates-grid-min: 400px;
--templates-grid-gap: 22px;
}
[data-card-mode="comfortable"] .card,
[data-card-mode="comfortable"] .template-card {
padding: 22px 24px 20px;
}
[data-card-mode="comfortable"] .dashboard-target:has(.mod-head) {
padding: 20px 22px 18px 26px;
gap: 16px;
}
[data-card-mode="comfortable"] .dashboard-autostart:has(.mod-head),
[data-card-mode="comfortable"] .dashboard-integration:has(.mod-head) {
padding: 18px 20px 16px;
}
/* ── Dense: tight columns, slim padding, hide auxiliary mod-* blocks ── */
[data-card-mode="dense"] {
--card-grid-min: 260px;
--card-grid-gap: 8px;
--card-grid-min-narrow: 220px;
--card-grid-gap-narrow: 6px;
--templates-grid-min: 240px;
--templates-grid-gap: 10px;
}
[data-card-mode="dense"] .card,
[data-card-mode="dense"] .template-card {
padding: 12px 14px 10px;
}
[data-card-mode="dense"] .dashboard-target:has(.mod-head) {
padding: 10px 14px 10px 18px;
gap: 8px;
}
[data-card-mode="dense"] .dashboard-autostart:has(.mod-head),
[data-card-mode="dense"] .dashboard-integration:has(.mod-head) {
padding: 10px 12px 8px;
gap: 6px;
}
/* Auxiliary content drops out in dense — keeps identity (icon, name,
badge, dot) and primary control surfaces, sheds preview + secondary
text. The actual data-bearing metric row is preserved. */
[data-card-mode="dense"] .mod-leds {
display: none;
}
[data-card-mode="dense"] .mod-head {
gap: 6px;
margin-bottom: 6px;
}
[data-card-mode="dense"] .mod-foot {
padding-top: 6px;
gap: 4px;
}
/* Secondary text-button labels collapse to icon-only in dense; icon-only
buttons and the kebab menu are unaffected. Primary action keeps its
label so the "what does this card do" affordance survives. */
[data-card-mode="dense"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
display: none;
}
[data-card-mode="dense"] .mod-metrics {
gap: 4px 8px;
}
[data-card-mode="dense"] .mod-metric .k {
font-size: 0.62rem;
}
[data-card-mode="dense"] .mod-metric .v {
font-size: 0.92rem;
}
/* Dashed corner bracket and channel stripe stay — they are the card's
identity even at small sizes. No display:none on ::before / ::after. */
/* ── Row: single column, full-width stacked list ─────────────────────
* Differs from `dense` in layout, not just padding: the grid collapses
* to one column so every card spans the available width. Cards keep
* their column-flex internals (mod-head → metrics → foot) — a true
* horizontal row layout would require rewriting the mod-card vocabulary
* and is a separate future mode.
* ────────────────────────────────────────────────────────────────── */
[data-card-mode="row"] {
--card-grid-min: 100%;
--card-grid-gap: 6px;
--card-grid-min-narrow: 100%;
--card-grid-gap-narrow: 6px;
--templates-grid-min: 100%;
--templates-grid-gap: 6px;
}
[data-card-mode="row"] .card,
[data-card-mode="row"] .template-card {
padding: 10px 14px 10px;
}
[data-card-mode="row"] .dashboard-target:has(.mod-head) {
padding: 10px 14px 10px 18px;
gap: 8px;
}
[data-card-mode="row"] .dashboard-autostart:has(.mod-head),
[data-card-mode="row"] .dashboard-integration:has(.mod-head) {
padding: 10px 12px 8px;
gap: 6px;
}
/* Same auxiliary trims as `dense` — the row layout is information-dense
by nature, so secondary visuals drop out for the same reasons. */
[data-card-mode="row"] .mod-leds {
display: none;
}
[data-card-mode="row"] .mod-head {
gap: 6px;
margin-bottom: 6px;
}
[data-card-mode="row"] .mod-foot {
padding-top: 6px;
gap: 4px;
}
[data-card-mode="row"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label {
display: none;
}
[data-card-mode="row"] .mod-metrics {
gap: 4px 8px;
}
[data-card-mode="row"] .mod-metric .k {
font-size: 0.62rem;
}
[data-card-mode="row"] .mod-metric .v {
font-size: 0.92rem;
}
/* ──────────────────────────────────────────────────────────────────────
* Segmented `C / M / D` toggle — sibling of dash-cust-density but
* standalone so any section header / page toolbar can host one without
* pulling in the dashboard-customize stylesheet.
* ────────────────────────────────────────────────────────────────────── */
.card-mode-toggle {
display: inline-flex;
gap: 2px;
padding: 2px;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
background: var(--lux-bg-1, var(--card-bg));
}
.card-mode-toggle__btn {
appearance: none;
border: 0;
background: transparent;
color: var(--lux-ink-dim, var(--text-secondary));
font: 600 0.7rem/1 var(--font-mono, monospace);
letter-spacing: 0.04em;
padding: 4px 8px;
min-width: 22px;
cursor: pointer;
border-radius: calc(var(--lux-r-sm, var(--radius-sm)) - 1px);
transition: background 0.15s ease, color 0.15s ease;
}
.card-mode-toggle__btn:hover {
color: var(--lux-ink, var(--text-color));
background: var(--hover-bg, rgba(255, 255, 255, 0.04));
}
.card-mode-toggle__btn.is-active {
color: var(--primary-contrast, #fff);
background: var(--primary-color);
}
.card-mode-toggle__btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
+2 -2
View File
@@ -89,8 +89,8 @@ section {
.displays-grid,
.devices-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
gap: var(--card-grid-gap, 14px);
}
.devices-grid > .loading,
+4 -4
View File
@@ -71,8 +71,8 @@
.dashboard-subsection .dashboard-section-content {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
gap: var(--card-grid-gap, 14px);
}
.dashboard-subsection .dashboard-section-content .dashboard-target {
@@ -958,8 +958,8 @@ button.mod-icon.is-empty:hover svg { transform: none; opacity: 1; }
.dashboard-integrations-grid,
.dashboard-autostart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min-narrow, 320px), 100%), 1fr));
gap: var(--card-grid-gap-narrow, 12px);
}
/* Legacy row-style overrides kept for any card that still lacks .mod-head */
+2 -2
View File
@@ -4,8 +4,8 @@
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(var(--templates-grid-min, 350px), 1fr));
gap: var(--templates-grid-gap, 20px);
}
.template-card {
+5
View File
@@ -57,6 +57,9 @@ import {
import {
hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer,
} from './features/dashboard-layout.ts';
import {
hydrateCardModesFromCache, syncCardModesFromServer,
} from './features/card-modes.ts';
import {
openDashboardCustomize, closeDashboardCustomize,
} from './features/dashboard-customize.ts';
@@ -728,6 +731,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// already reflects the user's saved customizations (no flash of
// default-then-custom). Server sync runs after auth.
hydrateDashboardLayoutFromCache();
hydrateCardModesFromCache();
// Initialize locale (dispatches languageChanged which may trigger API calls)
await initLocale();
@@ -828,6 +832,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// across browsers). Fire-and-forget — the cached layout is already
// active; this overwrites it if the server has a newer copy.
syncDashboardLayoutFromServer();
syncCardModesFromServer();
// Trigger the active tab's loader — initTabs() ran before authRequired
// was known, so its conditional loader call may have been skipped.
@@ -24,6 +24,7 @@
import { t } from './i18n.ts';
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts';
import { mountCardModeToggle } from '../features/card-modes.ts';
export interface BulkAction {
key: string;
@@ -144,6 +145,7 @@ export class CardSection {
_pendingReconcile: CardItem[] | null;
_animated: boolean;
_showHidden: boolean;
_cardModeUnsubscribe: (() => void) | null;
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
this.sectionKey = sectionKey;
@@ -167,6 +169,7 @@ export class CardSection {
this._pendingReconcile = null;
this._animated = false;
this._showHidden = false;
this._cardModeUnsubscribe = null;
_sectionRegistry.set(sectionKey, this);
}
@@ -214,6 +217,7 @@ export class CardSection {
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
${hiddenToggle}
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
<span class="cs-mode-slot" data-cs-mode-slot="${this.sectionKey}"></span>
<div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
@@ -236,11 +240,30 @@ export class CardSection {
if (this.collapsible) {
header.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('.cs-filter-wrap') || (e.target as HTMLElement).closest('.cs-header-extra')) return;
const tgt = e.target as HTMLElement;
if (tgt.closest('.cs-filter-wrap') || tgt.closest('.cs-header-extra') || tgt.closest('.cs-mode-slot')) return;
this._toggleCollapse(header, content);
});
}
// Card-mode segmented toggle — mounts into the placeholder slot
// in the header. Re-mounted on every bind() because the DOM has
// been recreated; old subscriptions are torn down first to keep
// the listener Set bounded.
if (this._cardModeUnsubscribe) {
this._cardModeUnsubscribe();
this._cardModeUnsubscribe = null;
}
const wrapper = document.querySelector(`[data-card-section="${this.sectionKey}"]`) as HTMLElement | null;
const modeSlot = document.querySelector(`[data-cs-mode-slot="${this.sectionKey}"]`) as HTMLElement | null;
if (wrapper && modeSlot) {
this._cardModeUnsubscribe = mountCardModeToggle({
container: modeSlot,
surface: this.sectionKey,
host: wrapper,
});
}
if (filterInput) {
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
const updateResetVisibility = () => {
@@ -0,0 +1,261 @@
/**
* Card presentation modes — per-surface size toggle (comfortable / compact / dense).
*
* Sibling to `dashboard-layout.ts` but slimmer: a single open registry of
* surface keys ('devices', 'displays', 'dashboard-targets', …) each mapped
* to one of three modes. CSS does the heavy lifting via the
* `[data-card-mode="…"]` attribute (see card-modes.css).
*
* Boot sequence (matches dashboard-layout):
* 1. hydrateCardModesFromCache() — synchronous, called before first paint
* 2. syncCardModesFromServer() — async, runs after auth completes
*
* Persistence:
* - localStorage `card_modes_v1` cache for instant first paint
* - server `GET/PUT /preferences/card-modes` for cross-browser truth
* - PUT is debounced 300 ms; subscribers fire synchronously on save
*
* Surface keys are free-form strings — anything calling `setCardMode` is
* implicitly registering that key. Defaults are returned for unknown keys.
*/
import { fetchWithAuth } from '../core/api.ts';
import { t } from '../core/i18n.ts';
const LS_KEY = 'card_modes_v1';
const SCHEMA_VERSION = 1;
export type CardMode = 'comfortable' | 'compact' | 'dense' | 'row';
export const CARD_MODES: readonly CardMode[] = ['comfortable', 'compact', 'dense', 'row'] as const;
const DEFAULT_MODE: CardMode = 'compact';
export interface CardModePrefsV1 {
version: 1;
surfaces: Record<string, CardMode>;
}
const DEFAULT_PREFS: CardModePrefsV1 = {
version: SCHEMA_VERSION,
surfaces: {},
};
let _current: CardModePrefsV1 = _clone(DEFAULT_PREFS);
let _serverSyncedOnce = false;
let _saveTimer: ReturnType<typeof setTimeout> | null = null;
const _listeners = new Set<() => void>();
function _clone(prefs: CardModePrefsV1): CardModePrefsV1 {
return { version: prefs.version, surfaces: { ...prefs.surfaces } };
}
function _isCardMode(v: unknown): v is CardMode {
return v === 'comfortable' || v === 'compact' || v === 'dense' || v === 'row';
}
/** Normalise a parsed value back into a valid prefs object, dropping
* garbage values. Tolerates older/forward versions by treating the
* surfaces map as the only authoritative payload. */
function _normalise(parsed: unknown): CardModePrefsV1 {
const out: CardModePrefsV1 = _clone(DEFAULT_PREFS);
if (!parsed || typeof parsed !== 'object') return out;
const obj = parsed as Record<string, unknown>;
const surfaces = obj.surfaces;
if (surfaces && typeof surfaces === 'object') {
for (const [k, v] of Object.entries(surfaces as Record<string, unknown>)) {
if (typeof k === 'string' && _isCardMode(v)) {
out.surfaces[k] = v;
}
}
}
return out;
}
function _notify(): void {
for (const fn of _listeners) {
try { fn(); } catch (e) { console.error('card-modes listener', e); }
}
}
/** Read the current prefs. Defensive copy. */
export function getCardModePrefs(): CardModePrefsV1 {
return _clone(_current);
}
/** Effective mode for a surface — returns the configured value or the
* default when the surface is unset. */
export function getCardMode(surface: string): CardMode {
return _current.surfaces[surface] ?? DEFAULT_MODE;
}
/** Persist a surface's mode. Updates cache, fires subscribers,
* schedules debounced server PUT. */
export function setCardMode(surface: string, mode: CardMode): void {
if (!_isCardMode(mode)) return;
if (_current.surfaces[surface] === mode) return;
_current = {
version: _current.version,
surfaces: { ..._current.surfaces, [surface]: mode },
};
_persistLocal();
_notify();
_scheduleServerPush();
}
/** Subscribe to mode changes (any surface). Returns an unsubscribe fn. */
export function subscribeCardModes(fn: () => void): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
function _persistLocal(): void {
try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
}
function _scheduleServerPush(): void {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
_saveTimer = null;
_pushToServer(_current).catch(e => console.warn('card-modes PUT failed', e));
}, 300);
}
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
try {
await fetchWithAuth('/preferences/card-modes', {
method: 'PUT',
body: JSON.stringify(prefs),
});
} catch (e) {
console.warn('card-modes server PUT failed', e);
}
}
/** Hydrate from localStorage cache. Synchronous — safe to call before
* auth so the first paint already uses the user's saved modes. */
export function hydrateCardModesFromCache(): CardModePrefsV1 {
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) {
_current = _normalise(JSON.parse(raw));
}
} catch (e) {
console.warn('card-modes cache parse failed', e);
}
return _clone(_current);
}
/** Pull prefs from server (post-auth). Replaces local cache when server
* has a saved value; otherwise pushes local cache up so other browsers
* inherit it. Safe to call repeatedly — only runs the round-trip once. */
export async function syncCardModesFromServer(): Promise<void> {
if (_serverSyncedOnce) return;
try {
const resp = await fetchWithAuth('/preferences/card-modes');
if (!resp || !resp.ok) return;
const data = await resp.json();
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
_current = _normalise(data);
_persistLocal();
_notify();
} else {
// Server has nothing — push what we have so the next browser
// picks up the same view.
if (Object.keys(_current.surfaces).length > 0) {
await _pushToServer(_current);
}
}
_serverSyncedOnce = true;
} catch (e) {
console.warn('card-modes server sync failed', e);
}
}
// ─────────────────────────────────────────────────────────────────────────
// DOM helpers
// ─────────────────────────────────────────────────────────────────────────
/** Apply `data-card-mode` to a host element based on the current pref
* for `surface`. Idempotent; safe to call on the same element after a
* mode change to re-sync. */
export function applyCardModeAttr(host: HTMLElement, surface: string): void {
host.setAttribute('data-card-mode', getCardMode(surface));
}
/** Bind a host element to a surface's mode. Re-applies the attribute
* whenever the pref for that surface changes. Returns an unsubscribe
* function for use in a cleanup hook. */
export function bindCardModeAttr(host: HTMLElement, surface: string): () => void {
applyCardModeAttr(host, surface);
return subscribeCardModes(() => applyCardModeAttr(host, surface));
}
// ─────────────────────────────────────────────────────────────────────────
// Toggle UI
// ─────────────────────────────────────────────────────────────────────────
export interface MountCardModeToggleOpts {
/** Element to append the toggle to. */
container: HTMLElement;
/** Surface key whose mode this toggle controls. */
surface: string;
/** Optional host element to receive the `data-card-mode` attribute.
* Defaults to `container.parentElement ?? container`. Pass the grid
* container (or any common ancestor of the cards) so CSS overrides
* cascade correctly. */
host?: HTMLElement;
/** Position hint for screen readers + analytics. */
label?: string;
}
const _MODE_BUTTONS: ReadonlyArray<{ v: CardMode; lbl: string; key: string }> = [
{ v: 'comfortable', lbl: 'C', key: 'card_mode.comfortable' },
{ v: 'compact', lbl: 'M', key: 'card_mode.compact' },
{ v: 'dense', lbl: 'D', key: 'card_mode.dense' },
{ v: 'row', lbl: 'R', key: 'card_mode.row' },
];
/** Mount a segmented `C / M / D` toggle that drives the given surface.
* Returns a teardown function that removes the toggle and unsubscribes. */
export function mountCardModeToggle(opts: MountCardModeToggleOpts): () => void {
const host = opts.host ?? opts.container.parentElement ?? opts.container;
const surface = opts.surface;
const wrap = document.createElement('div');
wrap.className = 'card-mode-toggle';
wrap.setAttribute('role', 'radiogroup');
wrap.setAttribute('aria-label', opts.label || t('card_mode.tooltip') || 'Card size');
const buttons: HTMLButtonElement[] = _MODE_BUTTONS.map(b => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-mode-toggle__btn';
btn.dataset.cardMode = b.v;
btn.setAttribute('role', 'radio');
btn.textContent = b.lbl;
btn.title = t(b.key) || b.v;
btn.setAttribute('aria-label', t(b.key) || b.v);
btn.addEventListener('click', () => setCardMode(surface, b.v));
wrap.appendChild(btn);
return btn;
});
const refresh = () => {
const current = getCardMode(surface);
for (const btn of buttons) {
const isActive = btn.dataset.cardMode === current;
btn.classList.toggle('is-active', isActive);
btn.setAttribute('aria-checked', isActive ? 'true' : 'false');
}
applyCardModeAttr(host, surface);
};
refresh();
opts.container.appendChild(wrap);
const unsubscribe = subscribeCardModes(refresh);
return () => {
unsubscribe();
wrap.remove();
};
}
@@ -19,6 +19,7 @@ import { cardColorStyle } from '../core/card-colors.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
import { mountCardModeToggle } from './card-modes.ts';
function _applyGlobalLayoutAttrs(): void {
const c = document.getElementById('dashboard-content');
@@ -27,6 +28,33 @@ function _applyGlobalLayoutAttrs(): void {
c.dataset.layoutWidth = g.width;
c.dataset.layoutAnim = g.animations;
}
/** Card-mode toggle teardown registry. Each render replaces the dashboard
* inner HTML, so any previously-mounted toggle becomes detached. We tear
* down old subscribers before mounting fresh so the module-level listener
* Set in card-modes.ts stays bounded. Keyed by the surface name (matches
* `data-dashboard-mode-slot`). */
const _dashboardModeTeardowns = new Map<string, () => void>();
function _mountDashboardCardModeToggles(): void {
const container = document.getElementById('dashboard-content');
if (!container) return;
for (const [, teardown] of _dashboardModeTeardowns) {
try { teardown(); } catch (e) { console.warn('card-mode teardown', e); }
}
_dashboardModeTeardowns.clear();
const slots = container.querySelectorAll<HTMLElement>('[data-dashboard-mode-slot]');
for (const slot of slots) {
const surface = slot.dataset.dashboardModeSlot;
if (!surface) continue;
// Host = nearest [data-section] ancestor (either a .dashboard-section
// or a .dashboard-subsection — both carry the attribute now).
const host = slot.closest('[data-section]') as HTMLElement | null;
if (!host) continue;
const teardown = mountCardModeToggle({ container: slot, surface, host });
_dashboardModeTeardowns.set(surface, teardown);
}
}
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
@@ -552,7 +580,7 @@ export function toggleDashboardSection(sectionKey: string): void {
}
}
function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = ''): string {
function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = '', cardModeSurface: string = ''): string {
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
@@ -562,12 +590,19 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin
const countHtml = (count !== '' && count != null)
? `<span class="dashboard-section-count">${count}</span>`
: '';
// Optional card-mode toggle slot — mounted post-render in
// _mountDashboardCardModeToggles(). Sections that don't display a
// card grid (e.g. 'perf') pass an empty string and get no slot.
const modeSlot = cardModeSurface
? `<span class="dashboard-mode-slot" data-dashboard-mode-slot="${cardModeSurface}"></span>`
: '';
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron"${chevronStyle}>&#9654;</span>
${label}
${countHtml}
</span>
${modeSlot}
${extraHtml}
</div>`;
}
@@ -824,7 +859,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`, '', 'dashboard-integrations')}
${_sectionContent('integrations', intGrid)}
</div>`;
}
@@ -838,7 +873,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length, '', 'dashboard-automations')}
${_sectionContent('automations', automationGrid)}
</div>`;
}
@@ -848,7 +883,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const sceneSec = renderScenePresetsSection(scenePresets);
if (sceneSec && typeof sceneSec === 'object') {
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra, 'dashboard-scenes')}
${_sectionContent('scenes', sceneSec.content)}
</div>`;
}
@@ -859,7 +894,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length, '', 'dashboard-sync-clocks')}
${_sectionContent('sync-clocks', clockGrid)}
</div>`;
}
@@ -872,8 +907,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
targetsInner += `<div class="dashboard-subsection" data-section="running">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn, 'dashboard-running')}
${_sectionContent('running', runningItems)}
</div>`;
}
@@ -881,8 +916,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
targetsInner += `<div class="dashboard-subsection" data-section="stopped">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length, '', 'dashboard-stopped')}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}
@@ -944,6 +979,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
if (el) el.dataset.density = s.density;
}
_mountDashboardCardModeToggles();
_lastRunningIds = runningIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_cacheUptimeElements();
@@ -969,6 +969,11 @@
"dashboard.customize.density.comfortable": "Comfortable",
"dashboard.customize.density.compact": "Compact",
"dashboard.customize.density.dense": "Dense",
"card_mode.tooltip": "Card size",
"card_mode.comfortable": "Comfortable",
"card_mode.compact": "Compact",
"card_mode.dense": "Dense",
"card_mode.row": "List",
"dashboard.customize.collapse_default.on": "Start collapsed",
"dashboard.customize.collapse_default.off": "Start expanded",
"dashboard.customize.show": "Show",
@@ -964,6 +964,11 @@
"dashboard.customize.density.comfortable": "Просторно",
"dashboard.customize.density.compact": "Компактно",
"dashboard.customize.density.dense": "Плотно",
"card_mode.tooltip": "Размер карточек",
"card_mode.comfortable": "Просторно",
"card_mode.compact": "Компактно",
"card_mode.dense": "Плотно",
"card_mode.row": "Список",
"dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию",
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
"dashboard.customize.show": "Показать",
@@ -964,6 +964,11 @@
"dashboard.customize.density.comfortable": "宽松",
"dashboard.customize.density.compact": "紧凑",
"dashboard.customize.density.dense": "密集",
"card_mode.tooltip": "卡片大小",
"card_mode.comfortable": "宽松",
"card_mode.compact": "紧凑",
"card_mode.dense": "密集",
"card_mode.row": "列表",
"dashboard.customize.collapse_default.on": "默认折叠",
"dashboard.customize.collapse_default.off": "默认展开",
"dashboard.customize.show": "显示",
@@ -0,0 +1,119 @@
"""Tests for /api/v1/preferences/card-modes endpoints."""
import pytest
from ledgrab.config import get_config
@pytest.fixture(scope="module")
def client():
"""TestClient with auth header read at fixture-build time.
Mirrors test_preferences_api.py — the auth key is resolved here so
any singleton mutation during pytest collection cannot leave us with
a stale Bearer header.
"""
from fastapi.testclient import TestClient
from ledgrab.main import app
api_key = next(iter(get_config().auth.api_keys.values()), "")
with TestClient(app, raise_server_exceptions=False) as c:
if api_key:
c.headers["Authorization"] = f"Bearer {api_key}"
yield c
def _prefs(surfaces: dict[str, str] | None = None) -> dict:
return {"version": 1, "surfaces": surfaces or {}}
def test_get_default_empty(client):
"""When nothing is saved, GET returns {}."""
client.delete("/api/v1/preferences/card-modes")
resp = client.get("/api/v1/preferences/card-modes")
assert resp.status_code == 200
assert resp.json() == {}
def test_put_then_get_round_trip(client):
"""PUT a prefs object, GET it back verbatim."""
body = _prefs({"led-devices": "dense", "led-targets": "comfortable"})
put = client.put("/api/v1/preferences/card-modes", json=body)
assert put.status_code == 200
assert put.json() == {"ok": True}
got = client.get("/api/v1/preferences/card-modes")
assert got.status_code == 200
assert got.json() == body
def test_put_rejects_missing_version(client):
"""Body without numeric version is rejected with 422."""
resp = client.put(
"/api/v1/preferences/card-modes",
json={"surfaces": {"led-devices": "dense"}},
)
assert resp.status_code == 422
def test_put_rejects_invalid_mode(client):
"""A mode value outside the allowed set is rejected with 422."""
resp = client.put(
"/api/v1/preferences/card-modes",
json=_prefs({"led-devices": "extreme"}),
)
assert resp.status_code == 422
def test_put_rejects_non_dict_surfaces(client):
"""`surfaces` must be an object, not an array or string."""
resp = client.put(
"/api/v1/preferences/card-modes",
json={"version": 1, "surfaces": ["led-devices", "dense"]},
)
assert resp.status_code == 422
def test_put_accepts_empty_surfaces(client):
"""An empty surfaces map is a valid (no-override) state."""
resp = client.put("/api/v1/preferences/card-modes", json=_prefs({}))
assert resp.status_code == 200
def test_put_accepts_unknown_surface_keys(client):
"""Surface keys are an open registry — any non-empty string is OK."""
body = _prefs(
{
"led-devices": "compact",
"automations": "dense",
"weather-sources": "comfortable",
"future-surface-v2": "compact",
}
)
resp = client.put("/api/v1/preferences/card-modes", json=body)
assert resp.status_code == 200
got = client.get("/api/v1/preferences/card-modes").json()
assert got["surfaces"]["future-surface-v2"] == "compact"
def test_put_accepts_row_mode(client):
"""`row` is a valid mode (added alongside the original three)."""
body = _prefs({"led-devices": "row"})
resp = client.put("/api/v1/preferences/card-modes", json=body)
assert resp.status_code == 200
got = client.get("/api/v1/preferences/card-modes").json()
assert got["surfaces"]["led-devices"] == "row"
def test_delete_clears(client):
"""DELETE wipes saved prefs so the next GET returns empty."""
client.put(
"/api/v1/preferences/card-modes",
json=_prefs({"led-devices": "dense"}),
)
deleted = client.delete("/api/v1/preferences/card-modes")
assert deleted.status_code == 200
after = client.get("/api/v1/preferences/card-modes")
assert after.status_code == 200
assert after.json() == {}