From ece4508438d24e53772d07629b2b3385f51c458d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 23 Oct 2026 05:50:56 -0700 Subject: [PATCH] feat(system): Appearance defaults used by Images and the viewer System > Appearance is the concept's Browsing & display surface on real values: the browser rows edit this browser's display defaults through the shared gallerySettings object, and Open from ComfyUI and the node search auto-hide save to the server settings with their revision. A new Images visit starts from the default layout and size (the current view and saved views keep theirs); metadata off waits for Load metadata; cache off re-reads the shown file; paths off hides the Path row; the viewer reads the video defaults. D97 adds card details, collapsed viewer details and Reset display defaults; storage refused says Saved for this tab only. --- TESTING.md | 62 +++- tests/browser/system-concept.spec.mjs | 125 ++++++++ tests/browser/workspace-system.spec.mjs | 300 +++++++++++++++++++ tests/browser/workspace.spec.mjs | 10 +- tests/browser/workspace_system_fixture.py | 17 +- tests/js/workspace-images-state.test.js | 16 + tests/js/workspace-inspection-state.test.js | 53 ++++ tests/js/workspace-system-appearance.test.js | 168 +++++++++++ tests/js/workspace-system-state.test.js | 24 ++ tools/vendor/frontend-coverage-baseline.json | 22 +- web/js/workspace/file-inspector.mjs | 20 +- web/js/workspace/images-state.mjs | 11 + web/js/workspace/images.mjs | 16 +- web/js/workspace/inspection-state.mjs | 18 +- web/js/workspace/media-viewer.mjs | 18 +- web/js/workspace/preferences.mjs | 5 + web/js/workspace/system-appearance.mjs | 130 ++++++++ web/js/workspace/system-state.mjs | 24 ++ web/js/workspace/system.mjs | 32 +- web/js/workspace/workspace.css | 4 + 20 files changed, 1034 insertions(+), 41 deletions(-) create mode 100644 tests/js/workspace-system-appearance.test.js create mode 100644 web/js/workspace/system-appearance.mjs diff --git a/TESTING.md b/TESTING.md index 208e190c..2ba63fd5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1834,8 +1834,36 @@ browser fields, then the host fields (`POST /prompt_manager/settings` with highlights their controls; `settings-changed` is a conflict and keeps the draft. System reads the host values on mount and again after a conflict; until a read returned a revision, a host save fails without a request, so it never sends a blind -`expected_revision: null` (which S1 merges into the file on disk). No delivered section -edits a setting yet, so Save stays disabled until Appearance (S3). +`expected_revision: null` (which S1 merges into the file on disk). Save stays disabled +until a section has an unsaved change. + +Appearance (`system-appearance.mjs`, S3) is the concept's "Browsing & display" surface +on real values. Its browser rows are this browser's display defaults in the legacy +gallery's own `gallerySettings` object (`web/js/workspace/preferences.mjs`, Q3): Default +layout and Thumbnail size (`workspaceLayout` grid/list/masonry, `workspaceDensity` +comfortable/large/compact, new keys), Load metadata on selection (`autoLoadMetadata`), +Cache metadata in this browser (`cacheMetadata`), Image loading quality (`imageQuality`: +Optimized→`medium`, Original→`high`, Fast→`low`; the thumbnail dialog's preselected quality +once S5 lands), the three video rows (`videoAutoplay`, `videoMute`, `videoLoop`) and Show +file paths (`showFilePaths`). A write re-reads the stored object and writes back only the +changed keys, every other key (legacy paging, thumbnail state, unknown keys) as stored; +the legacy gallery's Save also keeps the keys it does not edit, so the two pages stay in +sync. The workspace never reads the legacy paging keys (`defaultLimit`, `gridColumns`, +`infiniteScroll`; README continuous-browsing ruling). A browser that refuses storage +(denied, or a full quota that leaves the stored bytes as they were) keeps the change for +this tab only and the status says "Saved for this tab only; browser storage is +unavailable.", never "Saved in this browser.". Another tab's change (a `storage` event) +repaints the clean controls; a draft it overtook stays, highlighted, with the conflict +status. Open from ComfyUI (New tab→`newtab`, Popup window→`popup`) and Node search +auto-hide (seconds) (a whole number 0–300; anything else is highlighted and never sent) +are the server's `webui_display_mode` and `result_timeout`, saved through +`POST /prompt_manager/settings` with the revision. The consumers read the defaults on +their next visit, never the current view: a new Images visit starts from the layout and +size (the last visit keeps its own, a saved view opens at Grid and Comfortable as +before); with metadata off a selection reads nothing until "Load metadata"; with the +cache off selecting the shown file again re-reads it (on, the one current record is +reused); paths off leaves out only the Path row of File & provenance; the viewer reads +the video defaults. Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per visit through `latestOnly()` on the section scope; a late answer after the section left @@ -1864,7 +1892,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 assigned): +Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122 and D123 assigned): - **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server @@ -1922,6 +1950,18 @@ Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 as the rest of Maintenance, above its D94 hand-off, as the native place that runs the health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a failed check toasts "The health check could not finish. Nothing was changed." +- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves + out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a + card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer + 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), 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. +- **D-NEW-S3-1** (inspector): with "Load metadata on selection" off, the Images panel and + the viewer's details show "Metadata loads when you ask for it." and a "Load metadata" + button instead of reading the file; the concept has no such state. `tests/browser/system-concept.spec.mjs` compares the shell and Overview with the concept (dark and light, 1600×1100 and 390×844), mapping only the deviations above; unmapped, @@ -1934,15 +1974,23 @@ source when there are several, replaces only the chips with the fixture's expect results, and asserts the full unmapped path list; it maps D96 and compares the Maintenance notice unchanged. `system comparison detects mutations` removes a row, renames a metric label, swaps the `--line` token, recolours a chip, renames a check, -removes the totals table and renames a caption. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, -`{concept,production}-{statistics,health}-{dark,light}.png`) are test artifacts; there +removes the totals table and renames a caption. `appearance matches the concept` maps +only D97 and the real timeout (unmapped, exactly those paths differ), and `appearance +comparison detects mutations` removes a row, renames a label and an option and swaps +the `--line` token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, +`{concept,production}-{statistics,health}-{dark,light}.png`, +`{concept,production}-appearance-{dark,light}-{1600,390}.png`) are test artifacts; there are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts, the health check and metric 4, a failed check, the thumbnail row against the status endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the -route, a load failure with Retry and late answers after leaving. Its hooks are +route, a load failure with Retry and late answers after leaving, and Appearance: new +visits versus the current view and a saved view, metadata off and on request, the cache, +hidden paths, the video defaults, the host fields with their revision and a stale one, +reload and a second tab, storage refused, the D97 rows and reset. Its hooks are `tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected failures, an absent source, a deleted linked file, unthumbnailed files) on the -concept-shaped fixture, with delays through the shared request holds. +concept-shaped fixture, with delays through the shared request holds; reset also restores +the server's host settings and its `config.json`. ```sh python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library -v diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index c32a62e6..761134b9 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -408,3 +408,128 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; }); expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']); }); + +/** Runs in the page: Appearance's surface, every row's text and control, and the concept's row geometry and tokens. */ +function measureAppearance(mainSelector) { + const layout = document.querySelector(`${mainSelector} .settings-layout`); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const text = node => node?.textContent.trim() ?? null; + const surface = layout.querySelector(':scope > section.surface'); + const control = row => { + const select = row.querySelector(':scope > select'); + if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent), + selected: select.selectedOptions[0]?.textContent ?? null }; + const input = row.querySelector(':scope > input'); + if (input) return { kind: `input.${input.type}`, id: input.id, value: input.value }; + const toggle = row.querySelector(':scope > * input[type="checkbox"]'); + if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked, + ...css(toggle.parentElement, 'width', 'height') }; + const chip = row.querySelector(':scope > .chip'); + return chip ? { kind: name(chip), text: text(chip), ...css(chip, 'color', 'backgroundColor', 'fontSize') } : null; + }; + const rows = [...surface.querySelectorAll(':scope > .setting-row')]; + const operations = [...surface.querySelectorAll(':scope > .operation')]; + const firstRow = rows[0]; + return { + layout: [...layout.children].map(name), + surface: { tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')), + intro: text(surface.querySelector(':scope > p')), + ...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius') }, + rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node), + node.localName === 'label' ? node.htmlFor : null]), control: control(row) })), + operations: operations.map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')), + description: text(row.querySelector('p')), action: text(row.querySelector('.row-actions > button')) })), + row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth', + 'borderTopColor', 'marginTop'), + label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'), + description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'), + select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'), + heading3: css(surface.querySelector('.setting-row h3'), 'fontSize', 'fontWeight', 'color'), + number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor'), + pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; +} + +// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them. +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. */ +function mapAppearanceConcept(reference, settings) { + const toggleRow = reference.rows.find(row => row.control?.kind === 'switch'); + const added = ADDED_APPEARANCE_ROWS.map(([id, label, description, checked]) => ({ + tree: toggleRow.tree, text: [['label', label, id], ['p', description, null]], + control: { ...toggleRow.control, id, checked } })); + return { + ...reference, + // 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'] }, + 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], + operations: [{ tree: ['div', 'div.row-actions'], title: 'Reset display defaults', + description: 'Restore this browser’s display defaults, then save. Other settings are kept.', action: 'Reset' }], + }; +} + +async function conceptAppearance(page, theme) { + await openConcept(page, 'system', theme); + await page.locator('#main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click(); + await expect(page.locator('#main .settings-layout > section.surface > h2')).toHaveText('Browsing & display'); + await page.mouse.move(0, 0); + return page.evaluate(measureAppearance, '#main'); +} + +async function productionAppearance(page, theme) { + await openProduction(page, 'system/appearance', theme); + await expect(page.locator('#workspace-main #timeout')).toBeEnabled(); + await page.mouse.move(0, 0); + return page.evaluate(measureAppearance, '#workspace-main'); +} + +for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`appearance matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + const { settings } = await serverJson(request, '/prompt_manager/settings'); + const concept = await conceptAppearance(page, theme); + await page.screenshot({ path: testInfo.outputPath(`concept-appearance-${theme}-${width}.png`), fullPage: true }); + const production = await productionAppearance(page, theme); + await page.screenshot({ path: testInfo.outputPath(`production-appearance-${theme}-${width}.png`), fullPage: true }); + expect(diffInventory(mapAppearanceConcept(concept, settings), production)).toEqual([]); + expect(production.pageScroll).toBe(false); + }); +} + +test('appearance comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const { settings } = await serverJson(request, '/prompt_manager/settings'); + const concept = await conceptAppearance(page, 'dark'); + const expected = mapAppearanceConcept(concept, settings); + const production = await productionAppearance(page, 'dark'); + expect(diffInventory(expected, production)).toEqual([]); + // 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(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort()); + const measure = () => page.evaluate(measureAppearance, '#workspace-main'); + const paths = async () => diffInventory(expected, await measure()).map(item => item.path); + // A removed row. + const undoRow = await mutate(page, { remove: ['#workspace-main .setting-row:nth-of-type(3)'] }); + expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length'])); + await undoRow(); + // A changed label and a changed option. + await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail sizes'; }); + expect(await paths()).toEqual(['rows.1.text.0.1']); + await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail size'; }); + await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Medium'; }); + expect(await paths()).toEqual(expect.arrayContaining(['rows.5.control.options.0'])); + await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Optimized'; }); + // A swapped token. + const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor'])); + await undoToken(); + expect(diffInventory(expected, await measure())).toEqual([]); +}); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 75d2069b..aa1ae755 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -250,3 +250,303 @@ test('out-of-order overview responses never repaint a newer section', async ({ p await expect(layout.locator('.summary-metrics')).toHaveCount(1); await expect(layout.locator('.two-col')).toHaveCount(1); }); + +// System › Appearance (#249 System S3 Task 8): the browser display defaults (`gallerySettings`, preferences.mjs) and the +// two host fields behind the one Save row, and the consumers that read them on their next visit. +const SETTINGS_URL = '/prompt_manager/settings'; +const INSPECT_PATH = '/prompt_manager/images/inspect/'; +const statusOf = page => page.locator('#workspace-main #settings-status'); +const saveOf = page => page.locator('#workspace-main .save-row > button'); +const storedSettings = page => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || 'null')); +const imageCards = page => page.locator('#workspace-main article.media-card'); +const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); + +async function openAppearance(page) { + await page.goto(`${workspace}#/system/appearance`); + await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display'); + // The host rows are enabled once the server settings answered. + await expect(page.locator('#timeout')).toBeEnabled(); +} + +async function saveAppearance(page, text) { + await expect(saveOf(page)).not.toHaveAttribute('aria-disabled', 'true'); + await saveOf(page).click(); + await expect(statusOf(page)).toHaveText(text); +} + +/** Set toggles (by row id) through their Toolkit switches. */ +async function toggle(page, values) { + for (const [id, checked] of Object.entries(values)) await page.locator(`#${id}`).setChecked(checked, { force: true }); +} + +async function openImages(page) { + await page.goto(`${workspace}#/images`); + await expect(imageCards(page).first()).toBeVisible(); +} + +test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => { + const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything', + state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } }); + expect(created.status()).toBe(201); + const view = (await created.json()).view; + try { + await openImages(page); + // The current view: the toolbar sets List. + await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click(); + await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list'); + await openAppearance(page); + await expect(page.locator('#default-view')).toHaveValue('grid'); + await page.locator('#default-view').selectOption('masonry'); + await page.locator('#density').selectOption('compact'); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + await saveAppearance(page, 'Saved in this browser.'); + const stored = await storedSettings(page); + expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']); + // Back in Images (the page session's last visit): the current view keeps its own layout and size. + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list'); + await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable'); + // The saved view opens as before. + await page.goto(`${workspace}#/saved/${view.id}`); + await expect(imageCards(page).first()).toBeVisible(); + await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid'); + await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable'); + // A new visit starts from the defaults. + await openImages(page); + await page.reload(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry'); + await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact'); + await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true })) + .toHaveAttribute('aria-pressed', 'true'); + } finally { + await request.delete(`/prompt_manager/saved-views/${view.id}`); + } +}); + +test('metadata off shows an explicit load and loads once on request', async ({ page }) => { + await openAppearance(page); + await toggle(page, { 'metadata-auto': false }); + await saveAppearance(page, 'Saved in this browser.'); + const reads = []; + page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); }); + await openImages(page); + const first = imageCards(page).first(); + await first.locator('.image-card-actions button').first().click(); + await expect(panel(page).getByRole('status')).toHaveText('Metadata loads when you ask for it.'); + const load = panel(page).getByRole('button', { name: 'Load metadata', exact: true }); + await expect(load).toBeVisible(); + expect(reads).toEqual([]); + await load.click(); + await expect(panel(page).locator('.panel-heading h2')).toBeVisible(); + expect(reads).toHaveLength(1); + // The same file again reads nothing more (the cache is on). + await first.locator('.image-card-actions button').first().click(); + await expect(panel(page).locator('.panel-heading h2')).toBeVisible(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(reads).toHaveLength(1); + // The viewer's details use the same inspector: another file waits there too, and loads on request. + await imageCards(page).nth(1).locator('.image-open').click(); + const viewerDetails = page.locator('#viewer-details'); + await expect(viewerDetails.getByRole('status')).toHaveText('Metadata loads when you ask for it.'); + expect(reads).toHaveLength(1); + await viewerDetails.getByRole('button', { name: 'Load metadata', exact: true }).click(); + await expect(viewerDetails.locator('.panel-heading h2')).toBeVisible(); + expect(reads).toHaveLength(2); +}); + +test('cache off rereads the current selection; on reuses the one current record', async ({ page }) => { + const reads = []; + page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); }); + const settle = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + for (const cache of [true, false]) { + if (!cache) { + await openAppearance(page); + await toggle(page, { 'metadata-cache': false }); + await saveAppearance(page, 'Saved in this browser.'); + await page.reload(); + } + await openImages(page); + const info = imageCards(page).first().locator('.image-card-actions button').first(); + await info.click(); + await expect(panel(page).locator('.panel-heading h2')).toBeVisible(); + await expect.poll(() => reads.length).toBeGreaterThan(0); + await settle(); + reads.length = 0; + // Inspect the selected file twice more. + for (const _ of [1, 2]) { + const answered = cache ? null : page.waitForResponse(response => new URL(response.url()).pathname.startsWith(INSPECT_PATH)); + await info.click(); + if (answered) await answered; + await expect(panel(page).locator('.panel-heading h2')).toBeVisible(); + } + await settle(); + expect(reads, `cache ${cache}`).toHaveLength(cache ? 0 : 2); + } +}); + +test('paths hidden only changes display', async ({ page }) => { + const rows = async () => page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }).locator('dt') + .evaluateAll(terms => terms.map(term => term.textContent)); + const openViewer = async () => { + await openImages(page); + await imageCards(page).first().locator('.image-open').click(); + const provenance = page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }); + await provenance.locator('summary').click(); + await expect(provenance.locator('dl')).toBeVisible(); + return provenance; + }; + let provenance = await openViewer(); + expect(await rows()).toContain('Path'); + const before = { show: await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count(), + download: await provenance.getByRole('link', { name: 'Download original', exact: true }).getAttribute('href') }; + await page.keyboard.press('Escape'); + await openAppearance(page); + await toggle(page, { paths: false }); + await saveAppearance(page, 'Saved in this browser.'); + provenance = await openViewer(); + const shown = await rows(); + expect(shown).not.toContain('Path'); + expect(shown).toEqual(expect.arrayContaining(['Source', 'Size'])); + // Everything else is as it was: the folder action and the original download stay. + expect(await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count()).toBe(before.show); + await expect(provenance.getByRole('link', { name: 'Download original', exact: true })).toHaveAttribute('href', before.download); +}); + +test('video defaults reach the viewer', async ({ page }) => { + await openAppearance(page); + await toggle(page, { 'video-auto': true, 'video-muted': false, 'video-loop': false }); + await saveAppearance(page, 'Saved in this browser.'); + expect(await page.evaluate(async () => (await import('/prompt_manager/js/workspace/media-viewer.mjs')).readViewerPreferences())) + .toEqual({ videoAutoplay: true, videoMute: false, videoLoop: false }); + const stored = await storedSettings(page); + expect([stored.videoAutoplay, stored.videoMute, stored.videoLoop]).toEqual([true, false, false]); +}); + +test('host fields round-trip through /settings with revision', async ({ page, request }) => { + const posts = []; + page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL) posts.push(item.postDataJSON()); }); + const before = await serverJson(request, SETTINGS_URL); + await openAppearance(page); + await expect(page.locator('#open-mode')).toHaveValue(before.settings.webui_display_mode); + await expect(page.locator('#timeout')).toHaveValue(String(before.settings.result_timeout)); + // Only whole seconds from 0 to 300: anything else is highlighted and never sent. + for (const bad of ['301', '-1', '2.5']) { + await page.locator('#timeout').fill(bad); + await page.locator('#timeout').press('Tab'); + await expect(page.locator('#timeout')).toHaveAttribute('aria-invalid', 'true'); + await expect(saveOf(page)).toHaveAttribute('aria-disabled', 'true'); + } + await page.locator('#timeout').fill('0'); + await page.locator('#timeout').press('Tab'); + await expect(page.locator('#timeout')).not.toHaveAttribute('aria-invalid'); + await page.locator('#open-mode').selectOption('popup'); + await saveAppearance(page, 'Saved to the server configuration.'); + expect(posts).toEqual([{ webui_display_mode: 'popup', result_timeout: 0, expected_revision: before.revision }]); + const after = await serverJson(request, SETTINGS_URL); + expect([after.settings.webui_display_mode, after.settings.result_timeout]).toEqual(['popup', 0]); + expect(after.revision).not.toBe(before.revision); + // No browser key was written for a host field. + expect(await storedSettings(page)).toBeNull(); +}); + +test('stale revision shows the conflict status', async ({ page, request }) => { + await openAppearance(page); + // Another page saves after this one read the settings. + const current = await serverJson(request, SETTINGS_URL); + await expect(await request.post(SETTINGS_URL, { data: { result_timeout: 42, expected_revision: current.revision } })).toBeOK(); + await page.locator('#open-mode').selectOption('popup'); + await saveAppearance(page, 'Settings changed elsewhere. Reload this section to see them.'); + await expect(page.locator('#open-mode')).toHaveAttribute('aria-invalid', 'true'); + // The draft is kept and the section now shows the other page's value. + await expect(page.locator('#open-mode')).toHaveValue('popup'); + await expect(page.locator('#timeout')).toHaveValue('42'); + expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('newtab'); + // With the reloaded revision, Save succeeds. + await saveAppearance(page, 'Saved to the server configuration.'); + expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('popup'); +}); + +test('reload keeps saved values; a new tab reads them', async ({ page, context }) => { + await openAppearance(page); + await page.locator('#image-quality').selectOption('high'); + await toggle(page, { 'card-details': false }); + await page.locator('#open-mode').selectOption('popup'); + await saveAppearance(page, 'Saved in this browser. Saved to the server configuration.'); + await page.reload(); + await expect(page.locator('#timeout')).toBeEnabled(); + await expect(page.locator('#image-quality')).toHaveValue('high'); + await expect(page.locator('#card-details')).not.toBeChecked(); + await expect(page.locator('#open-mode')).toHaveValue('popup'); + const other = await context.newPage(); + await openAppearance(other); + await expect(other.locator('#image-quality')).toHaveValue('high'); + await expect(other.locator('#card-details')).not.toBeChecked(); + // A change saved in one tab reaches the other's clean controls; a draft it overtakes is flagged and kept. + await other.locator('#density').selectOption('large'); + await page.locator('#density').selectOption('compact'); + await saveAppearance(page, 'Saved in this browser.'); + await expect(other.locator('#density')).toHaveValue('large'); + await expect(other.locator('#settings-status')).toHaveText('Settings changed elsewhere. Reload this section to see them.'); + await expect(other.locator('#density')).toHaveAttribute('aria-invalid', 'true'); + await page.locator('#image-quality').selectOption('low'); + await saveAppearance(page, 'Saved in this browser.'); + await expect(other.locator('#image-quality')).toHaveValue('low'); + await other.close(); +}); + +test('a browser that refuses storage is saved for this tab only, never "Saved"', async ({ page }) => { + await openAppearance(page); + await page.evaluate(() => { + Storage.prototype.setItem = function setItem() { throw new DOMException('Quota exceeded', 'QuotaExceededError'); }; + }); + await page.locator('#default-view').selectOption('list'); + await saveAppearance(page, 'Saved for this tab only; browser storage is unavailable.'); + expect(await storedSettings(page)).toBeNull(); + // This tab's next Images visit still uses it. + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list'); +}); + +test('added rows reach their consumers', async ({ page }) => { + await openImages(page); + await expect(imageCards(page).first().locator('.media-info h3')).toBeVisible(); + await openAppearance(page); + await toggle(page, { 'card-details': false, 'viewer-details': true }); + await saveAppearance(page, 'Saved in this browser.'); + await page.reload(); + await openImages(page); + // Show image details on cards off: no title or file line; Info and Tag stay. + const card = imageCards(page).first(); + await expect(card.locator('.media-info h3')).toBeHidden(); + await expect(card.locator('.image-card-actions button')).toHaveCount(2); + // Collapse viewer details by default: the viewer opens with its details hidden behind one toggle. + await card.locator('.image-open').click(); + const details = page.locator('#viewer-details'); + await expect(page.locator('#viewer-dialog')).toBeVisible(); + await expect(details).toBeHidden(); + const show = page.locator('#viewer-toolbar').getByRole('button', { name: 'Show details', exact: true }); + await expect(show).toHaveAttribute('aria-expanded', 'false'); + await show.click(); + await expect(details).toBeVisible(); + await expect(page.locator('#viewer-toolbar').getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true'); +}); + +test('reset restores defaults without touching other keys', async ({ page }) => { + await page.goto(`${workspace}#/system/appearance`); + await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ gridColumns: 5, defaultLimit: 200, + thumbnailPromptDismissed: 'kept', workspaceLayout: 'list', videoMute: false, showImageInfo: false }))); + await page.reload(); + await expect(page.locator('#timeout')).toBeEnabled(); + await expect(page.locator('#default-view')).toHaveValue('list'); + const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true }); + await reset.click(); + await expect(page.locator('#default-view')).toHaveValue('grid'); + await expect(page.locator('#video-muted')).toBeChecked(); + await expect(statusOf(page)).toHaveText('Unsaved changes.'); + await saveAppearance(page, 'Saved in this browser.'); + const stored = await storedSettings(page); + expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid', + videoMute: true, showImageInfo: true }); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index f065b4c5..8d847a33 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -425,7 +425,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance', 'Backup & export', 'Diagnostics & logs']; const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']; -const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'], +const SYSTEM_HANDOFFS = { sources: ['/prompt_manager/admin', '/prompt_manager/gallery'], maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] }; test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => { @@ -462,14 +462,14 @@ test('System is native: the concept shell, section navigation, sections, hand-of await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1); // The section changes in place: focus stays on the pressed section, not the page title. await expect(button).toBeFocused(); - // S2 Task 6: Overview is native (workspace-system.spec.mjs); Maintenance shows its health-check notice above - // the hand-off (D123); every other section hands off. + // S2 Task 6: Overview is native (workspace-system.spec.mjs), and so is Appearance (S3 Task 8); Maintenance shows + // its health-check notice above the hand-off (D123); every other section hands off. if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) - .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], + .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'], maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash] ?? ['section.surface handoff', 'div.save-row']); - if (hash === 'overview') continue; + if (hash === 'overview' || hash === 'appearance') continue; const handoff = layout.locator('section.handoff'); await expect(handoff.locator('h2')).toHaveText('Your current tools are ready'); expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href')))) diff --git a/tests/browser/workspace_system_fixture.py b/tests/browser/workspace_system_fixture.py index 6027c64d..ea1ce4b9 100644 --- a/tests/browser/workspace_system_fixture.py +++ b/tests/browser/workspace_system_fixture.py @@ -31,9 +31,17 @@ MAX_UNTHUMBNAILED = 50 def register_system_commands(app, routes, api, root: Path): - from py.config import GalleryConfig + from py.config import GalleryConfig, PromptManagerConfig configured = list(GalleryConfig.MONITORING_DIRECTORIES) + # System › Appearance saves the host fields to this server's config.json (S3); + # reset restores both. + config_path = Path(os.environ["PROMPT_MANAGER_CONFIG_PATH"]) + config_bytes = config_path.read_bytes() if config_path.exists() else None + host_values = ( + PromptManagerConfig.RESULT_TIMEOUT, + PromptManagerConfig.WEBUI_DISPLAY_MODE, + ) failures: dict[str, int] = {} @web.middleware @@ -52,6 +60,13 @@ def register_system_commands(app, routes, api, root: Path): failures.clear() GalleryConfig.MONITORING_DIRECTORIES = list(configured) api._health_report = None + PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE = ( + host_values + ) + if config_bytes is None: + config_path.unlink(missing_ok=True) + else: + config_path.write_bytes(config_bytes) @routes.post("/__test__/system/reset") async def system_reset(request): diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index b17aa9bf..bb81a6e9 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -478,3 +478,19 @@ test('a refresh deferred behind a page load is dropped when the stream is dispos await new Promise(resolve=>setImmediate(resolve)); assert.equal(requests.length,2,'no refresh runs after dispose'); }); + +test('defaults apply to a new visit but not to the current view or a saved view', async () => { + const { initialImagesView } = await source; + assert.equal(typeof initialImagesView, 'function', 'initialImagesView is exported'); + const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' }; + // A new visit starts from System › Appearance. + assert.deepEqual(initialImagesView({ preferences }), { layout: 'masonry', density: 'compact' }); + // The current view (the last visit) keeps its own toolbar choices. + assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'large' }, preferences }), { layout: 'list', density: 'large' }); + assert.deepEqual(initialImagesView({ prior: {}, preferences }), { layout: 'grid', density: 'comfortable' }); + // A saved view opens as it always did. + assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' }); + // Without readable preferences nothing changes. + assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' }); + assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' }); +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 82ea79d1..13bd1f12 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -825,3 +825,56 @@ test('refresh re-reads the selected file and keeps the shown record until the ne assert.equal(controller.state.record.identity.path, 'folder/b.png'); controller.dispose(); assert.equal(await controller.refresh(), undefined); }); + +test('metadata off shows an explicit load and loads once on request', async () => { + const { api, requests } = pendingApi(), updates = []; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false }); + controller.subscribe(state => updates.push(state)); + // Selecting reads nothing: the state says the metadata waits for a request. + assert.equal(controller.select(image()), undefined); + assert.equal(requests.length, 0); + assert.equal(controller.state.deferred, true); + assert.equal(controller.state.loading, false); + assert.equal(controller.state.record, null); + // Selecting the same file again still reads nothing. + controller.select({ ...image() }); + assert.equal(requests.length, 0); + const load = controller.load(); + assert.equal(requests.length, 1); + assert.equal(controller.state.loading, true); + assert.equal(controller.state.deferred, false); + // A second press while it reads, and the same file selected again once it loaded, read nothing more. + controller.load(); + assert.equal(requests.length, 1); + requests[0].resolve(envelope()); await load; + assert.equal(controller.state.record.generation.seed.value, '9007199254740993'); + controller.load(); controller.select(image()); + assert.equal(requests.length, 1); + // Another file waits for its own request again. + controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') }); + assert.equal(controller.state.deferred, true); + assert.equal(requests.length, 1); + // The default controller loads on selection, as before. + const eager = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + eager.select(image()); + assert.equal(requests.length, 2); + assert.equal(eager.state.deferred, false); + eager.dispose(); controller.dispose(); + assert.equal(controller.load(), undefined); +}); + +test('cache off rereads the current selection; on reuses the one current record', async () => { + for (const cache of [true, false]) { + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache }); + const first = controller.select(image()); + // A read in flight is shared either way. + controller.select(image()); + assert.equal(requests.length, 1); + requests[0].resolve(envelope()); await first; + const again = controller.select(image()); + assert.equal(requests.length, cache ? 1 : 2, `cache ${cache}`); + if (!cache) { requests[1].resolve(envelope()); await again; assert.equal(controller.state.record.identity.path, 'folder/a #%.png'); } + controller.dispose(); + } +}); diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js new file mode 100644 index 00000000..70f4ee09 --- /dev/null +++ b/tests/js/workspace-system-appearance.test.js @@ -0,0 +1,168 @@ +// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts +// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const gallerySettings = require('../../web/js/gallery-settings.js'); + +const appearanceModule = import('../../web/js/workspace/system-appearance.mjs'); +const stateModule = import('../../web/js/workspace/system-state.mjs'); +const coreModule = import('../../web/js/shared/toolkit/core.js'); +globalThis.GallerySettings = gallerySettings; + +/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */ +function fakeDOM() { + const previous = global.document; + const elements = []; + class Element { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = []; + this.dataset = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this._text = ''; + elements.push(this); + } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + append(...nodes) { + for (const node of nodes) { + const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node; + child.parentNode = this; + this.children.push(child); + } + } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + addEventListener(type, callback, options = {}) { + if (options.signal?.aborted) return; + const entry = { type, callback }; + this.listeners.push(entry); + options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); }); + } + removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); } + dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); } + click() { this.dispatch('click'); } + } + global.document = { + createElement: tag => new Element(tag), + createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }), + }; + const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; }; + return { + listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0), + find: (root, predicate) => walk(root).find(predicate), + tags: node => node.children.map(child => child.tagName.toLowerCase()), + restore() { global.document = previous; }, + }; +} + +const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true, + imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true, + sidebarCollapsedByDefault: false }; + +async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER } = {}) { + const { mountAppearance } = await appearanceModule; + const { createSystemDrafts } = await stateModule; + const { createScope } = await coreModule; + const dom = fakeDOM(); + let hostValues = null; + const drafts = createSystemDrafts({ + readBrowser: () => ({ ...browser }), readRuntime: () => ({}), + readHost: async () => ({ values: hostValues, revision: 'sha256:one' }), + writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}), + }); + 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'); } }; +} + +test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => { + const { APPEARANCE_ROWS } = await appearanceModule; + const { dom, surface, scope } = await mounted(); + try { + assert.equal(surface.className, 'surface'); + assert.equal(surface.children[0].textContent, 'Browsing & display'); + assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.'); + const rows = surface.children.slice(2); + const titles = rows.map(row => row.children[0].children[0].textContent); + 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']); + 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); + } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } +}); + +test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => { + const { dom, drafts, scope, byId, loadHost } = await mounted(); + try { + // Host rows stay disabled until the server settings answered. + assert.equal(byId('open-mode').disabled, true); + assert.equal(byId('timeout').disabled, true); + await loadHost(); + assert.equal(byId('open-mode').disabled, false); + assert.equal(byId('timeout').value, '5'); + assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]), + [['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]); + assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]), + [['newtab', 'New tab'], ['popup', 'Popup window']]); + const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']]; + for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); } + for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) { + 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(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 }); + assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' }); + } finally { scope.dispose(); dom.restore(); } +}); + +test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => { + const { resultTimeout } = await appearanceModule; + assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]); + const { dom, drafts, scope, byId, loadHost } = await mounted(); + try { + await loadHost(); + const timeout = byId('timeout'); + timeout.value = '45'; timeout.dispatch('change'); + assert.deepEqual(drafts.changes('host'), { result_timeout: 45 }); + timeout.value = '301'; timeout.dispatch('change'); + assert.deepEqual(drafts.changes('host'), {}); + assert.deepEqual([...drafts.failed('host')], ['result_timeout']); + timeout.value = '0'; timeout.dispatch('change'); + assert.deepEqual(drafts.changes('host'), { result_timeout: 0 }); + assert.deepEqual([...drafts.failed('host')], []); + } finally { scope.dispose(); dom.restore(); } +}); + +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 { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed }); + try { + await loadHost(); + drafts.set('host', 'result_timeout', 9); + 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 }); + // 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()); + } finally { scope.dispose(); dom.restore(); } +}); diff --git a/tests/js/workspace-system-state.test.js b/tests/js/workspace-system-state.test.js index a9e09118..bbb1043b 100644 --- a/tests/js/workspace-system-state.test.js +++ b/tests/js/workspace-system-state.test.js @@ -259,3 +259,27 @@ test('a failed read keeps the store usable and drafts survive a reload (Q10)', a assert.equal(failing.dirty(), false); assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError); }); + +test('a rejected value drops its draft and is highlighted; a conflict elsewhere keeps the drafts', async () => { + const domains = adapters({ browser: { videoMute: true, videoLoop: true }, host: { result_timeout: 5 } }); + const drafts = await loaded(domains); + assert.deepEqual(drafts.saved('browser'), { videoMute: true, videoLoop: true }); + drafts.set('host', 'result_timeout', 12); + assert.equal(drafts.dirty('host'), true); + drafts.reject('host', 'result_timeout'); + assert.equal(drafts.dirty('host'), false); + assert.deepEqual([...drafts.failed('host')], ['result_timeout']); + assert.equal(drafts.get('host', 'result_timeout'), 5); + // A valid value clears the highlight. + drafts.set('host', 'result_timeout', 7); + assert.deepEqual([...drafts.failed('host')], []); + drafts.discard(); + drafts.set('browser', 'videoMute', false); + drafts.conflict('browser', []); + assert.equal(drafts.statusText(), 'Unsaved changes.'); + drafts.conflict('browser', ['videoMute']); + assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.'); + assert.deepEqual([...drafts.failed('browser')], ['videoMute']); + assert.equal(drafts.get('browser', 'videoMute'), false); + assert.throws(() => drafts.reject('nowhere', 'x'), TypeError); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index a8492511..722483f3 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -403,8 +403,8 @@ }, "workspace/preferences.mjs": { "lines": 98, - "branches": 94, - "functions": 91, + "branches": 96, + "functions": 98, "statements": 98 }, "workspace/prompt-blocks.mjs": { @@ -449,6 +449,12 @@ "functions": 98, "statements": 98 }, + "workspace/system-appearance.mjs": { + "lines": 98, + "branches": 93, + "functions": 98, + "statements": 98 + }, "workspace/system-controls.mjs": { "lines": 98, "branches": 87, @@ -463,15 +469,15 @@ }, "workspace/system-state.mjs": { "lines": 98, - "branches": 92, - "functions": 92, + "branches": 93, + "functions": 98, "statements": 98 }, "workspace/system.mjs": { - "lines": 93, - "branches": 85, - "functions": 69, - "statements": 93 + "lines": 96, + "branches": 92, + "functions": 84, + "statements": 96 }, "workspace/theme.mjs": { "lines": 98, diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 2d7a032d..d55035b3 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -6,6 +6,7 @@ import { createWorkspaceIcon } from './icons.mjs'; import { navigateTo } from './state.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { openCopyFallback } from './workspace-dialogs.mjs'; +import { displayPreferences } from './preferences.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -196,7 +197,10 @@ function prefixedChip(prefix, text) { * `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File & * provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`. */ -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) { +/** D-NEW-S3-1: the inspector's line while "Load metadata on selection" is off. */ +export const DEFERRED_TEXT = 'Metadata loads when you ask for it.'; + +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -235,6 +239,13 @@ export function mountFileInspector(container, {api, sender, parentScope, variant function render(state) { const inner = el('div', undefined, full ? undefined : 'detail-inner'); if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;} + // D-NEW-S3-1: "Load metadata on selection" is off, so the file's details wait for an explicit request. + if (state.deferred) { + container.append(inner); + const status = el('p', DEFERRED_TEXT, 'small'); status.setAttribute('role', 'status'); inner.append(status); + if (onLoad) {const load = createButton('Load metadata'); contentScope.listen(load, 'click', onLoad); inner.append(load);} + return; + } const record = state.record; if (!record) return; const {file, library} = record; @@ -326,8 +337,10 @@ export function mountFileInspector(container, {api, sender, parentScope, variant section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' : 'Current file at this source; historical bytes are not verified.', 'small')); const path = el('dd', record.identity.path ?? 'Not available'); + // System › Appearance "Show file paths" off: the Path row is left out; nothing else changes. + const showPath = !local && displayPreferences()?.showFilePaths !== false; const facts = file && definitionList([ - ['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)], + ['Source', file.source_label], ...(showPath ? [['Path', path]] : []), ['Size', formatBytes(file.size)], ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], ['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type], ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], @@ -335,8 +348,9 @@ export function mountFileInspector(container, {api, sender, parentScope, variant if (facts) { // D38: "Show in folder" follows the Path value. const target = api ? revealTarget(record.identity) : null; - if (target) path.append(' ', revealButton(target)); section.append(facts); + if (target && showPath) path.append(' ', revealButton(target)); + else if (target) section.append(revealButton(target)); } else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small')); // The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line. if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small')); diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index d133eba0..c61daf46 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -1,6 +1,17 @@ import { noun } from './plural.mjs'; /** Identity is the source and relative path; content hashes may be shared by files. */ +/** + * The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps + * its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System › + * Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable). + */ +export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) { + if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' }; + if (saved || !preferences) return { layout: 'grid', density: 'comfortable' }; + return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' }; +} + export function imageKey(image) { return `${image.root_index}:${image.path}`; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 1ac497a6..a3ebd061 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,5 +1,5 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs'; +import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio, initialImagesView } from './images-state.mjs'; import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; @@ -15,6 +15,7 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs'; import { openMetadataScan } from './activity-scan.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { imagesFilters, imagesViewState } from './saved-views.mjs'; +import { displayPreferences } from './preferences.mjs'; import { createTagButton, onImageTagsSaved } from './image-tagging.mjs'; import { followImageTagSaves, openCardTagging } from './images-tagging.mjs'; import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs'; @@ -111,7 +112,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const { resolveUrl } = window.ApiClient; const sender = createPromptSender({scope}); let detailInspector; - const inspection = createInspectionController({api}); + // System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js. + const preferences = displayPreferences(); + const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false}); scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next))); scope.cleanup(() => inspection.dispose()); scope.cleanup(() => detailInspector?.dispose()); @@ -119,9 +122,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const prior = embedded || saved || favorites ? null : lastVisit; // A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6). const visit = prior?.stale ? null : prior; - let density = prior?.density || 'comfortable'; + const start = initialImagesView({prior, saved, preferences}); + let density = start.density; let metrics = {...INITIAL_METRICS}; - let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; + let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false; // Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip. let roots = [], folders = []; @@ -287,7 +291,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage selection = imageKey(image); for (const video of details.querySelectorAll('video')) releaseVideo(video); detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', - onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); + onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); @@ -331,6 +335,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const text = el('div'); const title = el('h3', cardTitle(image)); title.title = image.path; text.append(title, el('p', cardSubtitle(image))); + // D97: "Show image details on cards" off hides the title and file line; Info and Tag stay. + if (preferences?.showImageInfo === false) text.hidden = true; const actions = el('div', undefined, 'image-card-actions'); const inspectButton = createButton('', 'icon-btn'); inspectButton.append(createWorkspaceIcon('info')); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 290797b9..d9a0ec5f 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -344,9 +344,15 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter = }; } -const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null }); +const emptyState = () => ({ loading: false, error: false, deferred: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null }); -export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) { +/** + * One selection's inspection. #249 System S3 (Appearance): `autoLoad: false` ("Load metadata on selection" off) makes a + * selection publish `deferred` instead of reading, and `load()` reads it once on request; `cache: false` ("Cache + * metadata in this browser" off) re-reads the selected file when it is selected again, where the default reuses the one + * current record. A read in flight is shared either way. + */ +export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter, autoLoad = true, cache = true }) { let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false; let attempted, hasAttempt = false; const listeners = new Set(); @@ -369,13 +375,19 @@ export function createInspectionController({ api, extractFacts, adapter = rooted try { adapter.validate(image); const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image); - if (current?.key === key && current.scope === scope && current.path === path) return pending; + if (current?.key === key && current.scope === scope && current.path === path && (cache || state.loading || state.deferred)) return pending; selected = { key, scope, path }; } catch { // The loader publishes validation errors after invalidating the previous source. } loader.dispose(); loader = makeLoader(); current = selected; attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true; + if (!autoLoad && selected) { pending = null; publish({ deferred: true }); return undefined; } + return read(attempted); + }, + /** Read a deferred selection (the inspector's "Load metadata"); a read already made or running is reused. */ + load() { + if (disposed || !hasAttempt || !state.deferred) return pending ?? undefined; return read(attempted); }, retry() { diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index c4e4869f..608f3e96 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -2,7 +2,7 @@ import {createScope, createButton} from '../shared/toolkit/core.js'; import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; -import {readPreferences} from './preferences.mjs'; +import {displayPreferences, readPreferences} from './preferences.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. @@ -74,15 +74,31 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();}); 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. + const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true; + let detailsToggle = null; + if (collapsed) { + detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false'); + detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle); + } 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 = true; + local.listen(detailsToggle,'click',() => { + details.hidden = !details.hidden; + detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; + detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); + }); + } 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(), + onLoad:() => inspectionController.load?.(), onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); function closeViewer(restore = true) { if (closed) return; diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs index 96cb095c..a21b0294 100644 --- a/web/js/workspace/preferences.mjs +++ b/web/js/workspace/preferences.mjs @@ -91,6 +91,11 @@ export function writePreferences(dirty, getStorage = defaultStorage, { baseline return { persisted: true, conflicts }; } +/** The current values for a display consumer, or null on a page without gallery-settings.js (a unit test). */ +export function displayPreferences(getStorage = defaultStorage) { + try { return readPreferences(getStorage).values; } catch { return null; } +} + export function subscribePreferences(win, onChange, getStorage = defaultStorage) { const listener = event => { if (event.key !== null && event.key !== PREFERENCES_KEY) return; diff --git a/web/js/workspace/system-appearance.mjs b/web/js/workspace/system-appearance.mjs new file mode 100644 index 00000000..4969933b --- /dev/null +++ b/web/js/workspace/system-appearance.mjs @@ -0,0 +1,130 @@ +/** + * System › Appearance (#249 System S3 Task 8): the concept's `renderSystem` "appearance" body (app.js 676–678), one + * `section.surface` "Browsing & display" of `.setting-row`s whose values are the drafts of the one Save row. + * + * - The browser rows are the display defaults this browser keeps in the shared `gallerySettings` object + * (preferences.mjs, Q3): Default layout and Thumbnail size (`workspaceLayout`, `workspaceDensity`), the metadata, + * quality (Optimized→`medium`, Original→`high`, Fast→`low`), video and path rows. Images, the inspector and the + * viewer read them on their next visit; a toolbar change still applies to the current view only. + * - "Open from ComfyUI" (New tab→`newtab`, Popup window→`popup`) and "Node search auto-hide (seconds)" (a whole number + * 0–300) are the server's `webui_display_mode` and `result_timeout`, saved with the settings revision (#220). + * - "Continuous browsing" is the concept's fixed row: there is no paging or column control (README ruling). + * - D97: added rows for the legacy gallery settings the concept leaves out: "Show image details on cards" + * (`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. + * + * Controls follow the store: a value read later (the host settings, another tab's change) repaints them. + */ +import { settingRow, operationRow } from './system-controls.mjs'; +import { preferenceDefaults } from './preferences.mjs'; + +export const RESULT_TIMEOUT_MAX = 300; +const BOOLEAN = Object.freeze({ type: 'toggle' }); + +/** + * The concept's rows in its order, then the D97 rows: `[id, label, description, domain, key, control]`. A select's + * options are `[stored value, concept label]`. + */ +export const APPEARANCE_ROWS = Object.freeze([ + ['default-view', 'Default layout', 'Choose your starting image layout.', 'browser', 'workspaceLayout', + { type: 'select', options: [['grid', 'Grid'], ['list', 'List'], ['masonry', 'Masonry']] }], + ['density', 'Thumbnail size', 'Readable previews by default; no fixed column-count setting.', 'browser', 'workspaceDensity', + { type: 'select', options: [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']] }], + 'continuous', + ['metadata-auto', 'Load metadata on selection', 'Use the same inspector in the gallery and image viewer.', 'browser', 'autoLoadMetadata', BOOLEAN], + ['metadata-cache', 'Cache metadata in this browser', 'Reuse fetched metadata until it changes or you clear this cache.', 'browser', 'cacheMetadata', BOOLEAN], + ['image-quality', 'Image loading quality', 'Preserve original / optimized / fast image-loading preferences.', 'browser', 'imageQuality', + { type: 'select', options: [['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']] }], + ['video-auto', 'Autoplay videos', 'Videos stay within the same gallery and use native playback controls.', 'browser', 'videoAutoplay', BOOLEAN], + ['video-muted', 'Mute videos by default', 'Keep playback quiet while browsing.', 'browser', 'videoMute', BOOLEAN], + ['video-loop', 'Loop videos', 'Repeat a selected clip until you navigate away.', 'browser', 'videoLoop', BOOLEAN], + ['paths', 'Show file paths', 'Paths appear under File & provenance, not across every card.', 'browser', 'showFilePaths', BOOLEAN], + ['open-mode', 'Open from ComfyUI', 'Keep the node pack’s popup / new-tab preference.', 'host', 'webui_display_mode', + { 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. + ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN], + ['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])); + +/** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */ +export function resultTimeout(text) { + const value = String(text).trim(); + if (!/^\d+$/.test(value)) return null; + const seconds = Number(value); + return seconds <= RESULT_TIMEOUT_MAX ? seconds : null; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** The concept's fixed "Continuous browsing" row: `div > h3 + p` and the "Infinite scroll" chip. */ +function continuousRow() { + const row = el('div', undefined, 'setting-row'); + const text = el('div'); + text.append(el('h3', 'Continuous browsing'), + el('p', 'Images load automatically as you scroll in Grid, List and Masonry. Batch sizing is handled internally.')); + row.append(text, el('span', 'Infinite scroll', 'chip blue')); + return row; +} + +/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */ +function controlOf(node, id) { + if (node.dataset?.setting === id) return node; + for (const child of node.children ?? []) { + const found = controlOf(child, id); + if (found) return found; + } + return null; +} + +/** Show a stored value in its control; a value not read yet leaves the control disabled. */ +function paint(input, control, value) { + input.disabled = value === undefined; + if (value === undefined) return; + if (control.type === 'toggle') input.checked = value === true; + else if (document.activeElement !== input || control.type === 'select') input.value = String(value); +} + +/** Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes. */ +export function mountAppearance(container, { scope, drafts }) { + 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 }); + } + surface.append(operationRow({ title: 'Reset display defaults', + description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope, + onClick: () => { + const defaults = preferenceDefaults(); + for (const key of DISPLAY_KEYS) drafts.set('browser', key, defaults[key]); + } })); + const repaint = () => { for (const { input, control, domain, key } of controls) paint(input, control, drafts.get(domain, key)); }; + scope.cleanup(drafts.subscribe(repaint)); + repaint(); + return [surface]; +} diff --git a/web/js/workspace/system-state.mjs b/web/js/workspace/system-state.mjs index 2c24e67e..49f3a5c5 100644 --- a/web/js/workspace/system-state.mjs +++ b/web/js/workspace/system-state.mjs @@ -180,9 +180,33 @@ export function createSystemDrafts(adapters) { return saving; } + /** A value the control could not take (System S3: a timeout outside 0–300): no draft, and the control is highlighted. */ + function reject(domain, key) { + requireDomain(domain); + drafts[domain].delete(key); + failedKeys[domain].add(key); + notify(); + } + + /** + * Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay + * and are highlighted, and the status says the settings changed elsewhere. + */ + function conflict(domain, keys) { + requireDomain(domain); + if (!keys.length) return; + for (const key of keys) failedKeys[domain].add(key); + outcome = { [domain]: 'conflict' }; + notify(); + } + return { load, reloadHost, + reject, + conflict, + /** The saved values of a domain (its last read or save), not the drafts. */ + saved: domain => { requireDomain(domain); return { ...baseline[domain] }; }, get, set, dirty, diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index 0fa76585..1f6437ca 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -4,7 +4,8 @@ * `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row * (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is * truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the - * per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs). + * per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs) and + * Appearance (system-appearance.mjs) are native. * * Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its * listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session. @@ -14,6 +15,8 @@ import { SYSTEM_SECTIONS, navigateTo } from './state.mjs'; import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs'; import { sectionNav } from './system-controls.mjs'; import { healthNotice, mountOverview } from './system-overview.mjs'; +import { mountAppearance } from './system-appearance.mjs'; +import { readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs'; const SETTINGS_URL = '/prompt_manager/settings'; const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' }); @@ -24,7 +27,6 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex * settings, thumbnails, duplicates and cache tools. */ export const SECTION_HANDOFFS = Object.freeze({ - appearance: [GALLERY, ADMIN], sources: [ADMIN, GALLERY], integrations: [ADMIN], tagging: [ADMIN], @@ -46,12 +48,16 @@ function el(tag, text, className) { /** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */ const undelivered = async () => { throw new Error(NOT_DELIVERED); }; -/** The page session's drafts (Q10); the host domain reads and saves the server settings with their revision (#220). */ +/** + * The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write + * says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds. + * The host domain reads and saves the server settings with their revision (#220). + */ export function systemDrafts(api) { drafts ??= createSystemDrafts({ - readBrowser: () => ({}), + readBrowser: () => readPreferences().values, readRuntime: () => ({}), - writeBrowser: undelivered, + writeBrowser: changes => ({ persisted: writePreferences(changes).persisted }), writeRuntime: undelivered, async readHost() { const result = await api.get(SETTINGS_URL, { cache: 'no-store' }); @@ -80,17 +86,19 @@ function handoff(section) { function markFailed(layout, store) { const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)])); for (const control of layout.querySelectorAll('[data-setting]')) { - if (failed.has(control.dataset.setting)) control.setAttribute('aria-invalid', 'true'); + // A row's control names its stored key when that differs from the row id (Appearance's `data-key`). + if (failed.has(control.dataset.key ?? control.dataset.setting)) control.setAttribute('aria-invalid', 'true'); else control.removeAttribute('aria-invalid'); } } /** - * The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the + * The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), and Maintenance's notice with "Run health checks" above the * hand-off to the maintenance tools that are not native yet (D123). */ export const DELIVERED_SECTIONS = Object.freeze({ overview: mountOverview, + appearance: mountAppearance, maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')], }); @@ -101,11 +109,19 @@ export const DELIVERED_SECTIONS = Object.freeze({ * Activity store, which follows the jobs a section starts. * * The host values are read on mount (and by the store again after a conflict), so a save carries the current revision; - * until a read returned one, a host save fails without a request (system-state.mjs). + * until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on + * mount and again whenever another page changes them (a `storage` event). */ export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) { const store = systemDrafts(api); store.reloadHost(); + store.load('browser'); + // Another tab or the legacy gallery changed the display defaults: clean values follow it; a draft it overtook stays + // and is flagged (D92's conflict status). + scope.cleanup(subscribePreferences(window, next => { + const { conflicts } = reconcilePreferences({ baseline: store.saved('browser'), drafts: store.changes('browser'), next: next.values }); + store.load('browser').then(() => store.conflict('browser', conflicts)); + })); const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope, onSelect: id => navigate(`#/system/${id}`) }); const layout = el('div', undefined, 'settings-layout'); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index c7088b31..ace96b00 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -91,6 +91,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ } /* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */ .operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ } +/* D97: Appearance's legacy display rows. "Show image details on cards" off keeps a card's Info and Tag at the end of + its row; "Collapse viewer details by default" gives the viewer's stage the whole width while its details are hidden. */ +.media-info > div[hidden] + .image-card-actions { margin-inline-start: auto; /* D97 */ } +.viewer-layout:has(> #viewer-details[hidden]) { grid-template-columns: minmax(0, 1fr); /* D97 */ } @media (max-width: 700px) { .stat-grid { gap: 8px; } .stat { padding: 16px; }