diff --git a/CHANGELOG.md b/CHANGELOG.md index dad60b0..4086232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,20 @@ auto-generated per-PR notes; this file is the curated, human-readable history. per-tile controls, and export arrive in later phases (#149 D2–D7). Known limitation: two tabs independently refreshing a *rotating* OAuth refresh token can race (BroadcastChannel sync deferred). +- **Dashboard (phase 2): Arrange / Report layout switcher** (#149). A toolbar + below the dashboard header (the future filter bar) adds a primary **Arrange | + Report** segmented control: **Arrange** is the uniform multi-column grid, and + **Report** lays the tiles out as a single full-width scrolling column with + taller charts. A secondary **Columns 2 | 3** control tunes the Arrange grid + (hidden in Report's single column). Both are presentation-only — switching + reshapes the grid and the chart tiles resize themselves, with no tile re-query + — and the choice is persisted per browser (`asb:dashLayout` / `asb:dashCols`), + surviving reloads and Refresh. Chart tiles were also brought closer to the + design: the saved query **description** shows as a subtitle under the tile + name, the chart now draws on the **tile's own background** (instead of the + darker results-table background), and the value-axis **gridlines are hidden** + on tiles (they read as noisy light lines on a dark panel). Drag-to-reorder and + 1/2-column tile spans arrive in the next phase (#149 D3). - **Schema-aware, FROM-driven autocompletion** (#84) — column completion now fires *while you type*, driven by the statement's `FROM`/`JOIN` clause, so you no longer have to expand a table in the sidebar first. A new pure module diff --git a/src/core/chart-data.js b/src/core/chart-data.js index a464fff..05498fc 100644 --- a/src/core/chart-data.js +++ b/src/core/chart-data.js @@ -313,9 +313,12 @@ const withAlpha = (hex, frac) => { /** * Build a complete Chart.js config object (type + data + themed options) from a * result and the user's `cfg`. Pure: returns a plain object (Chart.js draws it). - * `colors` is a resolved token bundle from `chartColors`. + * `colors` is a resolved token bundle from `chartColors`. `opts.hideGrid` + * suppresses the value-axis gridlines (dashboard tiles draw on the panel + * background where a light gridline reads as noise — #149; the tick labels + * stay), keeping the chart body clean like the design's tiles. */ -export function chartJsConfig(columns, rows, cfg, colors) { +export function chartJsConfig(columns, rows, cfg, colors, opts = {}) { const { labels, datasets } = buildChartData(columns, rows, cfg); const pal = colors.palette; const horizontal = cfg.type === 'hbar'; @@ -336,7 +339,7 @@ export function chartJsConfig(columns, rows, cfg, colors) { }); const multi = datasets.length > 1; - const grid = { color: colors.borderFaint, drawBorder: false }; + const grid = { color: colors.borderFaint, drawBorder: false, display: !opts.hideGrid }; const ticks = { color: colors.fgMute, font: { family: colors.mono, size: 10 } }; const valueTicks = { ...ticks, callback: (v) => chartNumFmt(typeof v === 'number' ? v : Number(v)) }; diff --git a/src/core/dashboard.js b/src/core/dashboard.js index 5ed5ecb..faaff34 100644 --- a/src/core/dashboard.js +++ b/src/core/dashboard.js @@ -31,6 +31,26 @@ export function configBase(pathname) { return (pathname || '').replace(/\/dashboard\/?$/, ''); } +/** + * Dashboard layout modes (#149 D2): `arrange` = uniform multi-column grid + * (default), `report` = single full-width scrolling column with taller tiles. + * Persisted per browser (`asb:dashLayout`). + */ +export const DASH_LAYOUTS = ['arrange', 'report']; + +/** Snap a persisted layout to a known mode, defaulting to `arrange`. Pure. */ +export function normalizeDashLayout(v) { + return DASH_LAYOUTS.includes(v) ? v : 'arrange'; +} + +/** Column-count options for Arrange mode (persisted `asb:dashCols`). */ +export const DASH_COLS = [2, 3]; + +/** Snap a persisted column count to 2 or 3, defaulting to 3. Pure. */ +export function normalizeDashCols(n) { + return DASH_COLS.includes(n) ? n : 3; +} + /** * A favorite's SQL prepared for a one-shot tile fetch: `FORMAT JSON` appended * unless the query already ends in its own trailing `FORMAT` clause (which we diff --git a/src/state.js b/src/state.js index 48b718e..b2a530f 100644 --- a/src/state.js +++ b/src/state.js @@ -5,6 +5,7 @@ import { clamp } from './core/format.js'; import { mergeSaved } from './core/saved-io.js'; import { cloneChartCfg } from './core/chart-data.js'; +import { normalizeDashLayout, normalizeDashCols } from './core/dashboard.js'; import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js'; import { signal } from '@preact/signals-core'; @@ -28,6 +29,8 @@ export const KEYS = { libraryName: 'asb:libraryName', resultRowLimit: 'asb:resultRowLimit', varValues: 'asb:varValues', + dashLayout: 'asb:dashLayout', + dashCols: 'asb:dashCols', }; /** Row-limit options for the result cap selector (shared between state + UI). */ @@ -74,6 +77,11 @@ export function createState(read = { loadJSON, loadStr }) { // One persisted preference, default 500; a non-option stored value snaps // back to the default so the selector always reflects a real choice. resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)), + // Dashboard layout prefs (#149 D2), persisted per browser. Plain (non-signal) + // like theme/density — the standalone dashboard page reads them at build time + // and mutates + re-saves on the Arrange/Report + column-count controls. + dashLayout: normalizeDashLayout(read.loadStr(KEYS.dashLayout, 'arrange')), + dashCols: normalizeDashCols(parseInt(read.loadStr(KEYS.dashCols, '3'), 10)), sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), editorPct: num(KEYS.editorPct, 45, 15, 85), sideSplitPct: num(KEYS.sideSplitPct, 58, 25, 85), diff --git a/src/styles.css b/src/styles.css index ba3e2f6..7fe70cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1744,11 +1744,29 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* The dashboard's own scroll container: #root is a fixed overflow:hidden flex column (the workbench shell), so the dashboard fills it and scrolls itself. */ .dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); } +/* Header + layout toolbar share one sticky top bar (#149 D2) so the + Arrange/Report switcher stays visible while the grid scrolls. */ +.dash-topbar { position: sticky; top: 0; z-index: 40; background: var(--bg-header); } .dash-header { - position: sticky; top: 0; z-index: 40; display: flex; align-items: center; + display: flex; align-items: center; gap: 12px; padding: 11px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); flex-wrap: wrap; } +/* Layout toolbar (#149 D2) — becomes the filter bar in D4. */ +.dash-toolbar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 8px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); +} +.dash-seg { display: inline-flex; background: var(--bg-chip); border: 1px solid var(--border); border-radius: 8px; padding: 2px; } +.dash-seg-btn { + appearance: none; border: none; background: none; cursor: pointer; + padding: 4px 12px; border-radius: 6px; font: 500 12px var(--ui); color: var(--fg-mute); +} +.dash-seg-btn:hover { color: var(--fg); } +.dash-seg-btn.is-active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0,0,0,.12); } +.dash-cols-wrap { display: inline-flex; align-items: center; gap: 8px; } +.dash-seg-cols .dash-seg-btn { padding: 4px 10px; font-family: var(--mono); } +.dash-seg-label { font-size: 11px; color: var(--fg-faint); } .dash-icobtn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; background: var(--bg-chip); color: var(--fg-mute); border: 1px solid var(--border); @@ -1779,16 +1797,19 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } } .dash-btn:hover { background: var(--bg-hover); } .dash-btn:disabled { opacity: .55; cursor: default; } -/* Arrange grid: 3 across on wide screens (the design's default), degrading to - 2 then 1 as width shrinks. A configurable 2/3-column switcher + drag-reorder - land in D5. */ +/* Arrange grid: the persisted column count (2 or 3, via `--dash-cols`, default + 3) on wide screens, degrading to 2 then 1 as width shrinks. The 2/3 switcher + lives in the toolbar (#149 D2); drag-reorder + 1/2-col span land in D3. */ .dash-grid { display: grid; gap: 14px; padding: 18px 20px 40px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(var(--dash-cols, 3), minmax(0, 1fr)); max-width: 1560px; margin: 0 auto; } @media (max-width: 1100px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 720px) { .dash-grid { grid-template-columns: 1fr; } } +/* Report mode (#149 D2): one full-width scrolling column with taller tiles. */ +.dash-grid.is-report { grid-template-columns: 1fr; max-width: 1100px; } +.dash-grid.is-report .dash-tile { min-height: 440px; } /* D1 shows charts read-only: renderChart is called with controls:false so the Type/X/Y config bar isn't built at all (a settings-popover arrives in D6). */ .dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; } @@ -1803,8 +1824,18 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } font-size: 13px; font-weight: 600; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } +/* Saved-query description subtitle under the tile name (matches the design). */ +.dash-tile-desc { + font-size: 11px; color: var(--fg-mute); margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} .dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; } .dash-tile-body > .chart-view { flex: 1; min-width: 0; } +/* The chart draws on the tile's own background (--bg-side), not the darker + results-table bg the workbench chart view uses; this both matches the design + and lets the faint value-axis gridlines recede instead of standing out on a + near-black panel in dark mode. */ +.dash-tile .chart-view, .dash-tile .chart-empty { background: transparent; } .dash-tile-load { display: flex; align-items: center; gap: 8px; margin: auto; color: var(--fg-faint); font-size: 12px; diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js index 4c26035..43e9d79 100644 --- a/src/ui/dashboard.js +++ b/src/ui/dashboard.js @@ -18,6 +18,27 @@ import { formatBytes, formatRows } from '../core/format.js'; // browser's per-host pool and the cluster) on open and on every Refresh. const TILE_CONCURRENCY = 6; +/** + * Build a segmented control (`Arrange | Report`, `2 | 3`): a row of buttons of + * which exactly one reads active. `getActive` returns the currently-selected + * value; `onPick(value)` fires on a click. Returns `{ el, sync }` — `sync()` + * repaints the active button from `getActive()` (called after a pick so the + * two controls can share one `apply()`). + */ +function buildSeg(cls, options, getActive, onPick) { + const btns = options.map(([, label]) => + h('button', { class: 'dash-seg-btn', type: 'button' }, label)); + const sync = () => btns.forEach((b, i) => { + const on = options[i][0] === getActive(); + b.classList.toggle('is-active', on); + b.setAttribute('aria-pressed', String(on)); + }); + btns.forEach((b, i) => { b.onclick = () => onPick(options[i][0]); }); + const el = h('div', { class: 'dash-seg ' + cls, role: 'group' }, ...btns); + sync(); + return { el, sync }; +} + /** Build a tile's footer meta row (rows · ms · bytes), omitting stats CH didn't return. */ function tileFooter(meta) { const parts = [h('span', null, formatRows(meta.rows) + ' rows')]; @@ -55,9 +76,12 @@ async function renderTile(app, q, grid, tiles) { const body = h('div', { class: 'dash-tile-body' }, h('div', { class: 'dash-tile-load' }, Icon.spinner(), h('span', null, 'Loading…'))); const foot = h('div', { class: 'dash-tile-foot' }); - const card = h('div', { class: 'dash-tile' }, - h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: q.name }, q.name)), - body, foot); + // Header: the favorite's name, plus its saved description as a subtitle when it + // has one (single line, ellipsized) — mirrors the design mockup's tile header. + const head = h('div', { class: 'dash-tile-head' }, + h('span', { class: 'dash-tile-name', title: q.name }, q.name)); + if (q.description) head.appendChild(h('div', { class: 'dash-tile-desc', title: q.description }, q.description)); + const card = h('div', { class: 'dash-tile' }, head, body, foot); grid.appendChild(card); const r = await app.runTile(q.sql); @@ -78,7 +102,7 @@ async function renderTile(app, q, grid, tiles) { const chartTab = { chartKey: schemaKey(r.columns), chartCfg: cls.cfg }; let inst = null; body.replaceChildren(renderChart(app, res, { - tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false, + tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false, hideGrid: true, })); tiles.push({ destroy: () => inst.destroy() }); foot.replaceChildren(...tileFooter(r.meta)); @@ -125,10 +149,48 @@ export function renderDashboard(app) { const empty = h('div', { class: 'dash-empty', style: { display: favorites.length ? 'none' : '' } }, 'No favorites yet — star a query in the Library to add it to the dashboard.'); + // Layout toolbar (#149 D2), the row that becomes the filter bar in D4. The + // Arrange|Report switcher is the primary control; the 2/3 column count is a + // secondary setting, meaningful only in Arrange (hidden in Report's single + // column). Both are presentation-only: `apply()` reshapes the grid and the + // tiles' Chart.js instances resize themselves via their ResizeObserver — no + // tile re-query. State is mutated + persisted (asb:dashLayout/dashCols) so the + // choice survives reloads and Refresh (which rebuilds the grid's children, not + // the grid element, so its class/`--dash-cols` persist across a refresh). + const apply = () => { + grid.classList.toggle('is-report', state.dashLayout === 'report'); + grid.style.setProperty('--dash-cols', String(state.dashCols)); + colsWrap.style.display = state.dashLayout === 'report' ? 'none' : ''; + layoutSeg.sync(); + colsSeg.sync(); + }; + const layoutSeg = buildSeg('dash-seg-layout', [['arrange', 'Arrange'], ['report', 'Report']], + () => state.dashLayout, (v) => { + if (v === state.dashLayout) return; + state.dashLayout = v; + app.savePref('dashLayout', v); + apply(); + }); + const colsSeg = buildSeg('dash-seg-cols', [[2, '2'], [3, '3']], + () => state.dashCols, (v) => { + if (v === state.dashCols) return; + state.dashCols = v; + app.savePref('dashCols', v); + apply(); + }); + const colsWrap = h('div', { class: 'dash-cols-wrap' }, + h('span', { class: 'dash-seg-label' }, 'Columns'), colsSeg.el); + const toolbar = h('div', { class: 'dash-toolbar' }, + layoutSeg.el, + h('div', { class: 'dash-spacer', style: { flex: '1' } }), + colsWrap); + apply(); + // #root is a fixed, overflow:hidden flex column (the workbench layout), so the // dashboard needs its own scroll container — otherwise a tall grid clips with - // no vertical scroll. The sticky header lives inside it. - app.root.replaceChildren(h('div', { class: 'dash-page' }, header, empty, grid)); + // no vertical scroll. The header + toolbar share one sticky top bar inside it. + app.root.replaceChildren(h('div', { class: 'dash-page' }, + h('div', { class: 'dash-topbar' }, header, toolbar), empty, grid)); // Chart.js instances of the tiles currently in the grid, torn down before the // next Refresh rebuilds them (grid.replaceChildren() alone would orphan them, diff --git a/src/ui/results.js b/src/ui/results.js index 29b047d..c7b1b4e 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -962,6 +962,7 @@ export function installChartZoomFix(chart, canvas) { * `opts.running` overrides the run-state gate — a detached snapshot's `r` is * always already-complete, independent of whatever the live tab is doing. * `opts.controls === false` omits the Type/X/Y config bar (read-only tiles). + * `opts.hideGrid` suppresses the value-axis gridlines (dashboard tiles — #149). */ export function renderChart(app, r, opts = {}) { const tab = opts.tab || app.activeTab(); @@ -1019,7 +1020,7 @@ export function renderChart(app, r, opts = {}) { // contradicting the "first N rows" note. It would also sort up to VIS_CAP // rows just to discard all but the first `cap`. const chart = installChartZoomFix( - new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar))), + new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar), { hideGrid: opts.hideGrid })), canvas); setChart(chart); // Chart.js's own responsive sizing reads layout through APIs (getComputedStyle, diff --git a/tests/unit/chart-data.test.js b/tests/unit/chart-data.test.js index 88f9e3e..e634c9a 100644 --- a/tests/unit/chart-data.test.js +++ b/tests/unit/chart-data.test.js @@ -287,6 +287,15 @@ describe('chartJsConfig', () => { expect(cfg.options.scales.y.grid.display).toBe(false); // category axis expect(cfg.data.datasets[0].backgroundColor).toBe(colors.palette[0]); }); + it('shows value-axis gridlines by default, hides them with opts.hideGrid (#149)', () => { + const shown = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors); + expect(shown.options.scales.y.grid.display).toBe(true); + const hidden = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors, { hideGrid: true }); + expect(hidden.options.scales.y.grid.display).toBe(false); + // the horizontal-bar value axis (x) is hidden too, not just the category axis + const hbar = chartJsConfig(cols, rows, { type: 'hbar', x: 0, y: [1], series: null }, colors, { hideGrid: true }); + expect(hbar.options.scales.x.grid.display).toBe(false); + }); it('vertical column keeps indexAxis x', () => { const cfg = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors); expect(cfg.type).toBe('bar'); diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js index 95de434..6070867 100644 --- a/tests/unit/dashboard.test.js +++ b/tests/unit/dashboard.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { webcrypto } from 'node:crypto'; import { isDashboardRoute, configBase, dashboardTileSql, parseJsonResult, classifyTile, + normalizeDashLayout, normalizeDashCols, } from '../../src/core/dashboard.js'; import { AUTH_SS_KEYS, AUTH_REQUEST, AUTH_GRANT, @@ -107,6 +108,24 @@ describe('classifyTile', () => { }); }); +describe('normalizeDashLayout', () => { + it('passes through known modes, defaults everything else to arrange', () => { + expect(normalizeDashLayout('arrange')).toBe('arrange'); + expect(normalizeDashLayout('report')).toBe('report'); + expect(normalizeDashLayout('grid')).toBe('arrange'); + expect(normalizeDashLayout(undefined)).toBe('arrange'); + }); +}); + +describe('normalizeDashCols', () => { + it('passes through 2/3, defaults everything else to 3', () => { + expect(normalizeDashCols(2)).toBe(2); + expect(normalizeDashCols(3)).toBe(3); + expect(normalizeDashCols(4)).toBe(3); + expect(normalizeDashCols(NaN)).toBe(3); + }); +}); + // ── core/auth-handoff.js ───────────────────────────────────────────────────── function memSession(initial = {}) { const m = new Map(Object.entries(initial)); @@ -188,6 +207,19 @@ describe('renderDashboard', () => { expect(app.root.querySelector('.dash-tile-foot').textContent).toContain('rows'); }); + it('renders the saved description as a tile subtitle when present, omits it otherwise', async () => { + const favorites = [ + { id: '1', name: 'With desc', sql: 'a', favorite: true, description: 'Daily totals by category' }, + { id: '2', name: 'No desc', sql: 'b', favorite: true }, + ]; + const app = dashApp(favorites, vi.fn(async () => chartResult())); + await renderDashboard(app); + const descs = [...app.root.querySelectorAll('.dash-tile-desc')]; + expect(descs).toHaveLength(1); + expect(descs[0].textContent).toBe('Daily totals by category'); + expect(descs[0].getAttribute('title')).toBe('Daily totals by category'); + }); + it('uses the singular chip label with exactly one favorite', async () => { const app = dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); await renderDashboard(app); @@ -286,6 +318,68 @@ describe('renderDashboard', () => { expect(app.root.querySelector('.dash-tile .chart-config')).toBeNull(); // controls omitted, not hidden expect(app.root.querySelector('.dash-tile .chart-select')).toBeNull(); }); + + // ── D2: layout toolbar (Arrange/Report + column count) ────────────────────── + const oneFav = () => dashApp([{ id: '1', name: 'Q', sql: 'q', favorite: true }], vi.fn(async () => chartResult())); + const seg = (root, cls, label) => + [...root.querySelectorAll('.' + cls + ' .dash-seg-btn')].find((b) => b.textContent === label); + + it('defaults to Arrange (3 columns), grid not in report mode, column control shown', async () => { + const app = oneFav(); + await renderDashboard(app); + const grid = app.root.querySelector('.dash-grid'); + expect(grid.classList.contains('is-report')).toBe(false); + expect(grid.style.getPropertyValue('--dash-cols')).toBe('3'); + expect(seg(app.root, 'dash-seg-layout', 'Arrange').classList.contains('is-active')).toBe(true); + expect(seg(app.root, 'dash-seg-layout', 'Report').classList.contains('is-active')).toBe(false); + expect(app.root.querySelector('.dash-cols-wrap').style.display).toBe(''); + expect(seg(app.root, 'dash-seg-cols', '3').getAttribute('aria-pressed')).toBe('true'); + }); + + it('switching to Report reshapes the grid, hides the column control, and persists', async () => { + const app = oneFav(); + await renderDashboard(app); + seg(app.root, 'dash-seg-layout', 'Report').dispatchEvent(new Event('click', { bubbles: true })); + const grid = app.root.querySelector('.dash-grid'); + expect(grid.classList.contains('is-report')).toBe(true); + expect(seg(app.root, 'dash-seg-layout', 'Report').classList.contains('is-active')).toBe(true); + expect(app.root.querySelector('.dash-cols-wrap').style.display).toBe('none'); + expect(app.savePref).toHaveBeenCalledWith('dashLayout', 'report'); + // …and back to Arrange restores the grid + column control. + seg(app.root, 'dash-seg-layout', 'Arrange').dispatchEvent(new Event('click', { bubbles: true })); + expect(grid.classList.contains('is-report')).toBe(false); + expect(app.root.querySelector('.dash-cols-wrap').style.display).toBe(''); + }); + + it('the column-count control switches 2/3 and persists', async () => { + const app = oneFav(); + await renderDashboard(app); + seg(app.root, 'dash-seg-cols', '2').dispatchEvent(new Event('click', { bubbles: true })); + const grid = app.root.querySelector('.dash-grid'); + expect(grid.style.getPropertyValue('--dash-cols')).toBe('2'); + expect(seg(app.root, 'dash-seg-cols', '2').classList.contains('is-active')).toBe(true); + expect(app.savePref).toHaveBeenCalledWith('dashCols', 2); + }); + + it('clicking the already-active layout or column is a no-op (no persist)', async () => { + const app = oneFav(); + await renderDashboard(app); + seg(app.root, 'dash-seg-layout', 'Arrange').dispatchEvent(new Event('click', { bubbles: true })); + seg(app.root, 'dash-seg-cols', '3').dispatchEvent(new Event('click', { bubbles: true })); + expect(app.savePref).not.toHaveBeenCalled(); + }); + + it('reflects the persisted layout (Report) and column count (2) on first render', async () => { + const app = oneFav(); + app.state.dashLayout = 'report'; + app.state.dashCols = 2; + await renderDashboard(app); + const grid = app.root.querySelector('.dash-grid'); + expect(grid.classList.contains('is-report')).toBe(true); + expect(grid.style.getPropertyValue('--dash-cols')).toBe('2'); + expect(app.root.querySelector('.dash-cols-wrap').style.display).toBe('none'); + expect(seg(app.root, 'dash-seg-cols', '2').classList.contains('is-active')).toBe(true); + }); }); // ── app.js: runTile + auth handoff wiring ──────────────────────────────────── diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index 19e7dfc..2d6c746 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -41,6 +41,8 @@ describe('createState', () => { expect(s.expanded.value.size).toBe(0); expect(s.libraryName.value).toBe(DEFAULT_LIBRARY_NAME); expect(s.libraryDirty.value).toBe(false); + expect(s.dashLayout).toBe('arrange'); + expect(s.dashCols).toBe(3); }); it('reads + clamps persisted prefs', () => { const s = createState(reader({ @@ -53,9 +55,13 @@ describe('createState', () => { [KEYS.saved]: [{ id: 's1', sql: 'x', name: 'n', starred: true }], [KEYS.history]: [{ id: 'h1', sql: 'y', ts: 1, rows: 1, ms: 2 }], [KEYS.libraryName]: 'My team queries', + [KEYS.dashLayout]: 'report', + [KEYS.dashCols]: '2', })); expect(s.theme).toBe('light'); expect(s.libraryName.value).toBe('My team queries'); + expect(s.dashLayout).toBe('report'); + expect(s.dashCols).toBe(2); expect(s.sidebarPx).toBe(420); expect(s.editorPct).toBe(15); expect(s.sideSplitPct).toBe(85);