diff --git a/TESTING.md b/TESTING.md index cac84ebc..5eca5281 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3194,10 +3194,40 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati details by default" (`sidebarCollapsedByDefault`; the viewer opens with `#viewer-details` hidden and a "Show details"/"Hide details" toggle with `aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file - the viewer opens with the details showing and the toggle reading "Hide details"), and the operation "Reset display + the viewer opens with the details showing and the toggle reading "Hide details"; amended by D156: the row moved + to the "Viewer" surface), and the operation "Reset display defaults" ("Restore this browser’s display defaults, then save. Other settings are kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only. The node search timeout shows the server's real value, not the sample 30. CSS under D97. +- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the + shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has + `button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so + the concept's `.dialog-header > button` and D34's coarse 40px rule cover it; 8px from + ✕). It names the view it switches to in its accessible name and `title` ("Full screen + view" / "Centered view") with a `maximize` / `minimize` outline icon, and keeps focus + after a switch. Full screen (`#viewer-dialog.viewer-fullscreen`) fills the browser + window edge to edge: `inset: 0`, no width or height cap, no margin, border or radius + (`.ca-tools #viewer-dialog.viewer-fullscreen[open]`, beating the concept's box, D34's + max-height and the ≤700px height); the layout inside is the concept's. Centered is the + concept's framed dialog, unchanged. In full screen the details stay toggleable: the + D97 "Show details"/"Hide details" toggle is in the toolbar whenever the view is full + screen or "Collapse viewer details by default" is on; going back to Centered without + that setting removes the toggle and shows the details. The view is not the browser's + Fullscreen API (Esc keeps one meaning, closing the viewer; WebKit has no element + fullscreen for a modal dialog; the workspace also runs in ComfyUI popups). The viewer + opens in the view System › Appearance › **Viewer** › "Open viewer in" (Centered, + Full screen) chooses: a browser display preference, `viewerOpenMode` (`centered` by + default, normalized in `gallery-settings.js`) in the shared `gallerySettings`; no + server setting. The button changes only the current view (navigation in the viewer + keeps it; the next open uses the setting). "Viewer" is its own surface after + "Browsing & display" ("How the image viewer opens; the button beside ✕ switches the + current view."), holding "Open viewer in" and the moved "Collapse viewer details by + default"; "Reset display defaults" still restores both. At ≤700px (the concept's + breakpoint, where the viewer is already 96vw × 94dvh) the button is hidden + (`display: none !important` against the concept's `.icon-btn` rule), popup windows + that narrow included; the stored view still applies there. Browser tests: + `tests/browser/workspace-viewer-mode.spec.mjs`; the concept comparisons add the + button to the concept's header at runtime and map the Viewer surface. - **D98** (Sources): each configured source's row has a plain Remove after its scan button; nothing changes until Save. A source added this session reads " · not saved yet" with Remove only until Save; a configured source the server cannot use diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 9d8bd1fe..0320b545 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -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); diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index ccfec488..e78248bc 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -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); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index db75c85a..4ba5abf2 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -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'); diff --git a/tests/browser/workspace-viewer-mode.spec.mjs b/tests/browser/workspace-viewer-mode.spec.mjs new file mode 100644 index 00000000..d974d25f --- /dev/null +++ b/tests/browser/workspace-viewer-mode.spec.mjs @@ -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 }); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 3dd4b920..dff63d85 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -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'], diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index 7f85fd9a..014aa52e 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -76,6 +76,15 @@ test("the workspace keys have their own defaults and validation", () => { assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); }); +// #450 (D156): the viewer's default view, Centered unless Full screen was chosen. +test("the viewer open mode defaults to centered and accepts only its two values", () => { + const { VIEWER_OPEN_MODES } = require("../../web/js/gallery-settings.js"); + assert.deepEqual(VIEWER_OPEN_MODES, ["centered", "fullscreen"]); + assert.equal(DEFAULT_SETTINGS.viewerOpenMode, "centered"); + assert.equal(normalizeGallerySettings({ viewerOpenMode: "fullscreen" }).viewerOpenMode, "fullscreen"); + for (const value of ["full", true, 1, null]) assert.equal(normalizeGallerySettings({ viewerOpenMode: value }).viewerOpenMode, "centered"); +}); + // #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save // or a Reset from the legacy page keeps every other stored key. function legacyGallery(stored, controls, extra = {}) { @@ -109,7 +118,8 @@ const PAGE_OPTION_CONTROLS = () => ({ test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1, - imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" }, + imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list", + viewerOpenMode: "fullscreen" }, PAGE_OPTION_CONTROLS()); manager.saveSettings(); const result = saved(); @@ -120,6 +130,7 @@ test("a legacy gallery Save keeps the workspace keys and every other stored key" assert.equal(result.showImageInfo, false); assert.equal(result.videoMute, false); assert.equal(result.thumbnailPromptDismissed, "sig"); + assert.equal(result.viewerOpenMode, "fullscreen"); // Page options no longer edit the default view (S13 G5), so a stored one stays. assert.equal(result.defaultViewMode, "list"); // The page-only keys come from the dialog. diff --git a/tests/js/helpers/mounted-dom.mjs b/tests/js/helpers/mounted-dom.mjs index 3b8bffc4..1d1530d5 100644 --- a/tests/js/helpers/mounted-dom.mjs +++ b/tests/js/helpers/mounted-dom.mjs @@ -10,6 +10,7 @@ export function mountedDom() { // As in the DOM, a string argument becomes a text node. static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} + insertBefore(node,before){node.parent=this;const index=this.children.indexOf(before);this.children.splice(index<0?this.children.length:index,0,node);return node;} prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 628ccdd7..d9370950 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -188,6 +188,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi } finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); } }); +const left0 = dialog => dialog.children[1].children[0]; test('viewer renders the concept shell and toolbar order', async () => { const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')]; @@ -199,7 +200,14 @@ test('viewer renders the concept shell and toolbar order', async () => { assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog'); const [header,layout] = dialog.children; assert.equal(header.className,'dialog-header'); - const [heading,close] = header.children, [title,subtitle] = heading.children; + const [heading,mode,close] = header.children, [title,subtitle] = heading.children; + // #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default). + assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Centered view'); + assert.ok(f.walk(left0(dialog),n => n.textContent === 'Hide details').length === 1,'full screen adds the details toggle'); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Full screen view'); assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']); assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']); assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']); diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js index 497df7f6..d7c3f58b 100644 --- a/tests/js/workspace-system-appearance.test.js +++ b/tests/js/workspace-system-appearance.test.js @@ -65,7 +65,7 @@ function fakeDOM() { const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true, imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true, - sidebarCollapsedByDefault: false }; + sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }; async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) { const { mountAppearance } = await appearanceModule; @@ -80,9 +80,9 @@ async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: }); await drafts.load('browser'); const scope = createScope(); - const [surface] = mountAppearance(null, { scope, drafts }); - const byId = id => dom.find(surface, node => node.dataset?.setting === id); - return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, + const [surface, viewer] = mountAppearance(null, { scope, drafts }); + const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id); + return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, fixHost: () => { hostFails = false; } }; } @@ -98,13 +98,26 @@ test('the surface is the concept section with the concept rows in order, then th assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection', 'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos', 'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards', - 'Collapse viewer details by default', 'Reset display defaults']); + 'Reset display defaults']); const continuous = rows[2]; assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']); assert.equal(continuous.children[1].className, 'chip blue'); assert.equal(continuous.children[1].textContent, 'Infinite scroll'); assert.equal(rows.at(-1).className, 'operation'); - assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13); + assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12); + } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } +}); + +// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults. +test('the Viewer surface holds Open viewer in and the moved collapse row', async () => { + const { VIEWER_ROWS } = await appearanceModule; + const { dom, viewer, scope } = await mounted(); + try { + assert.equal(viewer.className, 'surface'); + assert.equal(viewer.children[0].textContent, 'Viewer'); + assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.'); + assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']); + assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']); } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } }); @@ -127,9 +140,11 @@ test('added rows reach their consumers: each control edits its stored key with t byId(id).checked = false; byId(id).dispatch('change'); } for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); } + assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]); + byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change'); assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low', autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false, - videoAutoplay: true, sidebarCollapsedByDefault: true }); + videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }); assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' }); } finally { scope.dispose(); dom.restore(); } }); @@ -154,7 +169,7 @@ test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => test('reset restores defaults without touching other keys', async () => { const { DISPLAY_KEYS } = await appearanceModule; - const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true }; + const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }; const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed }); try { await loadHost(); @@ -162,7 +177,7 @@ test('reset restores defaults without touching other keys', async () => { const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON'); assert.equal(reset.textContent, 'Reset'); reset.click(); - assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false }); + assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }); // The host fields and every key outside the display defaults are left as they are. assert.deepEqual(drafts.changes('host'), { result_timeout: 9 }); assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort()); diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js index 3ea591d0..404bd2fc 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -17,6 +17,7 @@ // The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry. const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"]; const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"]; + const VIEWER_OPEN_MODES = ["centered", "fullscreen"]; const LIMIT_OPTIONS = [50, 100, 200, 500]; const MIN_COLUMNS = 2; const MAX_COLUMNS = 12; @@ -42,6 +43,8 @@ videoLoop: true, workspaceLayout: "grid", workspaceDensity: "comfortable", + // #450 (D156): how the workspace viewer opens. + viewerOpenMode: "centered", }); function toInt(value) { @@ -82,6 +85,7 @@ videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop), workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout, workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity, + viewerOpenMode: VIEWER_OPEN_MODES.includes(merged.viewerOpenMode) ? merged.viewerOpenMode : DEFAULT_SETTINGS.viewerOpenMode, }; } @@ -91,7 +95,7 @@ return `repeat(${n}, minmax(0, 1fr))`; } - const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle }; + const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, VIEWER_OPEN_MODES, normalizeGallerySettings, gridColumnsStyle }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.GallerySettings = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index 5ce605a3..52fdabaa 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -21,7 +21,10 @@ const paths = Object.freeze({ "tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5", "folder": "M3 5h7l2 3h9v12H3z", "history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2", - "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5" + "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5", + // #450 (D156): the viewer's Full screen / Centered switch. + "maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5", + "minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" }); /** Create an approved decorative outline icon without injecting markup. */ diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 7b44c8c7..759e74aa 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -3,6 +3,7 @@ import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; import {displayPreferences, readPreferences} from './preferences.mjs'; +import {createWorkspaceIcon} from './icons.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. @@ -51,7 +52,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const heading = element('div'), title = element('h2');title.id = 'viewer-title'; heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow')); const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true; - header.append(heading,close); + // #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view. + let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen'; + const mode = createButton('','icon-btn');mode.id = 'viewer-mode'; + header.append(heading,mode,close); const body = element('div','viewer-layout'), left = element('div','viewer-left'); const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0; stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview'); @@ -75,27 +79,36 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle controls.append(download); const next = button('Next media','Next →',() => session.navigate(1)); // D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155 - // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. + // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450): + // the toggle is also there while the view is full screen. const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown'; - let detailsToggle = null; - if (collapsible) { - detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed)); - detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle); - } + const detailsToggle = createButton('');detailsToggle.setAttribute('aria-controls','viewer-details'); const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`; position.setAttribute('role','status'); const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`; const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; controls.append(position,reason,slideshowReason);left.append(stage,controls); const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); - if (detailsToggle) { - details.hidden = collapsed; - local.listen(detailsToggle,'click',() => { - details.hidden = !details.hidden; - detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; - detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); - }); + details.hidden = collapsed; + function paintToggle() { + detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; + detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); } + /** The toggle is in the toolbar only in full screen or with the collapse setting; without both the details show. */ + function syncToggle() { + if (collapsible || fullscreen) { if (!detailsToggle.isConnected) controls.insertBefore(detailsToggle,position); } + else { detailsToggle.remove();details.hidden = false; } + paintToggle(); + } + function paintMode() { + if (fullscreen) dialog.classList.add('viewer-fullscreen'); else dialog.classList.remove('viewer-fullscreen'); + const label = fullscreen ? 'Centered view' : 'Full screen view'; + mode.setAttribute('aria-label',label);mode.title = label; + mode.replaceChildren(createWorkspaceIcon(fullscreen ? 'minimize' : 'maximize')); + } + local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();}); + local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();}); + paintMode();syncToggle(); body.append(left,details);dialog.append(header,body);container.append(dialog); const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs index c964d846..1c420389 100644 --- a/web/js/workspace/preferences.mjs +++ b/web/js/workspace/preferences.mjs @@ -21,7 +21,7 @@ */ export const PREFERENCES_KEY = 'gallerySettings'; export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata', - 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']); + 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault', 'viewerOpenMode']); // The changes this tab could not store, overlaid on every read until storage accepts them. const tabOnly = new Map(); diff --git a/web/js/workspace/system-appearance.mjs b/web/js/workspace/system-appearance.mjs index 2f6210e7..13b59eed 100644 --- a/web/js/workspace/system-appearance.mjs +++ b/web/js/workspace/system-appearance.mjs @@ -13,7 +13,10 @@ * (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset * display defaults", which drafts the gallery's defaults for these browser rows and touches no other key. * - * - #317: a second surface, "Thumbnails", holds the server's thumbnail format, quality and size limit + * - #450 (D156, amends D97): a "Viewer" surface after it holds "Open viewer in" (Centered→`centered`, Full + * screen→`fullscreen`, `viewerOpenMode`) and the moved "Collapse viewer details by default"; "Reset display defaults" + * covers both surfaces' browser rows. + * - #317: a further surface, "Thumbnails", holds the server's thumbnail format, quality and size limit * (system-thumbnails.mjs). * * Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row @@ -50,14 +53,20 @@ export const APPEARANCE_ROWS = Object.freeze([ { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }], ['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout', { type: 'number' }], - // D97: the legacy gallery's card and viewer settings. + // D97: the legacy gallery's card setting (its viewer setting moved to VIEWER_ROWS, D156). ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN], +]); + +/** #450 (D156): the "Viewer" surface's rows, in the same shape. */ +export const VIEWER_ROWS = Object.freeze([ + ['viewer-mode', 'Open viewer in', 'Full screen fills the browser window; Centered is the framed viewer.', 'browser', 'viewerOpenMode', + { type: 'select', options: [['centered', 'Centered'], ['fullscreen', 'Full screen']] }], ['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser', 'sidebarCollapsedByDefault', BOOLEAN], ]); -/** The browser keys "Reset display defaults" restores (D97). */ -export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); +/** The browser keys "Reset display defaults" restores (D97), on both surfaces. */ +export const DISPLAY_KEYS = Object.freeze([...APPEARANCE_ROWS, ...VIEWER_ROWS].filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); /** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */ export function resultTimeout(text) { @@ -104,29 +113,36 @@ function paint(input, control, value) { /** * Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes: "Browsing & display", - * then the server's thumbnail settings (#317, system-thumbnails.mjs). + * "Viewer" (D156), then the server's thumbnail settings (#317, system-thumbnails.mjs). */ export function mountAppearance(container, { scope, drafts, api, activity }) { const surface = el('section', undefined, 'surface'); surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.')); const controls = []; - for (const row of APPEARANCE_ROWS) { - if (row === 'continuous') { surface.append(continuousRow()); continue; } - const [id, label, description, domain, key, control] = row; - const onChange = value => { - if (key === 'result_timeout') { - const seconds = resultTimeout(input.value); - if (seconds === null) { drafts.reject(domain, key); return; } - value = seconds; - } - drafts.set(domain, key, value); - }; - const node = settingRow({ id, label, description, control, scope, onChange }); - surface.append(node); - const input = controlOf(node, id); - input.dataset.key = key; - controls.push({ input, control, domain, key, text: node.children[0], note: null }); + function addRows(target, rows) { + for (const row of rows) { + if (row === 'continuous') { target.append(continuousRow()); continue; } + const [id, label, description, domain, key, control] = row; + const onChange = value => { + if (key === 'result_timeout') { + const seconds = resultTimeout(input.value); + if (seconds === null) { drafts.reject(domain, key); return; } + value = seconds; + } + drafts.set(domain, key, value); + }; + const node = settingRow({ id, label, description, control, scope, onChange }); + target.append(node); + const input = controlOf(node, id); + input.dataset.key = key; + controls.push({ input, control, domain, key, text: node.children[0], note: null }); + } } + addRows(surface, APPEARANCE_ROWS); + // D156: the viewer's defaults in their own surface. + const viewer = el('section', undefined, 'surface'); + viewer.append(el('h2', 'Viewer'), el('p', 'How the image viewer opens; the button beside ✕ switches the current view.')); + addRows(viewer, VIEWER_ROWS); surface.append(operationRow({ title: 'Reset display defaults', description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope, onClick: () => { @@ -146,5 +162,5 @@ export function mountAppearance(container, { scope, drafts, api, activity }) { }; scope.cleanup(drafts.subscribe(repaint)); repaint(); - return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })]; + return [surface, viewer, mountThumbnailSettings(container, { scope, drafts, api, activity })]; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index edd9a2a7..cce3ef50 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -180,6 +180,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */ .ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ } #viewer-dialog .dialog-header > div { min-width: 0; } +/* #450 D156: the view switch sits before ✕ (8px apart; the header's gap is the concept's 18px), and Full screen fills the + browser window edge to edge (no outer margin), beating the concept's box, D34's max-height and the ≤700px height. + At ≤700px the viewer is already full-bleed, so the switch hides there. */ +#viewer-dialog .dialog-header > #viewer-mode { margin-inline-start: auto; margin-inline-end: -10px; } +.ca-tools #viewer-dialog.viewer-fullscreen[open] { inset: 0; width: auto; height: auto; max-width: none; max-height: none; margin: 0; border: 0; border-radius: 0; } +/* `!important` only to beat the concept's `.icon-btn { display: inline-grid !important }`. */ +@media (max-width: 700px) { #viewer-dialog .dialog-header > #viewer-mode { display: none !important; } } #viewer-title { overflow-wrap: anywhere; /* D34 */ } #viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; } #viewer-details { overscroll-behavior: contain; /* D34 */ }