/* ========================================================= CONNIE - dashboard layout + settings state (localStorage) One source of truth for the user's dashboard preferences, persisted under a single key ``connie.layout``: { order: string[], // flat preferred card-id order; applied // per column when rendering columns: { [cardId]: ColName }, // per-card column override; // missing -> the card's natural column hidden: string[], // hidden card-ids collapsed: ColName[], // collapsed column names (folded to a strip) sizes: { [cardId]: "S" | "M" | "L" }, // rendered tile size step textSize: "S" | "M" | "L", // speech text size -> --speech-size voiceId: string | null, // chosen ElevenLabs voice language: string | null // chosen voice-language filter ("" / null // = all languages) } ColName is one of COLUMNS = ["left","left2","right","right2"] - a 4-column "2 | Orb | 2" widescreen layout. The two original names ("left","right") are kept on purpose so any pre-S3 stored layout stays valid with zero migration: old overrides still resolve, the two new columns ("left2","right2") simply start empty. v3.1: replaced the old up/down + compact model with real drag-and-drop (column override + flat order) and a discrete S/M/L size step that visibly changes the rendered tile height. Backward compatible: an old ``sizes`` value of "compact" maps to "S" and "normal" to "M"; an old layout without ``columns`` just falls back to each card's natural column. S3 (this revision): 2 columns -> 4 columns (widescreen) plus per-column collapse (``collapsed``). All four columns are collapsible; an unknown column name in a stored ``collapsed`` is dropped on load. Robustness: load() always returns a fully-formed object even when the stored JSON is missing, partial, an old shape, or corrupt. Unknown keys are ignored; missing keys fall back to defaults. Nothing here throws. ========================================================= */ const LAYOUT_KEY = "connie.layout"; const TEXT_SIZES = ["S", "M", "L"]; // Maps the discrete text-size choice to the CSS clamp() applied via --speech-size. const TEXT_SIZE_CSS = { S: "clamp(16px, 1.6vw, 20px)", M: "clamp(20px, 2.0vw, 25px)", L: "clamp(24px, 2.6vw, 32px)", }; // Tile size steps. "M" is the natural/content size (the pre-v3.1 default look). const SIZE_STEPS = ["S", "M", "L"]; const DEFAULT_SIZE = "M"; // Four columns in a "2 | Orb | 2" widescreen layout. "left"/"right" keep their // names from the old 2-column model so stored layouts migrate for free; the two // new columns start empty. computeDrop / cardsForColumn / columnOf are all // generic over this list - it is the single source of truth for column names. const COLUMNS = ["left", "left2", "right", "right2"]; // Legacy size-value migration (old model used compact/normal). const LEGACY_SIZE_MAP = { compact: "S", normal: "M" }; function defaultLayout() { return { order: [], columns: {}, hidden: [], collapsed: [], sizes: {}, textSize: "M", voiceId: null, language: null, }; } // Coerce an arbitrary parsed value into a clean layout object (defensive). function normalizeLayout(raw) { const d = defaultLayout(); if (!raw || typeof raw !== "object") return d; const order = Array.isArray(raw.order) ? raw.order.filter((x) => typeof x === "string") : d.order; const hidden = Array.isArray(raw.hidden) ? raw.hidden.filter((x) => typeof x === "string") : d.hidden; // Collapsed columns: keep only known column names, de-duplicated. const collapsed = Array.isArray(raw.collapsed) ? [...new Set(raw.collapsed.filter((x) => COLUMNS.includes(x)))] : d.collapsed; let columns = {}; if (raw.columns && typeof raw.columns === "object") { for (const [k, v] of Object.entries(raw.columns)) { if (typeof k === "string" && COLUMNS.includes(v)) columns[k] = v; } } let sizes = {}; if (raw.sizes && typeof raw.sizes === "object") { for (const [k, v] of Object.entries(raw.sizes)) { if (typeof k !== "string") continue; // Accept the new S/M/L steps, and migrate the old compact/normal values. if (SIZE_STEPS.includes(v)) sizes[k] = v; else if (LEGACY_SIZE_MAP[v]) sizes[k] = LEGACY_SIZE_MAP[v]; } } const textSize = TEXT_SIZES.includes(raw.textSize) ? raw.textSize : d.textSize; const voiceId = typeof raw.voiceId === "string" && raw.voiceId ? raw.voiceId : null; const language = typeof raw.language === "string" && raw.language ? raw.language : null; return { order, columns, hidden, collapsed, sizes, textSize, voiceId, language }; } function loadLayout() { try { const rawStr = window.localStorage.getItem(LAYOUT_KEY); if (!rawStr) return defaultLayout(); return normalizeLayout(JSON.parse(rawStr)); } catch (_) { return defaultLayout(); } } function saveLayout(layout) { try { window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(normalizeLayout(layout))); } catch (_) { // Private mode / quota: preferences just won't persist this session. } } // Resolve a card's column: an explicit override wins; otherwise the card's // natural column from ``naturalCols`` ({cardId -> "left"|"right"}); otherwise // "left" as a last resort (a brand-new card with no natural mapping). function columnOf(cardId, layout, naturalCols) { const ov = layout.columns && layout.columns[cardId]; if (COLUMNS.includes(ov)) return ov; const nat = naturalCols && naturalCols[cardId]; return COLUMNS.includes(nat) ? nat : "left"; } // The ordered list of card-ids that belong to ``colName``. We take EVERY known // card (from naturalCols), keep those whose resolved column == colName, then // sort by the saved flat order (ids in ``order`` first, in that order; unknown/ // new ids keep their natural position at the end). This is robust against old // keys: a new card in code always appears (it just isn't in ``order`` yet) and // a card can never disappear because of a stale ``order``. function cardsForColumn(naturalCols, layout, colName) { const allIds = Object.keys(naturalCols || {}); const inCol = allIds.filter((id) => columnOf(id, layout, naturalCols) === colName); const ord = Array.isArray(layout.order) ? layout.order : []; const inSaved = ord.filter((id) => inCol.includes(id)); const rest = inCol.filter((id) => !inSaved.includes(id)); return [...inSaved, ...rest]; } // Visible subset of a column (hidden cards filtered out), in order. function visibleCardsForColumn(naturalCols, layout, colName) { const hidden = Array.isArray(layout.hidden) ? layout.hidden : []; return cardsForColumn(naturalCols, layout, colName).filter( (id) => !hidden.includes(id) ); } // Compute a new {order, columns} after dropping ``draggedId`` into ``targetCol`` // immediately BEFORE ``beforeId`` (or at the end of the column when // ``beforeId`` is null/unknown). Pure: returns a new partial layout patch // ({order, columns}); does not mutate the input. // // Strategy: build the full per-column ordered id lists (including hidden cards, // so their relative order is preserved), remove the dragged id from wherever it // is, insert it at the requested spot in the target column, then flatten back // into one ``order`` array (left column first, then right) and record the // dragged id's column override. function computeDrop(naturalCols, layout, draggedId, targetCol, beforeId) { if (!COLUMNS.includes(targetCol)) return { order: layout.order, columns: layout.columns }; // Dropping a card immediately before itself is a no-op (the UI already excludes // the dragged card from drop candidates, but guard defensively here too). if (beforeId === draggedId) return { order: layout.order, columns: layout.columns }; // Current full ordered lists per column (hidden included). const lists = {}; for (const c of COLUMNS) lists[c] = cardsForColumn(naturalCols, layout, c); // Remove the dragged id from every column list. for (const c of COLUMNS) lists[c] = lists[c].filter((id) => id !== draggedId); // Insert into the target column at the right index. const tgt = lists[targetCol]; let idx = tgt.length; // default: append if (beforeId && beforeId !== draggedId) { const at = tgt.indexOf(beforeId); if (at !== -1) idx = at; } tgt.splice(idx, 0, draggedId); // Flatten back to a single flat order (all columns in COLUMNS order). Per-column // rendering re-derives each column from this + the columns map, so the cross- // column interleaving in the flat array is irrelevant - only intra-column order // matters. Generic over COLUMNS so it covers all four columns without a hardcode. const order = COLUMNS.flatMap((c) => lists[c]); // Record the column override for the dragged card. const columns = { ...(layout.columns || {}), [draggedId]: targetCol }; return { order, columns }; } // Set (or normalise) a card's size step. Returns a new ``sizes`` object. function setCardSize(layout, cardId, step) { const next = SIZE_STEPS.includes(step) ? step : DEFAULT_SIZE; return { ...(layout.sizes || {}), [cardId]: next }; } // Resolve a card's current size step (default "M"). function sizeOf(layout, cardId) { const s = layout.sizes && layout.sizes[cardId]; return SIZE_STEPS.includes(s) ? s : DEFAULT_SIZE; } // Is ``col`` currently collapsed (folded to a strip)? function isCollapsed(layout, col) { const c = layout && Array.isArray(layout.collapsed) ? layout.collapsed : []; return c.includes(col); } // Toggle a column's collapsed state. Returns a new ``collapsed`` array (does not // mutate). No-ops for an unknown column name (only COLUMNS can be collapsed). function toggleCollapsed(layout, col) { const cur = layout && Array.isArray(layout.collapsed) ? layout.collapsed : []; if (!COLUMNS.includes(col)) return [...cur]; return cur.includes(col) ? cur.filter((c) => c !== col) : [...cur, col]; } window.ConnieLayout = { LAYOUT_KEY, TEXT_SIZES, TEXT_SIZE_CSS, SIZE_STEPS, DEFAULT_SIZE, COLUMNS, defaultLayout, normalizeLayout, loadLayout, saveLayout, columnOf, cardsForColumn, visibleCardsForColumn, computeDrop, setCardSize, sizeOf, isCollapsed, toggleCollapsed, };