feat(viewer): full-screen toggle beside the close button, and a System default (#450)

Every viewer gets a button before the close button that switches between
Centered (today's framed dialog) and Full screen (the whole browser window,
edge to edge); it names the view it switches to. In full screen the details
stay toggleable. System > Appearance gains a Viewer surface with "Open viewer
in: Centered / Full screen" and the moved "Collapse viewer details by
default"; the choice is a browser display preference (viewerOpenMode). The
button changes only the current view and hides at 700px and below, where the
viewer is already full-bleed. Recorded as D156.
This commit is contained in:
Vito Sansevero
2026-10-28 18:50:32 -07:00
parent fc236291a2
commit eac4ac7ea2
16 changed files with 317 additions and 60 deletions
+7 -1
View File
@@ -555,7 +555,8 @@ test.describe('full inspector coarse pointer',()=>{
}
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
// #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×.
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) {
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
@@ -618,6 +619,11 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
const production=await page.evaluate(VIEWER_SHELL);
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
// #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none.
const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode'));
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
production.paths=production.paths.filter(path=>!modePaths.includes(path));
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
const appended=production.toolbar.slice(9);
+7 -6
View File
@@ -472,9 +472,9 @@ function measureAppearance(mainSelector) {
}
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs).
const ADDED_APPEARANCE_ROWS = [
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
];
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
@@ -485,10 +485,11 @@ function mapAppearanceConcept(reference, settings) {
control: { ...toggleRow.control, id, checked } }));
return {
...reference,
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
// D97: two added toggles and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
// D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails",
// README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)],
// D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] },
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
@@ -535,7 +536,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => {
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
.toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12',
'rows.length', 'surface.tree'].sort());
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
+2 -1
View File
@@ -287,7 +287,8 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
await page.goto(`${workspace}#/system/appearance`);
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
// #450 D156: the Viewer surface sits between Browsing & display and Thumbnails.
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2);
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
const format = surface.locator('#thumbnail-format');
@@ -0,0 +1,141 @@
// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to
// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance ›
// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only.
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
import { parityPng } from './local-inspect-files.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode');
const details = page => viewer(page).locator('#viewer-details');
test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); });
async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) {
await page.setViewportSize({ width, height });
await openProduction(page, 'images', theme);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
}
/** The dialog's box and edges against the window. */
const frame = page => viewer(page).evaluate(node => {
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right),
bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius,
border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 };
});
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
const centered = await frame(page);
expect(centered.left).toBeGreaterThan(0);
expect(centered.fills).toBe(false);
// Keyboard: the button is reachable and keeps focus after a switch.
await viewer(page).locator('#viewer-close').focus();
await page.keyboard.press('Shift+Tab');
await expect(modeButton(page)).toBeFocused();
await page.keyboard.press('Enter');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toBeFocused();
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true });
// The details stay toggleable in full screen.
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(details(page)).toBeHidden();
await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click();
await expect(details(page)).toBeVisible();
// Back to Centered: today's dialog, with no toggle while the collapse setting is off.
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await frame(page)).toEqual(centered);
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
// Navigation keeps the current view; Esc closes from full screen; the next open is Centered again.
await modeButton(page).click();
await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toBeVisible();
await expect(details(page)).toBeHidden();
await modeButton(page).click();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible();
await modeButton(page).click();
await expect(details(page)).toBeHidden();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false');
});
test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'system/appearance', 'dark');
const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) });
await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']);
const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true });
await expect(select).toHaveValue('centered');
await select.selectOption('fullscreen');
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen');
await page.evaluate(() => { location.hash = '#/images'; });
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen');
});
test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true })));
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'inspect', 'dark');
await page.locator('#local-file').setInputFiles(parityPng('f02.png'));
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(details(page)).toBeVisible();
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible();
});
test('the Library gallery viewer has the button too', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark');
await page.locator('.media-card .image-open').first().click();
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
});
test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => {
await openImages(page, { width: 390, height: 844 });
await expect(viewer(page).locator('#viewer-mode')).toBeHidden();
await expect(viewer(page).locator('#viewer-close')).toBeVisible();
});
test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => {
await openImages(page);
await modeButton(page).click();
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
const zoomed = await frame(page);
expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 });
await page.evaluate(() => { document.documentElement.style.zoom = '1'; });
if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) {
const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]);
expect(Math.min(...size)).toBeGreaterThanOrEqual(40);
}
test.info().annotations.push({ type: 'engine', description: browserName });
});
+1 -1
View File
@@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */,
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],