Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,39 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
issue's ~3 KB estimate, still a low single-digit percent of the bundle and
no new network requests).

### Fixed
- **Dashboard KPI bands now render as one horizontal wrapping row in flow
layouts** (#331). The flow renderer emitted each consecutive-KPI band member
as `.dash-kpi-member`, but the `display:contents` flattening rule targeted a
never-emitted `.dash-kpi-source` class, so members stayed block-level and the
KPI cards stacked vertically instead of sharing the band's `flex-wrap` stream.
The class contract is unified on `.dash-kpi-member` across renderer, CSS, and
tests; Report / 2-column / 3-column bands are now a single horizontal card
stream in canonical tile order. Grid Tiles (#321) and frameless (#316) KPI
behavior are unchanged.
- **Dashboard File menu now shares the Workbench File-menu primitive** (#331).
A new data-driven `src/ui/menu.ts` (`openMenu`) owns the shared dropdown
structure and interaction grammar — icon/label/metadata columns, section
headings, separators, overlay/outside-click dismiss, Escape + focus-restore,
ArrowUp/Down roving focus, `aria-haspopup`/`aria-expanded`, and anchored
placement — and both the Workbench (`file-menu.ts`) and Dashboard
(`buildDashboardFileMenu`) menus are rebuilt on it, ending the duplicated
builders. The Dashboard File trigger now uses the same downward-chevron
treatment as Workbench (no more right-arrow that misread as navigation) and
its menu gains EXPORT/IMPORT/OPEN section headings, icons, and `.json`
metadata; read-only Dashboards still expose Export only. Actions and
permission rules are unchanged.
- **Dashboard tile content stays inside the tile body, above the footer**
(#331). `.dash-tile-body` gained `overflow:hidden`, and the tall-panel
containment contract (`flex:1 1 auto; min-width/min-height:0; max-height:100%;
overflow:auto`) now covers Markdown/text (`.md-view`) and the diagnostic
`.panel-with-note` wrapper alongside tables and logs, so long content scrolls
inside the body instead of painting over the footer or neighbouring tiles
(both flow and Grid Tiles engines, which share the tile shell). A metaless
panel (e.g. a Text tile that never runs a query) now hides its footer rather
than reserving an empty footer line, reasserted before the unchanged-rows
repaint short-circuit so a sibling tile's refresh can't leave it stale.

## [0.6.0] - 2026-07-18

### Added
Expand Down
25 changes: 18 additions & 7 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2426,15 +2426,26 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
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 { flex: 1; min-height: 0; padding: 6px 8px; display: flex; overflow: hidden; }
.dash-tile-body > .chart-view,
.dash-tile-body > .kpi-panel { flex: 1; min-width: 0; }
/* Table/logs tiles (#149 D9): explicit flex constraints so content scrolls
inside the tile instead of clipping; the workbench grid's height:100% is
overridden back to auto (flex sizes it here). */
/* Table/logs/markdown-text tiles (#149 D9, #331): explicit flex + overflow
containment so tall content scrolls inside the tile body instead of
growing past it and painting over the footer/other tiles; the workbench
grid's height:100% is overridden back to auto (flex sizes it here). */
.dash-tile-body > .res-table-wrap,
.dash-tile-body > .dash-logs { flex: 1; min-width: 0; min-height: 0; }
.dash-tile-body > .dash-logs,
.dash-tile-body > .md-view {
flex: 1 1 auto; min-width: 0; min-height: 0; max-height: 100%; overflow: auto;
}
.dash-tile-body > .res-table-wrap { height: auto; }
/* #331: a diagnostic wrapper (schema-mismatch/staleness note + the actual
panel root) may be the direct child instead — same containment contract,
plus a column flex so the wrapped panel root can still scroll within it. */
.dash-tile-body > .panel-with-note {
flex: 1 1 auto; min-width: 0; min-height: 0; max-height: 100%; overflow: hidden;
display: flex; flex-direction: column;
}
/* Logs view (#149 D9): a compact, scroll-only reading surface — level colors
via the --log-* theme vars, monospace, wrapped messages. */
.dash-logs {
Expand Down Expand Up @@ -2508,9 +2519,9 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
.dash-kpi-stream .kpi-card {
flex: 0 1 auto; inline-size: fit-content; min-inline-size: 160px; max-inline-size: 320px;
}
/* A source's stable host contributes its children (cards, or a state card)
/* A member's stable host contributes its children (cards, or a state card)
directly to the stream's flex-wrap — it is never itself a visible box. */
.dash-kpi-source { display: contents; }
.dash-kpi-member { display: contents; }
.dash-kpi-state-card {
--kpi-accent: var(--accent);
min-width: 160px; max-width: 320px; padding: 14px 16px;
Expand Down
2 changes: 0 additions & 2 deletions src/ui/app.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ export interface SchemaFocus {
export interface AppDom {
fileBtn?: HTMLElement;
fileDialog?: HTMLElement;
fileMenu?: HTMLElement;
fileMenuOverlay?: HTMLElement;
libraryTitle?: HTMLElement;
/** #302 — the Workbench header "Dashboard →" nav control (shown only when the
* current workspace has a Dashboard). */
Expand Down
107 changes: 60 additions & 47 deletions src/ui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
// is injected on the `app` controller.

import { effect } from '@preact/signals-core';
import { h, fixedAnchor } from './dom.js';
import { h } from './dom.js';
import { Icon as IconUntyped } from './icons.js';
import { openMenu } from './menu.js';
import type { MenuHandle, MenuRow } from './menu.js';
import { renderResolvedPanel } from './panels.js';
import { resolvePanel } from '../core/panel-cfg.js';
import type { Column } from '../core/panel-cfg.js';
Expand Down Expand Up @@ -87,6 +89,10 @@ const Icon: {
moon(): SVGElement;
arrow(): SVGElement;
trash(): SVGElement;
chevDown(): SVGElement;
download(): SVGElement;
upload(): SVGElement;
eye(): SVGElement;
} = IconUntyped;

const formatRows: (n: number | null | undefined) => string = formatRowsUntyped;
Expand Down Expand Up @@ -225,65 +231,63 @@ function renderDashboardNotFound(app: DashboardApp): void {
back)));
}

/** #302 — the standalone Dashboard header's own "File" menu: a keyboard- and
* screen-reader-accessible dropdown owning Dashboard-scoped operations. Edit
* mode offers Export / Import / Open for viewing; a read-only (detached) view
* offers Export only (import + re-preview are edit-context operations). Every
* item delegates to an `app.actions.*` seam (dashboard.ts never reaches into
* app.ts). Esc/outside-click close and restore focus; arrows move between
* items. */
/** #302/#331 — the standalone Dashboard header's own "File" menu: a keyboard-
* and screen-reader-accessible dropdown owning Dashboard-scoped operations,
* built on the shared `openMenu` primitive (menu.ts) — the same structure +
* interaction grammar (icons, `.fm-section` headings, Esc/outside-click
* close + focus-restore, ArrowUp/ArrowDown roving focus) as the Workbench
* File menu, with Dashboard-specific CONTENTS:
* EXPORT ⭳ Export Dashboard… .json
* IMPORT ⭱ Import Dashboard…
* OPEN ◇ Open for viewing…
* Edit mode offers all three sections; a read-only (detached) view offers
* EXPORT only (import + re-preview are edit-context operations). Every item
* delegates to an `app.actions.*` seam (dashboard.ts never reaches into
* app.ts). The trigger uses the shared downward-chevron treatment
* (`Icon.chevDown()`, matching the Workbench File button) rather than a
* right-pointing arrow, which would misread as navigation. The trigger owns
* its own open/close TOGGLE (unlike the Workbench menu, which only ever
* opens) — clicking it again while open closes the menu and restores focus,
* tracked here via the returned `MenuHandle` rather than a second
* `openMenu` call. */
function buildDashboardFileMenu(app: DashboardApp, readOnly: boolean): HTMLElement {
const doc = app.document;
const btn = h('button', {
class: 'dash-btn dash-file-btn', 'aria-haspopup': 'menu', 'aria-expanded': 'false',
title: 'File — dashboard import/export', 'aria-label': 'Dashboard File menu',
}, h('span', null, 'File'), Icon.arrow()) as HTMLButtonElement;
let menu: HTMLElement | null = null;
let overlay: HTMLElement | null = null;
}, h('span', null, 'File'), Icon.chevDown()) as HTMLButtonElement;

const close = (): void => {
doc.removeEventListener('keydown', onKey, true);
menu?.remove(); overlay?.remove();
menu = null; overlay = null;
btn.setAttribute('aria-expanded', 'false');
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (!menu) return;
const items = Array.from(menu.querySelectorAll<HTMLButtonElement>('.dash-fm-item'));
const at = items.indexOf(doc.activeElement as HTMLButtonElement);
if (e.key === 'ArrowDown') { e.preventDefault(); items[(at + 1) % items.length]?.focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(at - 1 + items.length) % items.length]?.focus(); }
};

const item = (label: string, onClick: () => void): HTMLButtonElement => h('button', {
class: 'fm-item dash-fm-item', role: 'menuitem',
onclick: () => { close(); onClick(); },
}, h('span', { class: 'fm-label' }, label)) as HTMLButtonElement;
let handle: MenuHandle | null = null;

const open = (): void => {
overlay = h('div', { class: 'fm-overlay', onclick: close });
const items = [item('Export Dashboard…', () => app.actions.exportDashboard())];
const rows: MenuRow[] = [
{ kind: 'section', label: 'Export' },
{
kind: 'item', icon: Icon.download(), label: 'Export Dashboard…', meta: '.json', extraClass: 'dash-fm-item',
onClick: () => app.actions.exportDashboard(),
},
];
if (!readOnly) {
items.push(
item('Import Dashboard…', () => app.actions.importDashboard()),
item('Open for viewing…', () => app.actions.openDashboardForViewing()),
rows.push(
{ kind: 'section', label: 'Import' },
{
kind: 'item', icon: Icon.upload(), label: 'Import Dashboard…', extraClass: 'dash-fm-item',
onClick: () => app.actions.importDashboard(),
},
{ kind: 'section', label: 'Open' },
{
kind: 'item', icon: Icon.eye(), label: 'Open for viewing…', extraClass: 'dash-fm-item',
onClick: () => app.actions.openDashboardForViewing(),
},
);
}
menu = h('div', { class: 'file-menu dash-file-menu', role: 'menu' }, ...items);
doc.body.appendChild(overlay);
doc.body.appendChild(menu);
const r = btn.getBoundingClientRect();
const a = fixedAnchor(r) as { top: number; left: number };
menu.style.position = 'fixed';
menu.style.top = a.top + 'px';
menu.style.left = a.left + 'px';
btn.setAttribute('aria-expanded', 'true');
doc.addEventListener('keydown', onKey, true);
items[0].focus();
handle = openMenu({
document: doc, trigger: btn, rows, menuClass: 'dash-file-menu',
onClose: () => { handle = null; },
});
};

btn.onclick = () => { if (menu) { close(); btn.focus(); } else open(); };
btn.onclick = () => { if (handle) { handle.close(); btn.focus(); } else open(); };
return btn;
}

Expand Down Expand Up @@ -778,6 +782,15 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// Paint an ordinary (non-KPI) tile's result once per new result. Only ever
// called for a 'ready' tile, so columns/rows/meta/panel are all present.
function paintPanel(ts: ViewerTileState, tileEl: TileEl): void {
// #331: reasserted BEFORE the unchanged-rows early return below — a
// republish that repaints a DIFFERENT tile (e.g. a sibling's query
// finishing) still runs this function for every ready tile with the
// SAME `ts.rows` reference this tile painted last time, which would
// otherwise skip past the meta check entirely and leave whatever
// `reconcileGridTile`'s unconditional `foot.hidden = ts.isKpi` (#316)
// last wrote in place — stale-visible for a metaless tile once any
// other tile's data arrives.
tileEl.foot.hidden = !ts.meta;
if (ts.rows === tileEl.paintedRows) return;
destroyChart(tileEl);
const panel = (ts.panel || {}) as Record<string, unknown>;
Expand Down
Loading
Loading