fix(images): open Masonry by default, keep a saved Default layout, crop portraits from the top (#451) (#455)

- A Default layout or Thumbnail size saved in System now reaches the next Images
  visit in an open tab (this tab or another): the last visit records the
  defaults it started from, and a value its toolbar never changed follows newer
  defaults. A toolbar choice stays the current view's own.
- Images opens in Masonry when the browser has no stored Default layout
  (gallery-settings.js DEFAULT_SETTINGS.workspaceLayout); stored choices are kept.
- Grid/List card art, Library card art, the inspector cover and the Tag image
  preview crop with object-position 50% 20%, so portrait images keep their top.
- D157 in TESTING.md; concept comparisons start from the concept's stored Grid.
This commit is contained in:
Vito
2026-10-29 02:55:55 +00:00
committed by GitHub
parent aad3a37bcf
commit dff08bc161
14 changed files with 230 additions and 26 deletions
+24 -1
View File
@@ -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`): "<title> · <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:
+18
View File
@@ -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();
+3
View File
@@ -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});
+3 -1
View File
@@ -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);
+106 -9
View File
@@ -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 });
});
+3
View File
@@ -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);
+5 -2
View File
@@ -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.
+29 -1
View File
@@ -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 () => {
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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());
+2 -1
View File
@@ -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",
});
+22 -6
View File
@@ -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) {
+4 -2
View File
@@ -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');
+7
View File
@@ -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