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`): "
· ", 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 `#/` 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