diff --git a/CHANGELOG.md b/CHANGELOG.md
index 484746ca..ade20b7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,16 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
used `--zoom: 1` already; this only removes the now-dead compensation code.
### Added
+- **The Dashboard now has a compact mobile presentation at the canonical
+ 768px breakpoint** (#248). Its sticky header stays on one line with an
+ icon-only back link and Refresh action, an ellipsized Library title, and the
+ theme action; secondary favorite/source/update metadata and the desktop
+ layout selector are hidden. Every saved desktop layout is visually
+ overridden to one normal-height full-width column without changing its
+ persisted preference. Dashboard filters stay in one horizontally scrollable
+ row with fixed-position combobox popovers, while a Dashboard with no filters
+ omits the now-empty mobile toolbar entirely. Widening the viewport restores
+ the saved desktop layout and full controls automatically.
- **Favorited saved queries can now act as Dashboard Filter sources** (#160).
One explicit read-only query returns exactly one row containing any number of
`Array(T)`, `Array(Tuple(value T, label L))`, or `Map(K,V)` helpers. Exact
diff --git a/src/styles.css b/src/styles.css
index fd0b16f4..3e63a0ae 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2185,7 +2185,6 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
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 centered column (1100px) with taller tiles. The
.is-report/.is-wide selectors outweigh the responsive `.dash-grid` rules
above (class+class > single class), so both stay one column at every width —
@@ -2311,13 +2310,55 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
.dash-kpi-state-card[role='alert'] .dash-kpi-state-message { color: var(--error-fg); }
.dash-kpi-warnings { display: grid; gap: 4px; }
.dash-kpi-warning { color: var(--fg-mute); font-size: 12px; }
+/* The canonical app mobile breakpoint: keep only the primary Dashboard
+ actions in one header row, visually normalize every saved desktop layout,
+ and let the shared filter controls scroll horizontally without clipping
+ their fixed-position popovers. These are CSS-only overrides, so widening
+ restores the persisted desktop layout without writing preferences. */
+@media (max-width: 768px) {
+ .dash-header {
+ flex-wrap: nowrap; gap: 8px; padding: 8px 10px;
+ }
+ .dash-back {
+ width: 30px; height: 30px; padding: 0; justify-content: center; flex-shrink: 0;
+ }
+ .dash-back-label,
+ .dash-fav,
+ .dash-skip,
+ .dash-src,
+ .dash-updated,
+ .dash-spacer,
+ .dash-refresh-label,
+ .dash-layout-wrap { display: none; }
+ .dash-title {
+ flex: 1; min-width: 0; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap;
+ }
+ .dash-icobtn,
+ .dash-refresh { width: 30px; height: 30px; flex-shrink: 0; }
+ .dash-refresh { padding: 0; justify-content: center; }
+
+ .dash-toolbar { flex-wrap: nowrap; padding: 6px 10px; }
+ .dash-toolbar:not(.has-filters) { display: none; }
+ .dash-filter-host { width: 100%; min-width: 0; overflow: hidden; }
+ .dash-filters {
+ width: 100%; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
+ scrollbar-width: none; overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ }
+ .dash-filters::-webkit-scrollbar { display: none; }
+ .dash-filters > .var-field { flex-shrink: 0; }
+
+ .dash-grid,
+ .dash-grid.is-wide,
+ .dash-grid.is-report {
+ grid-template-columns: 1fr; max-width: none; width: 100%; margin: 0; padding: 12px;
+ }
+ .dash-grid.is-report .dash-tile { min-height: 300px; }
+}
@media (max-width: 520px) {
.dash-kpi-stream .kpi-card, .dash-kpi-state-card {
flex-basis: 100%; min-inline-size: 0; max-inline-size: none; inline-size: 100%;
min-width: 0; max-width: none; width: 100%;
}
}
-@media (max-width: 640px) {
- .dash-grid { grid-template-columns: 1fr; padding: 12px; }
- .dash-header { padding: 10px 12px; gap: 8px; }
-}
diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js
index 34b9750b..cb13810d 100644
--- a/src/ui/dashboard.js
+++ b/src/ui/dashboard.js
@@ -473,18 +473,23 @@ export function renderDashboard(app) {
h('span', null, favorites.length + (favorites.length === 1 ? ' favorite' : ' favorites')));
const skipNote = h('span', { class: 'dash-skip', style: { display: 'none' } });
const updated = h('span', { class: 'dash-updated' });
- const refreshBtn = h('button', { class: 'dash-btn', title: 'Re-run all tiles' },
- Icon.refresh(), h('span', null, 'Refresh'));
+ const refreshBtn = h('button', {
+ class: 'dash-btn dash-refresh', title: 'Re-run all tiles', 'aria-label': 'Refresh dashboard',
+ }, Icon.refresh(), h('span', { class: 'dash-refresh-label' }, 'Refresh'));
// Theme toggle, mirroring the workbench header: reuse app.toggleTheme (persists
// the pref + flips data-theme), and register the button as app.dom.themeBtn so
// that helper repaints its icon on toggle.
- const themeBtn = h('button', { class: 'dash-icobtn', title: 'Toggle theme', onclick: () => app.toggleTheme() });
+ const themeBtn = h('button', {
+ class: 'dash-icobtn', title: 'Toggle theme', 'aria-label': 'Toggle theme', onclick: () => app.toggleTheme(),
+ });
themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon());
app.dom.themeBtn = themeBtn;
const header = h('div', { class: 'dash-header' },
- h('a', { class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser' },
- Icon.arrow(), h('span', null, 'SQL Browser')),
+ h('a', {
+ class: 'dash-back', href: app.basePath || '/sql', title: 'Back to SQL Browser',
+ 'aria-label': 'Back to SQL Browser',
+ }, Icon.arrow(), h('span', { class: 'dash-back-label' }, 'SQL Browser')),
h('div', { class: 'dash-title' }, state.libraryName.value),
favChip,
skipNote,
@@ -550,7 +555,9 @@ export function renderDashboard(app) {
// The toolbar is flex-start (default), so layoutWrap + filterBar pack left as
// the issue specifies — no trailing spacer needed now the right-aligned
// Columns control is gone (#184).
- const toolbar = h('div', { class: 'dash-toolbar' }, layoutWrap, filterHost);
+ const toolbar = h('div', {
+ class: 'dash-toolbar' + (controls.length ? ' has-filters' : ''),
+ }, layoutWrap, filterHost);
apply();
// #root is a fixed, overflow:hidden flex column (the workbench layout), so the
diff --git a/tests/e2e/dashboard-mobile.html b/tests/e2e/dashboard-mobile.html
new file mode 100644
index 00000000..bfd384f3
--- /dev/null
+++ b/tests/e2e/dashboard-mobile.html
@@ -0,0 +1,96 @@
+
+
+
+
+ Dashboard mobile harness
+
+
+
+
+
+
+
diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js
new file mode 100644
index 00000000..a2ac62c6
--- /dev/null
+++ b/tests/e2e/dashboard-mobile.spec.js
@@ -0,0 +1,154 @@
+import { test, expect } from '@playwright/test';
+
+async function openAt(page, width, height = 844) {
+ await page.setViewportSize({ width, height });
+ await page.goto('/tests/e2e/dashboard-mobile.html');
+ await page.waitForFunction(() => window.__ready === true);
+}
+
+test.describe('Dashboard mobile layout', () => {
+ test('keeps the accessible header on one line and truncates its title at phone widths', async ({ page }) => {
+ await openAt(page, 390);
+ const header = page.locator('.dash-header');
+ const title = page.locator('.dash-title');
+
+ await expect(page.getByRole('link', { name: 'Back to SQL Browser' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Refresh dashboard' })).toBeVisible();
+ await expect(page.locator('.dash-back-label')).toBeHidden();
+ await expect(page.locator('.dash-refresh-label')).toBeHidden();
+ for (const selector of ['.dash-fav', '.dash-skip', '.dash-src', '.dash-updated']) {
+ await expect(page.locator(selector)).toBeHidden();
+ }
+
+ const geometry = await header.evaluate((node) => {
+ const visible = [...node.children].filter((child) => getComputedStyle(child).display !== 'none');
+ const rects = visible.map((child) => child.getBoundingClientRect());
+ const title = node.querySelector('.dash-title');
+ return {
+ header: node.getBoundingClientRect(),
+ centers: rects.map((rect) => rect.top + rect.height / 2),
+ titleClientWidth: title.clientWidth,
+ titleScrollWidth: title.scrollWidth,
+ whiteSpace: getComputedStyle(title).whiteSpace,
+ textOverflow: getComputedStyle(title).textOverflow,
+ pageOverflow: document.documentElement.scrollWidth - innerWidth,
+ };
+ });
+ expect(Math.max(...geometry.centers) - Math.min(...geometry.centers)).toBeLessThan(2);
+ expect(geometry.header.height).toBeLessThanOrEqual(47);
+ expect(geometry.titleClientWidth).toBeLessThan(geometry.titleScrollWidth);
+ expect(geometry.whiteSpace).toBe('nowrap');
+ expect(geometry.textOverflow).toBe('ellipsis');
+ expect(geometry.pageOverflow).toBeLessThanOrEqual(0);
+
+ const refresh = page.getByRole('button', { name: 'Refresh dashboard' });
+ await refresh.click();
+ await expect(refresh).toBeDisabled();
+ await expect(refresh).toBeEnabled();
+ expect(await page.evaluate(() => window.__refreshCount)).toBe(1);
+ });
+
+ test('keeps title and actions reachable without viewport overflow at 360px', async ({ page }) => {
+ await openAt(page, 360, 800);
+ const result = await page.locator('.dash-header').evaluate((header) => {
+ const title = header.querySelector('.dash-title').getBoundingClientRect();
+ const theme = header.querySelector('.dash-icobtn').getBoundingClientRect();
+ const refresh = header.querySelector('.dash-refresh').getBoundingClientRect();
+ return {
+ wraps: Math.abs((title.top + title.height / 2) - (theme.top + theme.height / 2)) > 2
+ || Math.abs((theme.top + theme.height / 2) - (refresh.top + refresh.height / 2)) > 2,
+ titleBeforeActions: title.right <= theme.left,
+ actionsInside: refresh.right <= innerWidth,
+ pageOverflow: document.documentElement.scrollWidth - innerWidth,
+ };
+ });
+ expect(result).toEqual({ wraps: false, titleBeforeActions: true, actionsInside: true, pageOverflow: 0 });
+ });
+
+ test('visually normalizes every saved layout on mobile and restores desktop CSS on resize', async ({ page }) => {
+ await openAt(page, 390);
+ for (const mode of ['wide', 'report', 'columns-2', 'columns-3']) {
+ await page.evaluate((next) => window.__setLayout(next), mode);
+ const layout = await page.locator('.dash-grid').evaluate((grid) => {
+ const tile = grid.querySelector('.dash-tile');
+ const style = getComputedStyle(grid);
+ return {
+ columns: style.gridTemplateColumns.split(' ').length,
+ maxWidth: style.maxWidth,
+ width: grid.getBoundingClientRect().width,
+ availableWidth: grid.closest('.dash-page').clientWidth,
+ tileMinHeight: getComputedStyle(tile).minHeight,
+ prefs: window.__prefs,
+ stored: [localStorage.getItem('asb:dashLayout'), localStorage.getItem('asb:dashCols')],
+ };
+ });
+ expect(layout.columns).toBe(1);
+ expect(layout.maxWidth).toBe('none');
+ expect(layout.width).toBe(layout.availableWidth);
+ expect(layout.tileMinHeight).toBe('300px');
+ expect(layout.prefs).toEqual({ dashLayout: 'report', dashCols: 3 });
+ expect(layout.stored).toEqual(['report', '3']);
+ }
+
+ await page.evaluate(() => window.__setLayout('report'));
+ const applyCount = await page.evaluate(() => window.__layoutApplyCount);
+ await page.setViewportSize({ width: 900, height: 844 });
+ await expect(page.locator('.dash-layout-wrap').first()).toBeVisible();
+ const restored = await page.locator('.dash-grid').evaluate((grid) => ({
+ maxWidth: getComputedStyle(grid).maxWidth,
+ tileMinHeight: getComputedStyle(grid.querySelector('.dash-tile')).minHeight,
+ applyCount: window.__layoutApplyCount,
+ stored: [localStorage.getItem('asb:dashLayout'), localStorage.getItem('asb:dashCols')],
+ }));
+ expect(restored).toEqual({ maxWidth: '1100px', tileMinHeight: '440px', applyCount, stored: ['report', '3'] });
+ });
+
+ test('scrolls filters in one row while fixed combobox content escapes clipping', async ({ page }) => {
+ await openAt(page, 390);
+ const filters = page.locator('.dash-filters');
+ const before = await filters.evaluate((node) => ({
+ clientWidth: node.clientWidth,
+ scrollWidth: node.scrollWidth,
+ fieldWidths: [...node.querySelectorAll('.var-field')].map((field) => field.getBoundingClientRect().width),
+ fieldTops: [...node.querySelectorAll('.var-field')].map((field) => field.getBoundingClientRect().top),
+ overflowX: getComputedStyle(node).overflowX,
+ }));
+ expect(before.scrollWidth).toBeGreaterThan(before.clientWidth);
+ expect(Math.max(...before.fieldTops) - Math.min(...before.fieldTops)).toBeLessThan(2);
+ expect(Math.min(...before.fieldWidths)).toBeGreaterThan(150);
+ expect(before.overflowX).toBe('auto');
+
+ await filters.evaluate((node) => { node.scrollLeft = node.scrollWidth; });
+ expect(await filters.evaluate((node) => node.scrollLeft)).toBeGreaterThan(0);
+ await filters.evaluate((node) => { node.scrollLeft = 0; });
+
+ const first = page.getByRole('combobox', { name: 'region' });
+ await first.focus();
+ await first.press('ArrowDown');
+ const list = page.locator('#var-recent-list-region');
+ await expect(list).toBeVisible();
+ const popover = await list.evaluate((node) => {
+ const input = document.querySelector('[aria-label="region"]');
+ const toolbar = document.querySelector('.dash-toolbar.has-filters');
+ const listRect = node.getBoundingClientRect();
+ const inputRect = input.getBoundingClientRect();
+ return {
+ position: getComputedStyle(node).position,
+ anchored: Math.abs(listRect.left - inputRect.left) < 2 && listRect.top >= inputRect.bottom,
+ escapesToolbar: listRect.bottom > toolbar.getBoundingClientRect().bottom,
+ pageOverflow: document.documentElement.scrollWidth - innerWidth,
+ };
+ });
+ expect(popover).toEqual({ position: 'fixed', anchored: true, escapesToolbar: true, pageOverflow: 0 });
+ await first.press('Enter');
+ await expect(first).toHaveValue('alpha');
+ });
+
+ test('removes an empty toolbar only on mobile', async ({ page }) => {
+ await openAt(page, 390);
+ await expect(page.locator('#no-filter-toolbar')).toBeHidden();
+ await page.setViewportSize({ width: 769, height: 844 });
+ await expect(page.locator('#no-filter-toolbar')).toBeVisible();
+ });
+});
diff --git a/tests/unit/dashboard.test.js b/tests/unit/dashboard.test.js
index ed80e45d..28811a84 100644
--- a/tests/unit/dashboard.test.js
+++ b/tests/unit/dashboard.test.js
@@ -381,9 +381,16 @@ describe('renderDashboard', () => {
];
const app = dashApp(favorites, vi.fn(async () => chartResult()));
await renderDashboard(app);
- expect(app.root.querySelector('.dash-header')).not.toBeNull();
- expect(app.root.querySelector('.dash-back')).not.toBeNull();
+ const header = app.root.querySelector('.dash-header');
+ const back = header.querySelector('.dash-back');
+ const refresh = header.querySelector('.dash-refresh');
+ expect(back.getAttribute('aria-label')).toBe('Back to SQL Browser');
+ expect(back.querySelector('.dash-back-label').textContent).toBe('SQL Browser');
+ expect(refresh.getAttribute('aria-label')).toBe('Refresh dashboard');
+ expect(refresh.querySelector('.dash-refresh-label').textContent).toBe('Refresh');
+ expect(header.querySelector('.dash-icobtn').getAttribute('aria-label')).toBe('Toggle theme');
expect(app.root.querySelector('.dash-fav').textContent).toContain('2 favorites');
+ expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(false);
expect(app.root.querySelectorAll('.dash-tile').length).toBe(2);
expect(app.root.querySelector('.dash-tile canvas')).not.toBeNull();
expect(app.root.querySelector('.dash-tile-foot').textContent).toContain('rows');
@@ -1179,6 +1186,7 @@ describe('renderDashboard — global filter bar (#149 D3)', () => {
const filters = app.root.querySelector('.dash-filters');
expect(filters.style.display).toBe('none');
expect(filters.querySelectorAll('.var-field').length).toBe(0);
+ expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(false);
});
it('a param declared with conflicting types across two favorites renders a plain input with a visible warning (#173 acceptance, review F1)', async () => {
@@ -1223,6 +1231,7 @@ describe('renderDashboard — global filter bar (#149 D3)', () => {
await renderDashboard(app);
const filters = app.root.querySelector('.dash-filters');
expect(filters.style.display).not.toBe('none');
+ expect(app.root.querySelector('.dash-toolbar').classList.contains('has-filters')).toBe(true);
expect([...filters.querySelectorAll('.var-name')].map((n) => n.textContent)).toEqual(['year', 'region']);
expect(fieldInput(app.root, 'year').value).toBe('2024');
});