diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de963c..85b3dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history. Dashboard gains refresh, View/Edit, and `G` navigation commands while stale viewer sessions, loading routes, stacked overlays, and nested text inputs fail closed. Contextual help is torn down on route and authentication - transitions so it cannot survive onto another surface or Login. + transitions so it cannot survive onto another surface or Login. Dashboard + Style adds `G G`, `G F`, `G R`, `G 2`, and `G 3` for Grid Tiles, Full view, + Report, and two/three columns; View mode previews every style without + changing the shared dashboard. ## [0.6.4] - 2026-07-24 diff --git a/src/dashboard/application/dashboard-viewer-session.ts b/src/dashboard/application/dashboard-viewer-session.ts index 7412ffb..e6d7fe7 100644 --- a/src/dashboard/application/dashboard-viewer-session.ts +++ b/src/dashboard/application/dashboard-viewer-session.ts @@ -172,6 +172,11 @@ export type DashboardLayoutView = | (FlowLayoutModel & { engine: 'flow' }) | { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel; renderMode: GridRenderMode }; +/** The five presentation styles exposed by the Dashboard header. This is a + * runtime choice: View mode can select any style without changing the shared + * document, while Edit mode maps the same values to authoring commands. */ +export type DashboardStyle = 'grafana-grid' | 'full' | 'report' | 'columns-2' | 'columns-3'; + export interface DashboardViewState { /** Search-matched tiles only, in their unchanged saved order. */ tiles: ViewerTileState[]; @@ -182,6 +187,8 @@ export interface DashboardViewState { resettableFilterIds: string[]; filters: ViewerFilterState[]; layout: DashboardLayoutView; + /** The effective header style, including any session-local View-mode choice. */ + style: DashboardStyle; /** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */ activeFilterCount: number; running: boolean; @@ -332,6 +339,9 @@ export interface DashboardViewerSession { * `documentRef` entirely. A fresh session (reload/new viewer) always starts * at 'tiles'. */ setGridRenderMode(mode: GridRenderMode): void; + /** Select a complete session-local Dashboard style. Unlike `syncDocument`, + * this never changes the source document or re-runs tile queries. */ + setDashboardStyle(style: DashboardStyle): void; /** Cancel all work and turn every later entry point into a no-op. */ destroy(): void; } @@ -489,6 +499,9 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // outside `documentRef` — never read/written by any command, never // persisted. A fresh session always starts at 'tiles'. let gridRenderMode: GridRenderMode = 'tiles'; + // View mode may preview any header style without authoring a layout. `null` + // means render the document's authored style (plus the legacy Full override). + let dashboardStyleOverride: DashboardStyle | null = null; let tileSearch = ''; // #335: the single wall-clock snapshot the LATEST execution wave resolved its // relative tokens against (published as `state.waveWallNowMs`). `null` until @@ -905,16 +918,31 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa // primary with a valid flow@1 fallback still resolves to the flow plugin // here, exactly as before #291 (`computeFlowLayout`'s own fallback // handling, untouched) — flow behavior stays bit-identical. - const plugin = resolveLayoutPluginSync(documentRef.layout); + const selectedStyle: DashboardStyle = dashboardStyleOverride + ?? (documentRef.layout.type === 'grafana-grid' + ? (gridRenderMode === 'full' ? 'full' : 'grafana-grid') + : (documentRef.layout.preset as DashboardStyle)); + const layoutDocument = dashboardStyleOverride === null + ? documentRef.layout + : selectedStyle === 'grafana-grid' || selectedStyle === 'full' + ? (documentRef.layout.type === 'grafana-grid' + ? documentRef.layout + : { type: 'grafana-grid', version: 1, items: {} }) + : { + type: 'flow', version: 1, preset: selectedStyle, + items: documentRef.layout.type === 'flow' ? documentRef.layout.items : {}, + }; + const renderMode: GridRenderMode = selectedStyle === 'full' ? 'full' : 'tiles'; + const plugin = resolveLayoutPluginSync(layoutDocument); const layout: DashboardLayoutView = plugin.type === 'grafana-grid' ? { engine: 'grafana-grid', grid: computeGrafanaGridLayout({ - tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), renderMode: gridRenderMode, + tiles: visible, layout: layoutDocument, containerWidth: deps.containerWidth?.(), renderMode, }), - renderMode: gridRenderMode, + renderMode, } - : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) }; + : { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: layoutDocument, mobile }) }; return { tiles: visibleRuntimes.map((runtime) => ({ ...runtime.state })), totalTileCount: tiles.length, @@ -927,6 +955,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa }).map((filter) => filter.def.id), filters: filters.map((filter) => ({ ...filter.state })), layout, + style: selectedStyle, activeFilterCount: filters.filter((filter) => filter.state.active).length, running, updatedAt, diagnostics: presentationDiagnostics, // #189: construction-time selection-resolution diagnostics are PERSISTENT @@ -1759,6 +1788,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa publish(); } + function setDashboardStyle(style: DashboardStyle): void { + if (destroyed || dashboardStyleOverride === style) return; + dashboardStyleOverride = style; + publish(); + } + function destroy(): void { destroyed = true; for (const runtime of tiles) { @@ -1781,6 +1816,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa state: stateSignal as ReadonlySignal, controls, timeRangeGroups, getFilterField, start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters, resetFilters, - setTileSearch, cancelTile, syncDocument, setGridRenderMode, destroy, + setTileSearch, cancelTile, syncDocument, setGridRenderMode, setDashboardStyle, destroy, }; } diff --git a/src/styles.css b/src/styles.css index d23f36e..ad6bf96 100644 --- a/src/styles.css +++ b/src/styles.css @@ -434,6 +434,8 @@ body { } .fm-item:hover { background: var(--bg-hover); } .fm-icon { display: flex; width: 15px; justify-content: center; color: var(--fg-mute); flex-shrink: 0; } +.fm-leading { display: inline-flex; flex-shrink: 0; } +.fm-trailing { display: inline-flex; flex-shrink: 0; margin-left: auto; } .fm-label { flex: 1; white-space: nowrap; } .fm-meta { font-size: 10px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; } .fm-sep { height: 1px; background: var(--border-faint); margin: 5px 0; } @@ -759,6 +761,12 @@ body { } .shortcut-keys { display: inline-flex; align-items: center; gap: 4px; } .shortcut-then { color: var(--fg-faint); font-size: 11px; } +.shortcut-plus { color: var(--fg-faint); font-size: 11px; } +.dash-style-key { + font-family: var(--mono); font-size: 11px; padding: 2px 6px; + background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); + white-space: nowrap; +} .modal-card .close-row { margin-top: 12px; display: flex; justify-content: flex-end; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 3125b66..33ab6ea 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -63,7 +63,7 @@ import { createDashboardChartInteractionController } from './dashboard-chart-int import type { DashboardChartInteractionController } from './dashboard-chart-interaction.js'; import { createDashboardViewerSession } from '../dashboard/application/dashboard-viewer-session.js'; import type { - DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState, + DashboardViewerSession, DashboardViewState, DashboardStyle, ViewerTileState, ViewerFilterState, } from '../dashboard/application/dashboard-viewer-session.js'; import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js'; import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js'; @@ -226,10 +226,14 @@ export function disposeDashboardSurface(): void { /** Build the Dashboard style picker with the same trigger and dropdown * vocabulary as File. The trigger shows only the active style; `sync()` * reflects session changes without adding a second header label. */ -type LayoutOption = [value: string, label: string, title?: string]; +type LayoutOption = [value: DashboardStyle, label: string, title: string, shortcut: string]; +function dashboardStyleKeyCaps(shortcut: string): HTMLElement { + const key = shortcut.toUpperCase(); + return h('kbd', { class: 'dash-style-key' }, `G + ${key}`); +} function buildLayoutMenu( doc: Document, onKeyboardOwnerChange: (owner: App['keyboardOwner']) => void, - options: LayoutOption[], getActive: () => string, onPick: (value: string) => void, ariaLabel: string, + options: LayoutOption[], getActive: () => string, onPick: (value: DashboardStyle) => void, ariaLabel: string, ): { el: HTMLButtonElement; sync: () => void } { const label = h('span'); const el = h('button', { @@ -251,10 +255,10 @@ function buildLayoutMenu( document: doc, trigger: el, menuClass: 'dash-file-menu dash-style-menu', - rows: options.map(([value, optionLabel]) => ({ + rows: options.map(([value, optionLabel, _title, shortcut]) => ({ kind: 'item', label: optionLabel, - meta: value === active ? 'Current' : null, + trailing: dashboardStyleKeyCaps(shortcut), extraClass: 'dash-style-item', onClick: () => onPick(value), })), @@ -539,11 +543,6 @@ export async function renderDashboard(app: DashboardApp): Promise { recordBoundParams: (bp) => app.params.recordBoundParams(bp), initialFilters: initialBag, }); - // The global shortcut reaches this route-local port only while its renderer - // generation is current. It is cleared by both Dashboard cleanup and every - // application surface transition. - const commandPort = { surface: 'dashboard' as const, generation: surfaceGeneration, refresh: () => session.refresh() }; - app.surfaceCommands = commandPort; let trackedSessionTileIds = new Set(viewerDoc.tiles.map((tile) => tile.id)); const syncSessionDocument = (next: DashboardDocumentV1): void => { session.syncDocument(next); @@ -577,29 +576,19 @@ export async function renderDashboard(app: DashboardApp): Promise { // but the render-mode toggle is harmless to expose read-only since it never // persists anything. const EDITABLE_LAYOUT_OPTIONS: LayoutOption[] = [ - ['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'], - ['full', 'Full view', 'Temporary full-width view — tile widths are not saved'], - ['report', 'Report', 'One centered, taller tile per row'], - ['columns-2', '2 columns', 'Arrange tiles in two columns'], - ['columns-3', '3 columns', 'Arrange tiles in three columns'], - ]; - const READONLY_LAYOUT_OPTIONS: LayoutOption[] = [ - ['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'], - ['full', 'Full view', 'Temporary full-width view — tile widths are not saved'], + ['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights', 'G'], + ['full', 'Full view', 'Temporary full-width view — tile widths are not saved', 'F'], + ['report', 'Report', 'One centered, taller tile per row', 'R'], + ['columns-2', '2 columns', 'Arrange tiles in two columns', '2'], + ['columns-3', '3 columns', 'Arrange tiles in three columns', '3'], ]; const getActiveLayoutOption = (): string => (currentDoc.layout.type === 'grafana-grid' ? (gridRenderMode === 'full' ? 'full' : 'grafana-grid') : typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'report'); - const layoutMenu = buildLayoutMenu( - doc, keyboardOwnerChannel(app), - readOnly ? READONLY_LAYOUT_OPTIONS : EDITABLE_LAYOUT_OPTIONS, - getActiveLayoutOption, - (value) => { - // #321 read-only: the reduced menu offers ONLY 'grafana-grid'/'full' - // — either choice is ONLY ever the transient render-mode override, NEVER - // a command / persistence. + let layoutMenu: { el: HTMLButtonElement; sync: () => void }; + const selectLayout = (value: DashboardStyle): void => { if (readOnly) { - session.setGridRenderMode(value === 'full' ? 'full' : 'tiles'); + session.setDashboardStyle(value as DashboardStyle); layoutMenu.sync(); return; } @@ -636,18 +625,25 @@ export async function renderDashboard(app: DashboardApp): Promise { runCommand({ type: 'change-layout', layout: { ...currentDoc.layout, preset: value as FlowPresetV1 } }); } layoutMenu.sync(); - }, + }; + layoutMenu = buildLayoutMenu( + doc, keyboardOwnerChannel(app), + EDITABLE_LAYOUT_OPTIONS, + () => readOnly ? session.state.value.style : getActiveLayoutOption(), + selectLayout, 'Dashboard style', ); + // The global shortcut reaches this route-local port only while its renderer + // generation is current. It is cleared by both Dashboard cleanup and every + // application surface transition. + const commandPort = { + surface: 'dashboard' as const, + generation: surfaceGeneration, + refresh: () => session.refresh(), + setDashboardStyle: selectLayout, + }; + app.surfaceCommands = commandPort; const layoutWrap = h('div', { class: 'dash-layout-wrap' }, layoutMenu.el); - // #321 BLOCKER fix: the reduced read-only menu (Grid Tiles / Full view) - // is a grafana-grid-only render-mode toggle — expose it read-only ONLY when - // the persisted doc is grafana-grid. A read-only FLOW doc (report/columns-2/ - // columns-3 — any pre-#321 shared doc) must hide the menu entirely: no - // engine switch is possible read-only, so this is decided once at build - // time from the static `currentDoc.layout.type`. Editable mode is unchanged - // (always the full menu). - const showLayoutSelect = !readOnly || currentDoc.layout.type === 'grafana-grid'; // Dashboard keeps the shared header's File word and placement. View exposes // the safe Export row only; edit additionally exposes Import. @@ -2178,13 +2174,12 @@ export async function renderDashboard(app: DashboardApp): Promise { ); if (sview.layout.engine !== lastEngineRendered) { lastLayoutSig = ''; lastGridSig = ''; lastEngineRendered = sview.layout.engine; } activeEngine = sview.layout.engine; - // #321: keep the local render-mode mirror current from the published - // grafana-grid layout view — the ONLY place this session-owned, transient - // state is read back into the UI. A change re-syncs the style menu, flips - // the grid host's `is-full` class (the CSS vertical-resize-cursor hook), - // and updates every built resize handle's accessible label. - if (sview.layout.engine === 'grafana-grid' && sview.layout.renderMode !== gridRenderMode) { - gridRenderMode = sview.layout.renderMode; + // Keep the local render-mode mirror current from the published session + // layout. View mode can now project flow styles too, so leaving a Full + // grid must also clear the grid host's vertical-only resize affordance. + const nextGridRenderMode = sview.layout.engine === 'grafana-grid' ? sview.layout.renderMode : 'tiles'; + if (nextGridRenderMode !== gridRenderMode) { + gridRenderMode = nextGridRenderMode; layoutMenu.sync(); grid.classList.toggle('is-full', gridRenderMode === 'full'); for (const tileEl of tileEls.values()) applyResizeHandleMode(tileEl, gridRenderMode === 'full'); @@ -2198,7 +2193,7 @@ export async function renderDashboard(app: DashboardApp): Promise { }); const primaryToolbar = h('div', { class: 'dash-toolbar dash-toolbar-primary' }, - showLayoutSelect ? layoutWrap : null, + layoutWrap, tileCount, tileSearch, timeFilterHost, diff --git a/src/ui/menu.ts b/src/ui/menu.ts index 3406a21..d6c5c21 100644 --- a/src/ui/menu.ts +++ b/src/ui/menu.ts @@ -37,7 +37,7 @@ import type { KeyboardOwner } from './app.types.js'; * focus target is the row's own first focusable descendant (an ``/ * `