diff --git a/TESTING.md b/TESTING.md index d2cc8341..dd91e90f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1397,7 +1397,7 @@ The Images page renders the concept's DOM with real data. Its controls, top to b (click, Enter, Space); Info selects the card into the side panel; the star toggles the favorite; arrows, Home and End move between cards. Bounded rendering keeps the DOM small, and returning to Images restores filters, layout, size, selection and - scroll. + scroll (a layout or size the toolbar never changed follows newer defaults, D157). - **Continuation** (`#gallery-continuation`): scrolling near it loads the next page; `Load more images` and `Retry` do the same by hand, with a toast per batch. - **Viewer** (`dialog#viewer-dialog`): " · <filename>", the concept toolbar @@ -1686,6 +1686,29 @@ Recorded deviations on this page (spec §8): `scroll-margin-block-end: 96px`, so it is never under the toast. `Rescan toast does not cover the discovery alert it leaves` ends the window inside the toast's band at 1600 and 390 pixels and measures both boxes. +- **D157** (#451, maintainer request, beyond the concept): Images opens in **Masonry** + when this browser has no Default layout (`gallery-settings.js` + `DEFAULT_SETTINGS.workspaceLayout`); the concept starts at Grid (`state.view`, app.js + 208). A stored choice is kept, and an invalid value reads as Masonry. Saved views, + favorites, collections and the embedded galleries keep their fixed Grid/Comfortable. + The return to Images (the last visit) keeps a layout or thumbnail size its toolbar + changed; a value it kept from the defaults it started with follows a Default layout or + Thumbnail size saved since, in this tab or another (a toolbar List then Default = + Masonry stays List; a toolbar List then back to Grid counts as untouched). A cover crop + of a generated image (Grid and List card art, Library card art, the side panel's and + History's inspector cover, the Tag image preview) uses `object-position: 50% 20%` + instead of the concept's centre: it acts only when the image is taller than its box + (portraits keep heads and faces), and a wider image stays centred. Masonry shows the + whole image and keeps the centre. The concept comparisons (`openProduction`) and the + specs of the Grid's own geometry start production from the concept's state, a stored + Default layout of Grid (`startInGrid`, concept-inventory.mjs, only while the browser + stored nothing); the comparisons do not measure `object-position`. + `a fresh browser opens Images in Masonry (#451)`, `a Default layout saved later reaches + the next Images visit in this tab (#451)`, `... in another tab ... (#451)` and `cover + crops keep the top of a portrait image; Masonry stays centred (#451)` + (workspace-system.spec.mjs) cover it. A browser whose legacy gallery wrote the old + default (`"grid"`) keeps Grid until Default layout is changed or "Reset display + defaults" is saved. `tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes: diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 5ef629f3..9234a50d 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -118,9 +118,27 @@ export async function openConcept(page,route,theme,motion='no-preference') { await expect(page.locator('#main #page-title')).toBeVisible(); } +/** + * D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid. + * A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default + * layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win). + */ +export async function startInGrid(page) { + if (page.conceptLayoutSeeded) return; + page.conceptLayoutSeeded = true; + await page.addInitScript(() => { + try { + if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return; + sessionStorage.setItem('pm-d157-seeded', '1'); + if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })); + } catch { /* A page without storage keeps the production default. */ } + }); +} + /** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */ export async function openProduction(page,route,theme,motion='no-preference') { await page.emulateMedia({reducedMotion:motion}); + await startInGrid(page); await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`); await selectTheme(page,theme); await expect(page.locator('#workspace-main #page-title')).toBeVisible(); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 7abdabb5..b3644736 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -1,5 +1,6 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; import {test,expect} from './fixtures.mjs'; +import { startInGrid } from './concept-inventory.mjs'; const workspace='/prompt_manager/workspace#/images'; const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true}); @@ -252,6 +253,8 @@ test('responsive gallery defers observed size writes and still fits the new view });} }; }); + // The Grid's observed sizes (D157: the default layout is Masonry). + await startInGrid(page); await page.setViewportSize({width:1280,height:900});await page.goto(workspace); await expect(page.locator('.media-card')).toHaveCount(4); await page.setViewportSize({width:390,height:844}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 0cb08c9a..f4d7a2cf 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1,6 +1,6 @@ import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from './fixtures.mjs'; -import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs'; +import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs'; const workspace = '/prompt_manager/workspace#/images'; const cards = page => page.locator('article[data-image-key]'); @@ -814,6 +814,8 @@ test('leaving during Rescan cancels it without replacing the next workspace', as test('Images uses approved geometry while keeping real controls', async ({page,request}) => { await request.post('/__test__/reset',{data:{workspace_visual:true}}); + // The approved Grid geometry (D157: the default layout is Masonry). + await startInGrid(page); await page.setViewportSize({width:1600,height:1100}); await page.goto(workspace); await expect(cards(page)).toHaveCount(24); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index db75c85a..f5cf8ed9 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -481,17 +481,19 @@ test('defaults apply to a new visit but not to the current view or a saved view' 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'); + // #451: a fresh browser's Default layout is Masonry. + await expect(page.locator('#default-view')).toHaveValue('masonry'); + await page.locator('#default-view').selectOption('grid'); 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. + expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']); + // Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the + // size the toolbar never changed follows the newer default (#451). 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'); + await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact'); // The saved view opens as before. await page.goto(`${workspace}#/saved/${view.id}`); await expect(imageCards(page).first()).toBeVisible(); @@ -501,15 +503,109 @@ test('defaults apply to a new visit but not to the current view or a saved view' 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-layout', 'grid'); await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact'); - await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true })) + await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true })) .toHaveAttribute('aria-pressed', 'true'); } finally { await request.delete(`/prompt_manager/saved-views/${view.id}`); } }); +/** Choose a Default layout in System › Appearance and save it. */ +async function saveDefaultLayout(page, value) { + await openAppearance(page); + await page.locator('#default-view').selectOption(value); + await saveAppearance(page, 'Saved in this browser.'); +} + +const imageLayoutOf = page => page.locator('#workspace-main .image-grid'); +const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true }); + +test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => { + await openImages(page); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry'); + await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true'); + // Nothing was stored for it: the default is the gallery settings' own. + expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull(); + // A stored explicit choice is kept. + await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }))); + await page.reload(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid'); +}); + +test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => { + // The visit starts from Grid, then System saves Masonry without a reload. + await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); }); + await openImages(page); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid'); + await page.locator('#system-nav a[data-route="system"]').click(); + await expect(page.locator('#workspace-main .settings-layout')).toBeVisible(); + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click(); + await expect(page.locator('#timeout')).toBeEnabled(); + await page.locator('#default-view').selectOption('masonry'); + await saveAppearance(page, 'Saved in this browser.'); + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry'); + await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true'); +}); + +test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => { + await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); }); + await openImages(page); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid'); + const other = await context.newPage(); + try { + await saveDefaultLayout(other, 'list'); + } finally { + await other.close(); + } + await page.locator('#primary-nav a[data-route="library"]').click(); + await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible(); + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list'); + // A toolbar choice afterwards is this view's own again: a return keeps it. + await layoutButton(page, 'Masonry').click(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry'); + await page.locator('#primary-nav a[data-route="library"]').click(); + await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible(); + await page.locator('#primary-nav a[data-route="images"]').click(); + await expect(imageCards(page).first()).toBeVisible(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry'); +}); + +// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre. +test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => { + const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition); + const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first(); + await openImages(page); + await layoutButton(page, 'Grid').click(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid'); + await expect(portrait).toBeVisible(); + expect(await position(portrait)).toBe('50% 20%'); + await layoutButton(page, 'List').click(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list'); + expect(await position(portrait)).toBe('50% 20%'); + // The side panel's cover (also History's media inspector). + await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click(); + await expect(panel(page).locator('.detail-cover')).toBeVisible(); + expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%'); + await layoutButton(page, 'Masonry').click(); + await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry'); + expect(await position(portrait)).toBe('50% 50%'); + // The Library's card art and a prompt history's shared gallery. + await page.goto(`${workspace}#/library`); + const art = page.locator('#workspace-main .prompt-card .card-art img').first(); + await expect(art).toBeVisible(); + expect(await position(art)).toBe('50% 20%'); + await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`); + await expect(imageCards(page).first()).toBeVisible(); + expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%'); +}); + test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => { await openAppearance(page); await page.locator('#default-view').selectOption('list'); @@ -787,12 +883,13 @@ test('reset restores defaults without touching other keys', async ({ page }) => 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'); + // #451 D157: the default layout is Masonry. + await expect(page.locator('#default-view')).toHaveValue('masonry'); 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', + expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry', videoMute: true, showImageInfo: true }); }); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 68de229e..cfe0d78b 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs'; import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from './fixtures.mjs'; +import { startInGrid } from './concept-inventory.mjs'; test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); const workspace = '/prompt_manager/workspace'; @@ -415,6 +416,8 @@ test('zoomed virtual Images keeps the visible real media mounted during native s test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => { await request.post('/__test__/reset', { data: { workspace_visual: true } }); + // The Grid's footer reservations (D157: the default layout is Masonry). + await startInGrid(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); await expect(cards).toHaveCount(24); diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index 7f85fd9a..54b22f4e 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -64,12 +64,15 @@ test("infinite scrolling is off by default and must be a boolean", () => { test("the workspace keys have their own defaults and validation", () => { const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js"); - assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid"); + // #451: Images opens in Masonry unless the browser chose otherwise. + assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry"); assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable"); assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]); assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]); assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry"); - assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid"); + assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid"); + assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry"); + assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry"); assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large"); assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable"); // The legacy gallery's own view mode is separate: it has no masonry. diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 84b0fd1b..383c3b21 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -525,7 +525,35 @@ test('defaults apply to a new visit but not to the current view or a saved view' 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' }); + // #451: a preference object without a layout falls back to the default layout, Masonry. + assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' }); +}); + +test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => { + const { initialImagesView } = await source; + const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' }; + const started = { layout: 'grid', density: 'comfortable' }; + // The visit kept the defaults it started from: the newer defaults win. + assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }), + { layout: 'masonry', density: 'compact' }); + // A toolbar choice is the current view's own, whatever the defaults became; each value on its own. + assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }), + { layout: 'list', density: 'compact' }); + assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }), + { layout: 'masonry', density: 'large' }); + // Unchanged defaults: the visit returns as it was. + assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }), + { layout: 'masonry', density: 'compact' }); + // A saved view and an embedded gallery never get a prior; without preferences the prior is kept. + assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }), + { layout: 'grid', density: 'comfortable' }); +}); + +test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => { + const { visitDefaults } = await source; + assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' }); + assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' }); + assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' }); }); test('listingFilter is the listing query as an object, without paging (#392)', async () => { diff --git a/tests/js/workspace-preferences.test.js b/tests/js/workspace-preferences.test.js index 07b18cad..3eb01e06 100644 --- a/tests/js/workspace-preferences.test.js +++ b/tests/js/workspace-preferences.test.js @@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => { // Invalid workspace values fall back to the defaults, as every legacy key does. store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' }); const fallback = module.readPreferences(() => store).values; - assert.equal(fallback.workspaceLayout, 'grid'); + assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout assert.equal(fallback.workspaceDensity, 'comfortable'); assert.equal(fallback.videoMute, true); // The legacy normalizer knows the new keys and still keeps unknown ones. @@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => { module.resetTabPreferences(); const read = module.readPreferences(denied); assert.equal(read.persisted, false); - assert.equal(read.values.workspaceLayout, 'grid'); + assert.equal(read.values.workspaceLayout, 'masonry'); const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied); assert.deepEqual(result, { persisted: false, conflicts: [] }); // The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it. diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js index 497df7f6..f45fb008 100644 --- a/tests/js/workspace-system-appearance.test.js +++ b/tests/js/workspace-system-appearance.test.js @@ -162,7 +162,8 @@ 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 }); + // #451: the default layout is Masonry. + assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', 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()); diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js index 3ea591d0..1fcb3dbd 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -40,7 +40,8 @@ videoAutoplay: false, videoMute: true, videoLoop: true, - workspaceLayout: "grid", + // #451: Images opens in Masonry unless this browser chose another layout. + workspaceLayout: "masonry", workspaceDensity: "comfortable", }); diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 14992eb4..cdb12dca 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -1,15 +1,31 @@ import { noun } from './plural.mjs'; /** Identity is the source and relative path; content hashes may be shared by files. */ +/** The layout and size a saved view, an embedded gallery and a page without readable preferences use. */ +const FIXED_VIEW = Object.freeze({ layout: 'grid', density: 'comfortable' }); + /** - * 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). + * The defaults a new Images visit starts from: System › Appearance's (`preferences`, preferences.mjs values; Masonry + * when the layout is missing, #451), or the fixed Grid/Comfortable when they are unavailable (null). + */ +export function visitDefaults(preferences) { + if (!preferences) return { ...FIXED_VIEW }; + return { layout: preferences.workspaceLayout || 'masonry', density: preferences.workspaceDensity || 'comfortable' }; +} + +/** + * The layout and thumbnail size an Images visit starts with (#249 System S3). A saved view starts as before, at Grid + * and Comfortable; any other new visit starts from `visitDefaults(preferences)`. The last visit (`prior`, the current + * view) keeps a value its toolbar changed; a value it kept from the defaults it started from (`prior.defaults`) follows + * defaults saved since, in this tab or another (#451). A prior without `defaults` keeps its own values. */ 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' }; + if (!prior) return saved ? { ...FIXED_VIEW } : visitDefaults(preferences); + const own = { layout: prior.layout || FIXED_VIEW.layout, density: prior.density || FIXED_VIEW.density }; + if (!prior.defaults || !preferences) return own; + const current = visitDefaults(preferences); + const follow = key => own[key] === prior.defaults[key] ? current[key] : own[key]; + return { layout: follow('layout'), density: follow('density') }; } export function imageKey(image) { diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index bfde7afe..96593555 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, initialImagesView, listingFilter } from './images-state.mjs'; +import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, visitDefaults, listingFilter } from './images-state.mjs'; import { createImageCard } from './image-card.mjs'; import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; @@ -156,6 +156,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no // toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere. const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences}); + // The defaults this visit started from: a return follows defaults saved since unless the toolbar changed a value (#451). + const startDefaults = visitDefaults(embedded ? null : preferences); let density = start.density; let metrics = {...INITIAL_METRICS}; let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; @@ -182,7 +184,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // A Set category write still in flight may change what the saved collection shows: the return reloads instead. // A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the // Images return state (#249 D51, and M2 for saved views). - if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null; + if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 57ae93ce..527f9675 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -169,6 +169,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; } .list-grid .image-open video { height: 95px; aspect-ratio: auto; } +/* #451 D157: a cover crop of a generated image keeps its top (heads, faces) instead of its middle. The vertical 20% + acts only when the image is taller than its box; a wider image overflows sideways and stays centred. Masonry shows + the whole image (contain) and keeps the centre. */ +.image-grid:not(.masonry-grid) .image-open :is(img, video), .card-art img, .detail-cover, .tag-image-preview { + object-position: 50% 20%; /* D157 */ +} + /* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media, titles and states that the concept's one demo image never shows. */ /* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the