From dff08bc1615ab14ddab546313e6f6e70c7a6b5bd Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 02:55:55 +0000 Subject: [PATCH 1/2] 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. --- TESTING.md | 25 +++- tests/browser/concept-inventory.mjs | 18 +++ tests/browser/prompt-inspector.spec.mjs | 3 + tests/browser/workspace-images.spec.mjs | 4 +- tests/browser/workspace-system.spec.mjs | 115 +++++++++++++++++-- tests/browser/workspace-visual.spec.mjs | 3 + tests/js/gallery-settings.test.js | 7 +- tests/js/workspace-images-state.test.js | 30 ++++- tests/js/workspace-preferences.test.js | 4 +- tests/js/workspace-system-appearance.test.js | 3 +- web/js/gallery-settings.js | 3 +- web/js/workspace/images-state.mjs | 28 ++++- web/js/workspace/images.mjs | 6 +- web/js/workspace/workspace.css | 7 ++ 14 files changed, 230 insertions(+), 26 deletions(-) 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 From 66e567cc95ed00b34698924cde49cb0f982c013a Mon Sep 17 00:00:00 2001 From: Vito <vito.sansevero@gmail.com> Date: Thu, 29 Oct 2026 04:04:52 +0000 Subject: [PATCH 2/2] fix(workspace): remove the 1-5 prompt rating from the workspace (#447) (#461) The star is only for favorites (maintainer ruling 2026-10-28). The workspace no longer shows or edits a prompt rating: no Rating field in the Library editor, no stars on Library cards, no "Highest rated" sort on Images or the Library, and no rating column or rated-images total in System > Statistics. The editor never sends `rating`, so a workspace save keeps a stored rating as it is. A saved view or Library visit with the retired rating sort opens on Recently added. Data and API are unchanged; legacy pages keep their ratings. Recorded as deviation D153 (amends D14, D23, D96); concept comparisons map the concept through it. --- TESTING.md | 67 ++++++++++++--- tests/browser/images-concept.spec.mjs | 3 +- tests/browser/library-concept.spec.mjs | 27 ++++-- .../library-editor-canonical-comparison.mjs | 5 +- tests/browser/system-concept.spec.mjs | 15 ++-- tests/browser/workspace-images.spec.mjs | 7 +- .../browser/workspace-library-editor.spec.mjs | 43 ++++++---- tests/browser/workspace-library.spec.mjs | 31 +++++-- tests/browser/workspace-saved-views.spec.mjs | 25 ++++++ tests/browser/workspace-system.spec.mjs | 11 ++- tests/js/workspace-images-state.test.js | 9 +- .../js/workspace-library-editor-state.test.js | 84 ++++++++++++++----- tests/js/workspace-library-format.test.js | 9 +- tests/js/workspace-library-state.test.js | 10 ++- tests/js/workspace-saved-views.test.js | 14 +++- tests/js/workspace-system-overview.test.js | 6 +- web/js/workspace/images-state.mjs | 6 +- web/js/workspace/images.mjs | 5 +- web/js/workspace/library-editor-state.mjs | 27 +++--- web/js/workspace/library-editor.mjs | 19 ++--- web/js/workspace/library-layout.mjs | 6 -- web/js/workspace/library-state.mjs | 2 +- web/js/workspace/library.mjs | 20 ++--- web/js/workspace/saved-views.mjs | 7 +- web/js/workspace/system-overview.mjs | 22 ++--- web/js/workspace/toolbar.mjs | 1 + 26 files changed, 314 insertions(+), 167 deletions(-) diff --git a/TESTING.md b/TESTING.md index dd91e90f..4d8daa7d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1386,8 +1386,8 @@ The Images page renders the concept's DOM with real data. Its controls, top to b - **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and Library title, and tag names), `#category-filter` "All categories" plus the - Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest - first and Name Z–A), and `Filters` (opens "Refine this view"). + Library's categories, `#sort` (Recently added, Name A–Z, then Oldest first and Name + Z–A; no Highest rated, D153), and `Filters` (opens "Refine this view"). - **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"), "Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders" or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library). @@ -1413,7 +1413,7 @@ How the earlier workspace Images filters moved (old → new). The legacy `/galle | Source | "Refine this view" → Source (D14); shown in the scope chip | | Folder | "Refine this view" → Folder (D14); shown in the scope chip | | Media (All media / Images / Videos) | "Refine this view" → Media (D14) | -| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) | +| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A (D13, D14; no Highest rated, D153) | | (none) | toolbar category (the effective category, D141) and "Refine this view" → Tags, through the linked positive (D13, D14) | | Refresh | Import → "Scan an output folder" → Rescan (D15) | | Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row | @@ -2404,7 +2404,7 @@ The shelf uses the shared concept toolbar and caption (E1) and the concept's `article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art` (at most two previews), `.card-content` (`.card-title` with the title `button` and the category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and -`.card-footer` (the rating and last-use line, then `.row-actions` with the positive +`.card-footer` (the last-use line, then `.row-actions` with the positive `button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`). `tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and the measured row heights on Chromium and touch. @@ -2414,8 +2414,8 @@ Recorded deviations on this page: "Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>" ("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the - browser reads as today), and "Not used yet" without one. Stars are `★` × rating with - a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words. + browser reads as today), and "Not used yet" without one. There are no rating stars + (D153, amending D23): every parent shows only the last-use words. The meta chip reads "− N negative variants" (singular for one). The title and Explore history are buttons that open `#/library/<id>`, as the concept's are; the trade-off against the old links is that they offer no open in a new tab, middle-click or URL on @@ -2576,6 +2576,48 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no Add tags); units `tests/js/workspace-library-selection.test.js`; Python `tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart. +### No 1–5 prompt rating in the workspace (#447) + +**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps +a 1–5 rating): the workspace shows and edits no prompt rating. The concept's rating +appears in four places, and production draws none of them: + +- Library cards: the concept's footer reads "★★★★★ · Used today"; production reads only + the last use (amends D23). `library-concept.spec.mjs` takes the stars and their " · " + out of the concept page itself before measuring (`dropConceptRatings`), so the footer + facts are compared at production's width; it first checks that every concept card had + them. +- Sort: the concept's "Highest rated" is not offered on Images or the Library (amends + D14); the comparisons drop it from the concept's options. A saved view or a Library + visit that still names `sort: "rating"` opens on Recently added; the server keeps such a + view as it was stored. +- Editor (New prompt, Edit recipe metadata, Create text revision): no `#recipe-rating` + "Rating" select; `library-editor-canonical-comparison.mjs` `mapConcept` drops the + concept's Rating field. The editor never sends `rating`. An edit sends only changed + fields, so a stored rating stays as it is; a new prompt and a text revision create an + unrated positive (the revised prompt keeps its own rating; the legacy admin page shows + the revision as unrated). A reuse that leaves metadata unapplied compares title, + category, tags and notes only. The edit token still covers `rating`, so a rating + changed on a legacy page while the editor is open is an ordinary metadata conflict + whose review shows no visible difference. +- System > Statistics: no "Avg. rating" column and no "Rated images" total (amends D96); + `system-concept.spec.mjs` maps the concept's table without that column. + +Data and API are unchanged: the `rating` column (`CHECK` 1–5), `PUT +/prompt_manager/prompts/{id}/rating`, `rating` in Library, Images and statistics +answers, and the server's `rating` sorts. The legacy pages keep their ratings until the +cutover (#334). Favorites (the image star) are unchanged. + +Tests: units `tests/js/workspace-library-editor-state.test.js` (a prompt rated 4 edited +and saved keeps 4, no `rating` in any body, no rating field), +`workspace-library-state.test.js`, `workspace-images-state.test.js`, +`workspace-saved-views.test.js`, `workspace-system-overview.test.js`, +`workspace-library-format.test.js`; browser `workspace-library-editor.spec.mjs` (the +fixture's positive stored with rating 4 still reads 4 after Edit, a revision, Add +negative and a reuse), `workspace-library.spec.mjs`, `workspace-images.spec.mjs`, +`workspace-saved-views.spec.mjs`, `workspace-system.spec.mjs` and the concept +comparisons above. + ### Prompt history page and recorded deviations (#249 PR F-a) The history page (`#/library/<id>`) takes the concept's heading, summary, negative @@ -3491,12 +3533,13 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati every check passed, some could not run, or issues need review. Close / Review missing links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up. Nothing is changed by a check. -- **D96** (Statistics): "Usage statistics" without the sample note: the concept's - "Runs by category" table (Category / Runs / Avg. rating) with the real runs and - average rating of eligible parents per category ("Uncategorized", "Not rated"), - then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the - legacy statistics, and Rated images: indexed images whose linked prompt is rated). - The Overview row says "Runs and ratings by category, with library totals." because +- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the + concept's "Runs by category" table without its Avg. rating column (Category / Runs) + with the real runs of eligible parents per category ("Uncategorized"), then a second + table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy + statistics; no Rated images). The API still reports `average_rating` and + `rated_images`; the workspace does not show them. + The Overview row says "Runs by category, with library totals." because that is what the dialog shows (the concept's own dialog shows only "Runs by category"); per-prompt run counts, prompt tags and dated runs are recorded, but no table of them exists yet. A failed read toasts "Statistics could not diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index b2156f52..67088ec8 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -92,7 +92,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1 const paths=new Map(manifest.images.map(image=>[image.filename,image.path])); // D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired). const toolbar={...reference.toolbar}; - toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item); + // D153 (#447): production offers no "Highest rated". + toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item); // D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category // COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped. // D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`), diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index 14de15c5..be8bf28e 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) { // Save view is the concept's own (saved views, #249 D16 retired). const toolbar={...library.toolbar}; toolbar.items=toolbar.items.map(item=>{ - // D14: the Library appends its Oldest first after the concept's three sorts. - if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']}; + // D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated". + if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']}; // D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order. if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}; return item; @@ -39,11 +39,11 @@ export function mapLibraryConcept(reference) { title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)}, // D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips. tags:sortedTags(card.tags), - // D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's - // run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs" - // and "Used today". The two-line clamp is + // D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The + // fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own + // "4 runs" and "Used today". The two-line clamp is // applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px). - footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); + footer:card.footer})); return {...reference,library:{...library,toolbar, // D35: the caption uses the singular for one. caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))}, @@ -139,9 +139,20 @@ async function sortConceptTags(page) { return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;}); } +/** + * D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and + * their separator are taken out of the concept page itself, so the footer facts are measured as production draws them. + * Returns the concept's original footer texts. + */ +async function dropConceptRatings(page) { + return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{ + const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;})); +} async function openConceptLibrary(page,theme,motion) { await openConcept(page,'library',theme,motion); await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + const footers=await dropConceptRatings(page); + expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/))); await page.mouse.move(0,0);await decoded(page);await settle(page); } async function openProductionLibrary(page,theme,motion) { @@ -295,7 +306,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'}); expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']); expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'}); - expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today'); + expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today'); expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]); expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]); expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); @@ -394,7 +405,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async( const reference=await conceptInventory(page); await openProductionLibrary(page,'dark','no-preference'); const actual=await inventoryOf(page,'#workspace-main'); - expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today')); + expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today')); expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); }); diff --git a/tests/browser/library-editor-canonical-comparison.mjs b/tests/browser/library-editor-canonical-comparison.mjs index d202e184..efec7ec6 100644 --- a/tests/browser/library-editor-canonical-comparison.mjs +++ b/tests/browser/library-editor-canonical-comparison.mjs @@ -25,9 +25,10 @@ export function inventoryOf(dialog) { demo:Boolean(dialog.querySelector('.demo-note')), }; } -// Documented differences only: prototype Save label, prototype demo note, and data-dependent values. +// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the +// concept's "Rating" select is not drawn. export function mapConcept(reference) { - return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}}; + return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}}; } // Flatten an inventory to path->value so a comparison reports exactly which leaves differ. diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index ccfec488..1d96d442 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -162,7 +162,6 @@ async function serverJson(request, url) { } const number = value => value.toLocaleString('en-US'); -const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`; /** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */ function mapOverviewConcept(reference, overview) { @@ -178,8 +177,8 @@ function mapOverviewConcept(reference, overview) { 'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }), // D93: the real sources, not samples. '2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }), - // D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage. - Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }), + // D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings). + Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }), }; const surfaces = reference.columns.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })); @@ -288,7 +287,10 @@ test('system comparison detects mutations', async ({ page, request }) => { expect(diffInventory(expected, await measure())).toEqual([]); }); -/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */ +/** + * D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the + * concept's "Avg. rating" column and a "Rated images" total are not drawn. + */ function mapStatisticsConcept(reference, statistics) { const [runs] = reference.tables; return { @@ -296,10 +298,11 @@ function mapStatisticsConcept(reference, statistics) { // No sample note (prototype copy); a second table follows the first. body: ['table', 'table'], tables: [ - { ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) }, + { ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'), + rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) }, { caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']], rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)], - ['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] }, + ['Images', number(statistics.totals.total_images)]] }, ], }; } diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index f4d7a2cf..87db1b57 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -897,7 +897,8 @@ test('images toolbar and filter row match the concept controls', async ({ page, // app.js toolbar() a347–360: search, category, sort, Save view, Filters. await expect(main.locator('search.toolbar > *')).toHaveCount(5); await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']); - await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']); + // D153 (#447): no "Highest rated", even with a rated prompt in the library. + await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']); const row = main.locator('.filter-row'); await expect(row.locator(':scope > *')).toHaveCount(5); await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']); @@ -916,8 +917,8 @@ test('images toolbar and filter row match the concept controls', async ({ page, await search(page).fill(''); await expect(cards(page)).toHaveCount(2); await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png'); - await sortBy(page).selectOption('rating'); - await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); + await sortBy(page).selectOption('name-desc'); + await expect(sortBy(page)).toHaveValue('name-desc'); await sortBy(page).selectOption('newest'); await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes'); await expect(cards(page)).toHaveCount(1); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index fa8346bc..42def0d0 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -9,8 +9,9 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); const workspace='/prompt_manager/workspace'; const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']}; -const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes']; -const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']]; +// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save. +const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes']; +const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'; const editorDialog=page=>page.locator('dialog.library-editor'); test.beforeEach(async({page,request,browserName})=>{ @@ -65,9 +66,8 @@ test('edit recipe metadata shows the concept fields with the selected saved nega await expect(dialog.getByRole('region')).toHaveCount(0); await expect(field(dialog,'Category')).toHaveValue('Portraits'); await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light'); - await expect(field(dialog,'Rating')).toHaveValue('4'); await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes); - expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS); + await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0); expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']); expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']); await expect(dialog.locator('[placeholder]')).toHaveCount(0); @@ -82,19 +82,20 @@ test('editor modes persist through reload with selected saved negative',async({p const f=page.fixture;await mountEditor(page); let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill(''); - await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0'); + await field(dialog,'Tags, separated by commas').fill('studio, moody'); await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]); - expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'}); + expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'}); await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); const saved=(await detail(request,f.positive_id)).positive; - expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']); + // D153: the edit never sends a rating, so the stored 4 stays. + expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']); await page.reload();await mountEditor(page); dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue(''); - await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); + await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative); await button(dialog,'Create text revision').click(); await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible(); await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true); @@ -106,12 +107,15 @@ test('editor modes persist through reload with selected saved negative',async({p await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library'); expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative, - parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}}); + parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}}); + expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false); expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']); await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1); const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id); - expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'}); + // D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating. + expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null}); + expect((await detail(request,f.positive_id)).positive.rating).toBe(4); dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); expect(await actionNames(dialog)).toEqual(ACTIONS.new); @@ -120,9 +124,9 @@ test('editor modes persist through reload with selected saved negative',async({p await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true); expect(page.writes).toHaveLength(before); await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn'); - await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); + await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'', - parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}}); + parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}}); dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id}); await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog); @@ -134,6 +138,7 @@ test('editor modes persist through reload with selected saved negative',async({p const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt); expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}}); await expect(page.locator('#editor-trigger')).toBeFocused(); + expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4); }); test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{ @@ -804,20 +809,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on await uniqueDotNames(page); await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative); - await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); + await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText(message); await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true'); expect(page.writes.every(write=>write.method==='POST')).toBe(true); }); -test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{ +test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{ const f=page.fixture;await page.goto(`${workspace}#/library`); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative'); await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click(); await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); - await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); + await expect(toast(page)).toHaveText(UNAPPLIED); await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes'); @@ -825,6 +830,8 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click(); expect(page.writes).toHaveLength(1); + // D153: neither the reuse nor the kept draft touches the stored rating. + expect((await detail(request,f.positive_id)).positive.rating).toBe(4); }); test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{ @@ -832,7 +839,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative'); await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', ')); - await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); + await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.'); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); @@ -848,13 +855,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative); await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', ')); - await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); + await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); }; await reuse('','blank category negative'); await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.'); await reuse('Landscapes','different category negative'); - await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); + await expect(toast(page)).toHaveText(UNAPPLIED); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']); }); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 3ddfdeae..9c18949c 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -152,13 +152,25 @@ test('failed library reads retry while late search responses cannot overwrite ne await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); }); +// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added. +test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{ + await page.goto(route);await expect(cards(page)).toHaveCount(3); + const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));}); + await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});}); + await expect(cards(page)).toHaveCount(3); + await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest'); + expect(sorts).toEqual(['newest']); +}); + test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{ await page.goto(route);await expect(cards(page)).toHaveCount(3); // app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters. const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1); expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']); await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…'); - await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']); + // D153 (#447): no "Highest rated". + await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']); await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']); await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0); await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0); @@ -239,7 +251,8 @@ test('a sort change atomically discards old row geometry and renders changed par } finally {await page.evaluate(()=>window.releaseLibraryFrames());} await expect(cards(page)).toHaveCount(3); const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape'); - await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★'); + // D153: a stored rating of 5 still draws no stars. + await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0); await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden(); }); @@ -445,8 +458,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as for(const item of data.items) { const card=page.locator(`article[data-prompt-id="${item.id}"]`); const footer=card.locator('.card-footer > span'); - await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]); - await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]); + // D153: no stars or "Rated N out of 5", whatever the stored rating. + await expect(footer.locator('> *')).toHaveCount(0); await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags); await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`); } @@ -598,7 +611,7 @@ async function patchShelf(page,patch) { await route.fulfill({response,json:body}); }); } -/** The footer line as seen: everything but its visually hidden rating sentence. */ +/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */ const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join(''); const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n'); @@ -634,13 +647,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']); const footer=card.locator('.card-footer'); expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']); - await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★'); - await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5'); - expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today'); + // D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use. + await expect(footer.locator('> span > *')).toHaveCount(0); + expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today'); const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2); await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait'); await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1); - // Unrated and never used: the used label alone; no negatives still reads as a count. + // Never used: the used label alone; no negatives still reads as a count. const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`); expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet'); await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0); diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index b29f16a1..ca176c06 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -230,6 +230,31 @@ test('a view stored under older rules still lists and opens with its usable valu await expect(promptCards(page)).toHaveCount(1); }); +// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved. +test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => { + const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } }); + const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } }); + expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']); + const listings = []; + page.on('request', item => { + const url = new URL(item.url()); + if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort')); + }); + await page.goto(`${workspace}#/saved/${images.id}`); + await expect(heading(page)).toHaveText('Rated cats'); + await expect(sortBy(page)).toHaveValue('newest'); + expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated'); + await expect(search(page)).toHaveValue('cat'); + await expect(imageCards(page)).toHaveCount(1); + await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click(); + await expect(heading(page)).toHaveText('Rated dogs'); + await expect(sortBy(page)).toHaveValue('newest'); + await expect(search(page)).toHaveValue('dog'); + await expect(promptCards(page)).toHaveCount(1); + expect(listings.length).toBeGreaterThan(1); + expect(listings).not.toContain('rating'); +}); + test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => { const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } }); const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index f5cf8ed9..1ea7505d 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) => expect(await rowsOf(layout, 'Workspace preferences')).toEqual([ ['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'], ['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'], - ['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'], + ['Statistics', 'Runs by category, with library totals.', 'Explore'], ]); expect(writes).toEqual([]); }); @@ -373,13 +373,12 @@ test('statistics dialog lists runs by category and library totals', async ({ pag await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics'); const tables = dialog.locator('#modal-body > table'); await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']); - await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']); + // D153 (#447): no rating column and no rated-images total. + await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']); const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent))); - expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', - number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`])); + expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)])); expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)], - ['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)], - ['Rated images', number(statistics.rated_images)]]); + ['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]); await page.keyboard.press('Escape'); await expect(explore).toBeFocused(); // A statistics read that fails says so; the Overview stays. diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 383c3b21..be3d1c10 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -211,11 +211,12 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i); }); -test('stream sends category, tags and the rating sort and validates them', async () => { +test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', async () => { const {createImageStream}=await source;const calls=[]; const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}}); - await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'}); - assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating'); + await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'}); + assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc'); + await assert.rejects(stream.reset({sort:'rating'}),/sort/i); await stream.reset({category:'',tags:[]}); assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false); for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i); @@ -277,7 +278,7 @@ test('the caption date names the newest result day under Newest first only', asy assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),''); assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13'); assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025'); - for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),''); + for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),''); assert.equal(captionDate([],'newest',now),''); assert.equal(captionDate([{modified_time:'bad'}],'newest',now),''); }); diff --git a/tests/js/workspace-library-editor-state.test.js b/tests/js/workspace-library-editor-state.test.js index ff1826da..068a6c50 100644 --- a/tests/js/workspace-library-editor-state.test.js +++ b/tests/js/workspace-library-editor-state.test.js @@ -117,8 +117,9 @@ test('new prompt from Images waits for a fresh options epoch', async () => { assert.equal(sentBody.expected_epoch, EPOCH); assert.deepEqual(sentBody, { expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '', - parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' }, + parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' }, }); + assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating'); assert.equal(controller.state.phase, 'complete'); assert.equal(committed.length, 1); assert.equal(committed[0].kind, 'create'); @@ -230,11 +231,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => { const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) })); const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await controller.initialize(); - controller.setField('rating', 5); + controller.setField('notes', 'kept'); await controller.submit(); phases.push(controller.state.phase); if (controller.state.phase !== 'write-error') errors.push(controller.state.error); - assert.equal(controller.state.draft.rating, 5, 'draft preserved'); + assert.equal(controller.state.draft.notes, 'kept', 'draft preserved'); assert.equal(controller.state.receipt, null); controller.dispose(); } @@ -306,7 +307,7 @@ test('confirmed save refresh retry is read only', async () => { assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); let failReads = 1; const committed = [], reconciled = []; - const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) }; + const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) }; const base = library({ write: () => response(200, receipt) }); const server = harness(call => { if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline')); @@ -315,7 +316,6 @@ test('confirmed save refresh retry is read only', async () => { const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31, onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) }); await controller.initialize(); - controller.setField('rating', null); controller.setField('notes', 'keep\ntext'); await controller.submit(); assert.equal(controller.state.phase, 'saved-refresh-error'); @@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => { test('legacy title fallback is not an edit', async () => { const { createLibraryEditorController } = await source; assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); - const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) }; + const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) }; const server = harness(library({ write: () => response(200, receipt) })); const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await controller.initialize(); @@ -346,7 +346,6 @@ test('legacy title fallback is not an edit', async () => { assert.equal(controller.state.baseline.title, null, 'stored title stays distinct'); assert.equal(controller.state.draft.tags, 'soft, warm'); assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative'); - controller.setField('rating', null); controller.setField('notes', 'keep\ntext'); controller.setField('tags', 'warm, soft,'); controller.setField('category', ' Studio '); @@ -357,7 +356,7 @@ test('legacy title fallback is not an edit', async () => { const sentBody = write.body; assert.equal(sentBody.expected_epoch, EPOCH); assert.equal(sentBody.if_match, TOKEN); - assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' }); + assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' }); const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await unchanged.initialize(); @@ -421,12 +420,12 @@ test('metadata conflict reload keeps changed fields for review and never replays assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); let owner = positive(); const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2 - ? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } }) + ? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } }) : response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call)); const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await controller.initialize(); - controller.setField('rating', '5'); - owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 }); + controller.setField('notes', 'mine'); + owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 }); await controller.submit(); assert.equal(controller.state.phase, 'conflict'); await controller.submit(); @@ -434,20 +433,20 @@ test('metadata conflict reload keeps changed fields for review and never replays await controller.reloadOwner({ keepDraft: true }); assert.equal(controller.state.phase, 'ready'); assert.equal(controller.state.reviewRequired, true); - assert.equal(controller.state.draft.rating, 5, 'changed field kept'); + assert.equal(controller.state.draft.notes, 'mine', 'changed field kept'); assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner'); - assert.equal(controller.state.draft.notes, 'other writer'); + assert.equal(controller.state.draft.category, 'Elsewhere'); assert.equal(server.writes().length, 1); await controller.submit(); - assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } }); + assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } }); assert.equal(controller.state.phase, 'complete'); controller.dispose(); const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await discard.initialize(); - discard.setField('rating', 1); + discard.setField('notes', 'dropped'); await discard.reloadOwner({ keepDraft: false }); - assert.equal(discard.state.draft.rating, 3); + assert.equal(discard.state.draft.notes, ''); assert.equal(discard.state.reviewRequired, false); discard.dispose(); }); @@ -457,12 +456,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as assert.equal(typeof createLibraryEditorController, 'function', 'controller export'); let owner = positive(); const server = harness(call => library({ owner, write: () => { - owner = positive({ rating: 5, edit_token: TOKEN2 }); + owner = positive({ notes: 'applied', edit_token: TOKEN2 }); return Promise.reject(new TypeError('Failed to fetch')); } })(call)); const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 }); await controller.initialize(); - controller.setField('rating', 5); + controller.setField('notes', 'applied'); await controller.submit(); assert.equal(controller.state.phase, 'unknown-outcome'); await controller.retryUnconfirmed(); @@ -570,8 +569,10 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b await controller.submit(); assert.deepEqual(server.writes()[0].body, { expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry', - parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' }, + parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' }, }); + // D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written. + assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false); controller.dispose(); }); @@ -589,7 +590,9 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn assert.equal(controller.state.phase, 'ready'); assert.match(controller.state.error.message, /Title/); assert.throws(() => controller.setField('unknown', 'x'), TypeError); - assert.throws(() => controller.setField('rating', 7), TypeError); + // D153: the editor has no rating field. + assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/); + assert.throws(() => controller.setField('notes', 4), TypeError); controller.setField('title', 'T'); await controller.submit(); assert.equal(controller.state.phase, 'write-error'); @@ -807,3 +810,44 @@ test('a blank category reused against an uncategorized owner is not unapplied me controller.dispose(); } }); + +// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one. +test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => { + const { createLibraryEditorController, EDITOR_FIELDS } = await source; + assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']); + let owner = positive({ rating: 4, title: 'Portrait' }); + const server = harness(call => library({ owner, write: write => { + owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 }); + return response(200, { success: true, library_epoch: EPOCH, positive: owner }); + } })(call)); + const reconciled = []; + const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31, + onReconciled: result => reconciled.push(result) }); + await controller.initialize(); + assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating'); + assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating'); + controller.setField('title', 'Portrait, edited'); + await controller.submit(); + const [write] = server.writes(); + assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' }); + assert.equal(controller.state.phase, 'complete'); + assert.equal(controller.state.receipt.positive.rating, 4); + assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating'); + controller.dispose(); +}); + +test('a reused rated positive is not reported as unapplied metadata for its rating', async () => { + const { createLibraryEditorController } = await source; + const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false, + positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) }); + const server = harness(created(() => response(200, reused))); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + await controller.initialize(); + controller.setField('title', 'Portrait'); + controller.setField('positive_text', 'a portrait'); + await controller.submit(); + assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false); + assert.equal(controller.state.phase, 'complete'); + assert.equal(controller.state.outcome.metadataNotApplied, false); + controller.dispose(); +}); diff --git a/tests/js/workspace-library-format.test.js b/tests/js/workspace-library-format.test.js index b7bfed3c..7ef6c232 100644 --- a/tests/js/workspace-library-format.test.js +++ b/tests/js/workspace-library-format.test.js @@ -46,9 +46,8 @@ test('used label boundaries in UTC and local time', async () => { for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet'); }); -test('rating label omits unrated', async () => { - const { ratingLabel } = await source; - assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' }); - assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' }); - for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' }); +// D153 (#447): cards show no rating, so the layout module has no rating label. +test('the layout module offers no rating label', async () => { + const layout = await source; + assert.equal(Object.hasOwn(layout, 'ratingLabel'), false); }); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index f5969dac..cda08eb3 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3, const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor}); test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{ const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}}); - assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false); - const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose(); + assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false); + const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'name');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose(); }); test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{ const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}]; @@ -114,3 +114,9 @@ test('the stream keeps the first page\'s storage epoch and refuses a later page const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null); s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset; }); +test('the shelf no longer sorts by rating (D153)',async()=>{ + const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}}); + await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/); + assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/); + for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose(); +}); diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index 46d2fe06..c5cb9d6a 100644 --- a/tests/js/workspace-saved-views.test.js +++ b/tests/js/workspace-saved-views.test.js @@ -72,9 +72,9 @@ test('the listing keeps usable views in order and normalizes their state', async test('page filters and saved states convert both ways', async () => { const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source; - const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' }; + const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' }; const state = imagesViewState(filters); - assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' }); + assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' }); assert.deepEqual(imagesFilters(state), filters); assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' }); // #331 D140: the open collection round-trips; a missing one is ''. @@ -215,3 +215,13 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5) await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError); assert.deepEqual(api.calls, []); }); + +test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => { + const { normalizeViewState, imagesFilters, libraryFilters } = await source; + assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest'); + assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest'); + assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest'); + assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest'); + assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc'); + assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest'); +}); diff --git a/tests/js/workspace-system-overview.test.js b/tests/js/workspace-system-overview.test.js index e4f63566..e6b07802 100644 --- a/tests/js/workspace-system-overview.test.js +++ b/tests/js/workspace-system-overview.test.js @@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.'); }); -test('statistics rows are the real runs and ratings, then the library totals', async () => { +test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => { const { categoryRows, totalsRows } = await overviewModule; const statistics = { by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 }, @@ -149,8 +149,8 @@ test('statistics rows are the real runs and ratings, then the library totals', a totals: { total_prompts: 12, total_tags: 15, total_images: 24 }, rated_images: 20, }; - assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]); - assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]); + assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]); + assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]); }); /** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */ diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index cdb12dca..6fc5f46b 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -32,8 +32,8 @@ export function imageKey(image) { return `${image.root_index}:${image.path}`; } -/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */ -export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']); +/** The workspace listing's sorts: the concept's Recently added and Name A–Z, plus Oldest and Z–A (D14; no rating, D153). */ +export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc']); const isCount = value => Number.isSafeInteger(value) && value >= 0; // The largest page a refresh asks for (the listing clamps at 500). @@ -315,7 +315,7 @@ const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.ge /** * D17: the caption's right-hand date, "<Today|Yesterday|weekday> · <Month D>" of the newest result under Newest first, - * with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts. + * with ", YYYY" outside the current year; '' under Oldest first and the name sorts. */ export function captionDate(items, sort, now = new Date()) { if (sort !== 'newest') return ''; diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 96593555..16e22c6e 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -61,8 +61,9 @@ function selectOf(id, options, value) { return select; } -// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). -const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'], +// The concept's sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). D153 (#447) +// drops the concept's "Highest rated". +const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]); const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]); const RETRY_RESCAN = 'Rescan from Import to retry.'; diff --git a/web/js/workspace/library-editor-state.mjs b/web/js/workspace/library-editor-state.mjs index bc8fd1e9..735ee733 100644 --- a/web/js/workspace/library-editor-state.mjs +++ b/web/js/workspace/library-editor-state.mjs @@ -9,7 +9,9 @@ */ const MODES = new Set(['new', 'metadata', 'revision', 'negative']); const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']); -const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating']; +// D153 (#447): the workspace edits no 1–5 rating; a stored one is left exactly as it is. +export const EDITOR_FIELDS = Object.freeze(['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']); +const FIELDS = EDITOR_FIELDS; const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'}; // A proxy timeout or bad gateway cannot tell whether the server committed the write. const UNCONFIRMED_STATUS = new Set([502, 503, 504]); @@ -106,27 +108,20 @@ export function fallbackTitle(text) {return text.split('\n')[0].trim();} export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];} const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length; const stored = value => (value ?? '').trim(); -function normalizeRating(value) { - if (value === null || value === '') return null; - const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value; - if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null'); - return number; -} - -const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null}); +const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: ''}); function seedDraft(mode, {owner, pair}) { if (!owner) return blankDraft(); if (mode === 'negative') return {...blankDraft(), positive_text: owner.text}; return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text, negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '), - notes: owner.notes ?? '', rating: owner.rating}; + notes: owner.notes ?? ''}; } /** Keep fields the user changed from the previous seed; the rest follow the new seed. */ function overlay(next, draft, previous) { return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]])); } function baselineOf(owner) { - return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes}; + return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], notes: owner.notes}; } /** Only changed fields; an untouched fallback title is not a title edit. */ @@ -136,19 +131,18 @@ function metadataChanges(draft, seed, baseline) { if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title; if (category !== stored(baseline.category)) changes.category = category; if (!sameTags(tags, baseline.tags)) changes.tags = tags; - if (draft.rating !== baseline.rating) changes.rating = draft.rating; if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes; return changes; } function createMetadata(draft) { return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags), - rating: draft.rating, notes: draft.notes}; + notes: draft.notes}; } // A blank category is stored as NULL; "Uncategorized" is only the UI's label for it. const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value)); function sameMetadata(sent, owner) { return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) && - sent.rating === owner.rating && sent.notes === (owner.notes ?? ''); + sent.notes === (owner.notes ?? ''); } function draftProblem(mode, draft) { if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'}); @@ -328,9 +322,8 @@ export function createLibraryEditorController({api, mode, positiveId = null, pai }, setField(name, value) { if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`); - const next = name === 'rating' ? normalizeRating(value) : value; - if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`); - emit({draft: {...state.draft, [name]: next}}); + if (typeof value !== 'string') throw new TypeError(`${name} must be a string`); + emit({draft: {...state.draft, [name]: value}}); }, async submit() { if (disposed || !SUBMITTABLE.has(state.phase)) return; diff --git a/web/js/workspace/library-editor.mjs b/web/js/workspace/library-editor.mjs index 4eb238b0..d7eda159 100644 --- a/web/js/workspace/library-editor.mjs +++ b/web/js/workspace/library-editor.mjs @@ -7,12 +7,11 @@ * module only renders `controller.state` and forwards user actions. */ import { createButton } from '../shared/toolkit/core.js'; -import { createLibraryEditorController } from './library-editor-state.mjs'; +import { createLibraryEditorController, EDITOR_FIELDS } from './library-editor-state.mjs'; const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'}; -const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']]; const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category', - tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'}; + tags: 'recipe-tags', notes: 'recipe-notes'}; const SUBMITTABLE = new Set(['ready', 'write-error']); const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.'; const NO_PAIR = 'Archive needs a selected saved negative variant.'; @@ -218,9 +217,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o category.setAttribute('list', 'categories'); tagInput.setAttribute('list', 'tag-options'); columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags)); - const rating = controls.rating = el('select'); - for (const [value, text] of RATINGS) rating.add(new Option(text, value)); - form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes)); + // D153 (#447): no Rating field; the concept's `#recipe-rating` select is not drawn. + form.append(columns, categories, tags, field('Notes', textarea('notes'), FIELD_IDS.notes)); } else form.append(el('p', NEGATIVE_HELP, 'small')); const line = statusLine(), recovery = new Map(); const actions = el('div', undefined, 'dialog-actions'); @@ -230,9 +228,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o form.append(line.note, actions); for (const [name, node] of Object.entries(controls)) { - node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => { - controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value); - }, {signal: views.signal}); + node.addEventListener('input', () => controller.setField(name, node.value), {signal: views.signal}); } form.addEventListener('submit', event => { event.preventDefault(); @@ -246,7 +242,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o let categoryKey = null, refocus = null; function render(state) { for (const [name, node] of Object.entries(controls)) { - const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name]; + const value = state.draft[name]; if (node.value !== value) node.value = value; } const key = state.categories.join('\n'); @@ -274,7 +270,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o body.replaceChildren(form); return {controller, views, render, start() { - if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value); + // A kept draft carries only the editor's own fields (D153: never a rating). + if (draft) for (const [name, value] of Object.entries(draft)) if (EDITOR_FIELDS.includes(name)) controller.setField(name, value); render(controller.state); controller.initialize(); }}; diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs index bc573a17..57025237 100644 --- a/web/js/workspace/library-layout.mjs +++ b/web/js/workspace/library-layout.mjs @@ -60,9 +60,3 @@ export function usedLabel(lastUsedAt, now = new Date()) { const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`; return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`; } - -/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */ -export function ratingLabel(rating) { - if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''}; - return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`}; -} diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 900c944f..2f61be61 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -11,7 +11,7 @@ function parameters(filters,limit) { if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags'); for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim()); const sort=filters.sort || 'newest'; - if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); + if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort'); query.set('sort',sort);return query; } // `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 3baa22bc..97de6b37 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr import {promptRoles} from './prompt-blocks.mjs'; import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; import {createLibraryEditorController} from './library-editor-state.mjs'; -import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs'; +import {layoutLibraryRows,visibleLibraryRows,usedLabel} from './library-layout.mjs'; import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; @@ -29,8 +29,8 @@ const selectedPairs=new Map(); // Parent metadata a reuse did not apply, kept for an explicit Edit metadata action. const metadataDrafts=new Map(); const archivedVisible=new Map(); -// The concept's three sorts first, then the Library's appended Oldest first (D14). -const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]); +// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated". +const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]); // A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437). const CARD_CONTROLS='button, input[type="checkbox"]'; const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.'; @@ -111,7 +111,7 @@ function outcomeMessage(result,outcome) { if(kind!=='create' && kind!=='add-negative') return null; if(receipt.positive_created) return 'Saved.'; if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.' - :outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.' + :outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.' :'Saved as a new negative variant of an existing prompt.'; if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.'; return 'This prompt pair already exists. Showing the saved version.'; @@ -122,7 +122,7 @@ function takeSavedSelection(positiveId) { } function rememberUnappliedMetadata(result,{outcome,draft}={}) { if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return; - const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes}); + const {title:name,category,tags,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,notes}); } /** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */ function createNewPromptEditor(api,scope,main) { @@ -159,6 +159,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. const restorable=visit?.state?visit:null; const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved}; + // D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added. + if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest'; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. @@ -220,7 +222,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height); const hiddenSave=restorable?null:savedParentId;savedParentId=null; function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} - /** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */ + /** The concept's prompt card (app.js renderLibrary a586) with real counts and last use (#249 D23; no rating, D153). */ function cardFor(item,index) { const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true'); @@ -246,10 +248,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip')); content.append(heading,prompts,meta,vocabulary); const footer=el('div',undefined,'card-footer'); - const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at); - const facts=el('span'); - if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);} - else facts.textContent=used; + // D153 (#447): the footer reads only the last use; the concept's rating stars are not drawn. + const facts=el('span',usedLabel(item.last_used_at)); const actions=el('div',undefined,'row-actions'); const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive=''; const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history=''; diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index 7f027860..e58fa94f 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -6,7 +6,8 @@ * view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view". * - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162). * - A view's state always holds every key of its page (the API requires them all); a value its page no longer - * accepts is dropped to that key's default, so an opened view applies what it can. + * accepts is dropped to that key's default, so an opened view applies what it can (a view saved under the retired + * "Highest rated" sort opens on Recently added, D153). * - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name", * Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`. */ @@ -36,7 +37,7 @@ const tags = { fallback: [], accepts: value => Array.isArray(value) && value.len typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) }; const FIELDS = Object.freeze({ images: Object.freeze({ - q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'), + q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc'], 'newest'), source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) }, folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags, // A view saved on Favorites (#249 D51) reopens Images filtered to favorites. @@ -45,7 +46,7 @@ const FIELDS = Object.freeze({ collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) }, }), library: Object.freeze({ - q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags, + q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags, }), }); diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index 26c76597..033ea2d1 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -11,7 +11,8 @@ * D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs). * #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the * configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131). - * - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals". + * - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or + * rated-images total). * - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real * result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a * read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends. @@ -168,21 +169,16 @@ export function healthSummary(report) { return `${time}Nothing was changed; review the checks with issues.`; } -/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */ +/** D96: the concept's "Runs by category" rows `[category, runs]`; D153 drops its average rating column. */ export function categoryRows(statistics) { - return statistics.by_category.map(row => [ - row.category ?? 'Uncategorized', - number(row.runs), - row.average_rating === null || row.average_rating === undefined ? 'Not rated' - : `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`, - ]); + return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]); } -/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */ +/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */ export function totalsRows(statistics) { const { totals } = statistics; - return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images], - ['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]); + return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]] + .map(([label, value]) => [label, number(value)]); } function table(caption, head, rows, empty) { @@ -215,7 +211,7 @@ function table(caption, head, rows, empty) { /** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */ export function statisticsNodes(statistics) { return [ - table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'), + table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'), table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''), ]; } @@ -378,7 +374,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.', label: 'Appearance', onClick: go('appearance') }), operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }), - operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore', + operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore', onClick: openStatistics }), ]); const columns = el('div', undefined, 'two-col'); diff --git a/web/js/workspace/toolbar.mjs b/web/js/workspace/toolbar.mjs index 49cebf51..c1070bc5 100644 --- a/web/js/workspace/toolbar.mjs +++ b/web/js/workspace/toolbar.mjs @@ -13,6 +13,7 @@ export const SEARCH_DELAY = 250; // The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any // other label is one of the page's appended options. +// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order. const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']); const isText = value => typeof value === 'string';