From aad3a37bcf6bd344e8a2d40bee23dd3da85b0760 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 02:44:42 +0000 Subject: [PATCH 1/7] fix(inspector): give image tags their own labelled section (#449) (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector drew a file's tags as a bare chip row right under the negative prompt, so they read as part of it. The side panel, the viewer details and History's current file now draw them in labelled sections in the prompt sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags · N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are removed within a source only. Recorded as D154. --- TESTING.md | 33 +++++++- tests/browser/concept-inventory.mjs | 44 ++++++++++ tests/browser/full-inspector.spec.mjs | 32 +++++--- tests/browser/inspector-concept.spec.mjs | 78 ++++++++++++------ tests/browser/workspace-history.spec.mjs | 19 +++++ tests/browser/workspace-tagging-bulk.spec.mjs | 6 +- tests/browser/workspace-tagging.spec.mjs | 82 ++++++++++++++----- tests/js/workspace-file-inspector.test.js | 67 +++++++++++++-- web/js/workspace/file-inspector.mjs | 51 ++++++++---- web/js/workspace/workspace.css | 5 ++ 10 files changed, 334 insertions(+), 83 deletions(-) diff --git a/TESTING.md b/TESTING.md index c1a37fcf..d2cc8341 100644 --- a/TESTING.md +++ b/TESTING.md @@ -731,8 +731,9 @@ The shared inline and collection inspector reads the current rooted file's suppo embedded records, exact generation facts and recorded or inferred prompt evidence. It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows only the concept content: cover, title, filename, the evidence badge with a category -chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image -tags first, D88, then the linked tags; image tags and the linked positive's own tags +chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled +sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt +tags · N" or "Recipe tags · N" for the linked tags; image tags and the linked positive's own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and "Prompt history" only when prompt roles resolved or a link is saved; the sticky panel scrolls inside the window. The viewer's details (`#viewer-details`) repeat that @@ -1523,6 +1524,9 @@ Recorded deviations on this page (spec §8): filters, while recipe, recorded-run and embedded tags stay plain `span.chip`. Amended by D88 (#249 PR C): the file's own current image tags lead the same row and are filters too; a linked tag already shown (case-insensitive) is left out. + Amended by D154 (#449): the image tags and the linked tags each sit in their own + labelled section, and a linked tag stays in its section even when an image tag + has the same name. Amended by D146 (#332): the category chip also names its origin in visible text after the category (" · this image", " · prompt", " · recipe"), and an image's own category shows for an unlinked or ambiguous file too. @@ -1787,6 +1791,30 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 concept image's tags saved as its image tags production still equals the concept. After a Tag save the selected file's inspection is re-read, so the panel redraws the row without reselecting and the viewer, which shares that inspection, shows it. + Amended by D154 (#449): the one row is now two labelled sections. +- **D154** (#449, beyond the concept): the inspector's tags no longer follow the + negative prompt as a bare `.tags` row, where they read as part of it. Every surface of + the shared inspector (the Images side panel, the viewer details, History's inline + current file, the Library-embedded gallery and Activity) draws them in + `section.tag-section` regions in the concept's prompt-section frame (the + `.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a + `.role-label`, the row's own 12px top margin dropped; workspace.css). The label is + muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First + `section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88), + then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's + tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category + chip names its origin). Each region is named without its count and with the prompt + blocks' context ("Current file image tags" in History). An empty source draws no + section. Repeats are removed within a source only, so a linked tag that is also an + image tag shows in both sections and each count is true. D21's filter rules are + unchanged. History's current-file read carries no `image_tags` facet (D88), so there + only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the + concept's row in the same sections at runtime (`withTagSections`, the D154 rule + injected) and compares the measured sections (frame, heading, label, row); a + mutation of the label color, the row margin or the top rule fails it. Browser tests: + "D154: image tags sit in their own labelled section after the negative prompt, in + the panel and the viewer" and "the inline current file labels its image tags after + its negative prompt". - **D115** (beyond the plan's list): the concept always has a selected image, so its "Auto-tag images" always opens the dialog. Production opens it for the image selected in Images when it was last left; with none it is `aria-disabled` and @@ -1872,6 +1900,7 @@ production-versus-concept difference of PR C and its record: | Suggestions count field | D91 (ruling T3-bulk) | | No prototype note, "Suggestions from …", no " in this tab" | D29 | | Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) | +| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) | | Auto-tag images `aria-disabled` without a selected image | D115 | | Retry leads the actions when the controller allows it | D116 (ruling T5) | | States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only | diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 3a3ed3ee..5ef629f3 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -214,6 +214,41 @@ export async function withChipSource(page,root,origin,category=null) { }; } +/** + * #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept + * has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at + * runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first + * holding the concept's row and each further one a copy of it. Returns the undo. + */ +export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; } +.tag-section .role-label { color: var(--ca-muted); } +.tag-section > .tags { margin-top: 0; }`; +export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']]; +export async function withTagSections(page,root,sections=PROMPT_TAGS) { + const restoreRule=await mutate(page,{css:D154_TAG_SECTION}); + const token=await page.evaluate(({root,sections})=>{ + const container=document.querySelector(root); + const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div'); + const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section')); + if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`); + const anchor=row.nextSibling,parent=row.parentNode,added=[]; + sections.forEach(([className,label,name],index)=>{ + const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name); + const heading=document.createElement('div');heading.className='prompt-heading'; + const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`; + heading.append(text);section.append(heading,index?row.cloneNode(true):row); + parent.insertBefore(section,anchor);added.push(section); + }); + const token=`tag-sections-${Math.random().toString(36).slice(2)}`; + (window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());}); + return token; + },{root,sections}); + return async()=>{ + await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token); + await restoreRule(); + }; +} + /** * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) @@ -662,6 +697,13 @@ export function measureInspector(selector) { text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text), ...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))}; } + function tagSectionOf(section) { + const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags'); + return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section), + heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})), + label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})), + row:when(row,rowOf)}; + } function factsOf(list) { const style=getComputedStyle(list); return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list), @@ -695,6 +737,8 @@ export function measureInspector(selector) { badges:when(rows.find(row=>parts.indexOf(row)parts.indexOf(row)>firstSection),rowOf), + // #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row. + tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf), facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf), // Disclosures (viewer only) by their summary and frame; their content is the D20 production facts. details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary'); diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index f134e415..fa740476 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{ // Linked, workflow inferred: every concept element. await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE); expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'], - inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']}); + inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']}); await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt',''); await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG'); await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png'); @@ -139,9 +139,13 @@ test('panel matches the concept order and copy',async({page,request})=>{ // #332 MC8: the chip names where its value came from, in visible text after the category. await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe'); await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: '); - // Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling). - await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); - await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0); + // Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154 + // (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is. + const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true}); + await expect(recipeTags).toHaveClass('tag-section linked-tags'); + await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2'); + await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']); + await expect(panel.locator('.tag-section button')).toHaveCount(0); await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); const footer=panel.locator('.detail-footer'); @@ -149,7 +153,7 @@ test('panel matches the concept order and copy',async({page,request})=>{ await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2); - await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/); + await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/); // Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format. reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';}; await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png'); @@ -168,18 +172,20 @@ test('panel matches the concept order and copy',async({page,request})=>{ // Ambiguous saved association: the same honest absence. reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};}; await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png'); - await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0); + await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0); await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); // D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree. - const tagRow=panel.locator('.detail-inner > .tags').last(); + const tagRow=panel.locator('.tag-section.linked-tags > .tags'); reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};}; await selectFile(page,'complete.png'); await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: '); await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']); + await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2'); reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};}; await selectFile(page,'workflow-only.png'); await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: '); await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0); + await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2'); // Loading and error states render in `.detail-inner` as `.small` text with the existing buttons. reads.patch=null;await page.unroute('**/images/inspect/**'); await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}})); @@ -243,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{ await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png'); const panel=page.getByRole('complementary',{name:'Selected image details',exact:true}); const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags')); - await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click(); + await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click(); expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric'); await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible(); await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible(); @@ -290,14 +296,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, // then the pair send and history buttons (D20) and D139's Collections…. expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'], - inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']}); + inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']}); await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal'); await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0); await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG'); await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png'); await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred'); - await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); + await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']); await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); @@ -311,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible(); await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible(); await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible(); - await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/); + // D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone. + await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/); + await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2'); // Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons. const raw=details.locator('details').last();await raw.locator('summary').click(); await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible(); @@ -467,7 +475,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page test('embedded gallery uses the same full inspector',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id; - await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1); + await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1); }); test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{ diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 3b47cd97..d2bcd606 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs'; +import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs'; // Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i, // true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes. @@ -51,7 +51,8 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled + * tag sections the concept page gets at runtime from `withTagSections`); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's @@ -108,7 +109,8 @@ async function openProductionPanel(page,theme) { await expect(page.locator(CARDS).first()).toBeVisible(); await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE); await expect(panelOf(page).locator('.detail-footer')).toBeVisible(); - await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2); + await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1); + await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible(); await settle(page); } @@ -129,13 +131,14 @@ async function withCollectionsButton(page,selector) { } /** - * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy - * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", - * without its action); returns an undo. + * The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's + * coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button + * (its "Download sample JSON", without its action); returns an undo. */ -async function plainViewerChips(page) { +async function plainViewerChips(page,sections) { const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS}); const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN); + const restoreSections=await withTagSections(page,'#viewer-details',sections); await page.evaluate(()=>{ window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{ const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; @@ -151,7 +154,7 @@ async function plainViewerChips(page) { await restoreCollections(); await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); - await restoreSource();await restoreTargets(); + await restoreSections();await restoreSource();await restoreTargets(); }; } @@ -169,7 +172,7 @@ async function fileControlsOf(page) { * The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain * chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes. */ -async function viewerOf(page,concept,mutation=null) { +async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) { await page.locator(CARDS).first().locator('.image-open').click(); await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE); await settle(page); @@ -178,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) { const {viewerDetails}=await inspectorOf(page); const fileControls=await fileControlsOf(page); let viewerPlain=null; - if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();} + if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();} await restoreMutation?.(); await page.keyboard.press('Escape'); await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0); @@ -186,15 +189,18 @@ async function viewerOf(page,concept,mutation=null) { await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page); return {viewerDetails,viewerPlain,fileControls}; } -async function inventory(page,concept=false) { - // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history. +async function inventory(page,concept=false,sections=PROMPT_TAGS) { + // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's + // labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page. const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null; + const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null; if(concept)await settle(page); const {panel}=await inspectorOf(page); + await restoreSections?.(); await restoreCollections?.(); await restoreSource?.(); - const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); + const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; } @@ -258,7 +264,8 @@ async function productionSendOf(page,context) { /** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */ async function tagOf(page) { const before=await page.locator('#result-count').textContent(); - await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click(); + // The concept's bare row, or production's linked-tags section (D154): the last chip row either way. + await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click(); // Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant. await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true); await settle(page); @@ -323,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']); expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']); + // D154: both documents measured the labelled section, so the comparison covers it (never two absent sections). + for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]]) + expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']); expect.soft(diff,'differences after the recorded deviations').toEqual([]); // Copy: the concept's toast "Prompt copied." (or its dialog), in both documents. expect.soft(production.copy,'copy').toEqual(concept.copy); @@ -352,14 +362,17 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await openProductionPanel(page,'dark'); await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click(); await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry'); - const rows=panelOf(page).locator('.detail-inner > .tags'); - await expect(rows).toHaveCount(2); - await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']); - await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); + const badges=panelOf(page).locator('.detail-inner > .tags'); + await expect(badges).toHaveCount(1); + await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']); + await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); // D146 (#332): the visible origin names where the value came from. - await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt'); - await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); - await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); + await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt'); + // D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags". + const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true}); + await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3'); + await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']); + await expect(tags.locator('span.chip')).toHaveCount(0); await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); }); @@ -373,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic', async route=>{arrived();await held;await route.continue();},{times:1}); const count=page.locator('#result-count'); - await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click(); + await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click(); await reached; await expect(count).toHaveText('Loading images…'); await expect(page.locator(CARDS)).toHaveCount(0); @@ -383,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe }); // Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names -// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain -// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes. +// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in +// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449), +// and equals the concept with its row in those two sections, in both sizes. for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{ test.setTimeout(120_000); const viewport={width,height};await page.setViewportSize(viewport); @@ -392,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents(); expect(names).toEqual(['cinematic','blue_hour','architecture']); await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector'); - const reference=await inventory(page,true); + const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]); const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png'); expect(await saveImageTags(request,path,names)).toEqual(names); const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`); await openProductionPanel(page,'dark'); expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names}); - await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names); + await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names); + await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names); await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector'); assertInspectorMatch(reference,await inventory(page),viewport); }); @@ -423,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{ await restoreCss();await settle(page); assertInspectorMatch(reference,await measure(),viewport); + // D154: the tag section's muted label and its row's spacing are part of the mapped concept. + for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/], + ['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/], + ['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) { + const restore=await mutate(page,{css}); + const mutated=await measure(); + expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path); + await restore();await settle(page); + } + assertInspectorMatch(reference,await measure(),viewport); + const panel=(await inspectorOf(page)).panel; const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};}; const withoutActions=await viewerMutated({remove:['#viewer-details > button']}); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index a84732d0..b8ae2588 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); }); +// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they +// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is +// given one here to prove the shared render in History. +test('the inline current file labels its image tags after its negative prompt',async({page})=>{ + await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json(); + body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}}; + body.image_tags={state:'current',tags:['history_tag']}; + await route.fulfill({response,json:body});}); + await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); + await dialog(page).getByRole('button',{name:'Raw',exact:true}).click(); + await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible(); + const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true}); + const tags=current.getByRole('region',{name:'Current file image tags',exact:true}); + await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']); + const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true}); + expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false}); +}); + // #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so // the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits // nothing and shows no chip before. diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index b63fdcf5..245bfc13 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts); const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion'); const actionButtons = page => modal(page).locator('.dialog-actions button'); const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); -const tagRow = page => panel(page).locator('.detail-inner > .tags').last(); +const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags'); const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.'; test.beforeEach(async ({ page, request, browserName }) => { @@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async await expect(bar(page).locator('strong')).toHaveText('2 selected'); expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 }); // The finished job's files are read again: the selected card's panel shows its new image tags. - await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']); + await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']); expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']); expect(await savedTags(request, UNLINKED)).toEqual([]); }); @@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv expect(await savedTags(request, SECOND)).toEqual([]); expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']); // The reviewed file's panel shows its saved tags without reselecting. - await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']); + await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']); await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0); }); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index 3b65a367..b61abbca 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -458,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request }) expect(await count()).toBe(mounted); }); -// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked -// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain. +// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own +// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are +// Tags filter buttons; the viewer's chips are plain. const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); -const tagRow = page => panel(page).locator('.detail-inner > .tags').last(); +const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags'); +const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags'); const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`); const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true }); test('inspector shows saved image tags after Save without reselecting', async ({ page }) => { await page.goto(images); await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); - // D21/D39: the linked recipe's own tag, plain. - await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags); - await expect(tagRow(page).locator('button.chip')).toHaveCount(0); + // D21/D39: the linked recipe's own tag, plain, and no image tags yet. + await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags); + await expect(linkedTags(page).locator('button.chip')).toHaveCount(0); + await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0); await openTag(page, FIRST, { goto: false }); await manual(page).fill('dunes_tag, Editorial'); const reread = inspected(page, FIRST); await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click(); expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] }); - // The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial". - await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']); - await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']); + // The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154). + await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']); + await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']); + await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2'); + await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags); await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title); }); @@ -490,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as await infoButton(page, UNLINKED).click(); await read; await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png'); - await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']); + await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']); + await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0); const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag'); - await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click(); + await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click(); await listing; await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1); await expect(card(page, UNLINKED)).toBeVisible(); @@ -502,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as test('viewer details show the same chips', async ({ page, request }) => { await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']); await page.goto(images); - await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']); + await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']); await card(page, FIRST).locator('.image-open').click(); const details = page.locator('#viewer-dialog[open] #viewer-details'); await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title); - const row = details.locator('.tags').last(); + const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags'); await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']); await expect(row.locator('span.chip')).toHaveCount(3); await expect(row.locator('button')).toHaveCount(0); + await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags); // A file without image tags: the viewer shows only D21's linked tags. await page.getByRole('button', { name: 'Next media', exact: true }).click(); await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`); - await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags); + await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags); + await expect(details.locator('.tag-section.image-tags')).toHaveCount(0); +}); + +// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section, +// so they never read as part of it; the same in the side panel and the viewer details. +async function tagSectionPlacement(root) { + return root.evaluate(node => { + const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags'); + const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label')); + return { + after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags), + below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom, + border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop, + label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color}, + }; + }); +} +const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px', + label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}}; + +test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => { + await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']); + await page.goto(images); + const section = panel(page).getByRole('region', { name: 'Image tags', exact: true }); + await expect(section.locator('.role-label')).toHaveText('Image tags · 2'); + await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']); + expect(await tagSectionPlacement(panel(page))).toEqual(PLACED); + await card(page, FIRST).locator('.image-open').click(); + const details = page.locator('#viewer-dialog[open] #viewer-details'); + await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title); + const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true }); + await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2'); + await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']); + expect(await tagSectionPlacement(details)).toEqual(PLACED); }); test('search finds an image by its image tag', async ({ page, request }) => { @@ -570,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => { await page.goto(images); await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); - await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags); + await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags); await page.evaluate(() => { const node = document.querySelector('aside[aria-label="Selected image details"]'); window.__loadingSeen = false; @@ -589,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.'); // While the re-read is held, the panel still shows the file's details. await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title); - await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags); + await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags); expect(await page.evaluate(() => window.__loadingSeen)).toBe(false); const landed = inspected(page, FIRST); release(); await landed; - await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]); + await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']); + await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags); expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false); await page.unroute(matcher); }); @@ -607,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while await infoButton(page, SECOND).click(); await read; const filtered = listingOf(page, query => query.get('tags') === 'shared'); - await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click(); + await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click(); await filtered; await expect(cards(page)).toHaveCount(2); // The newest match (SECOND) is selected once the filter applies. @@ -767,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring await page.goto(images); await infoButton(page, SECOND).click(); const filtered = listingOf(page, query => query.get('tags') === 'shared'); - await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click(); + await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click(); await filtered; await expect(cards(page)).toHaveCount(2); await expect(card(page, SECOND)).toHaveClass(/\bselected\b/); @@ -792,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a await page.goto(images); await infoButton(page, SECOND).click(); const filtered = listingOf(page, query => query.get('tags') === 'shared'); - await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click(); + await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click(); await filtered; await expect(cards(page)).toHaveCount(2); await openTag(page, SECOND, { goto: false }); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index dd593658..7d082a7b 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) { return {...record(), imageTags, library}; } -test('renders current image tags before linked tags without duplicates', async () => { +test('renders current image tags before linked tags, each without repeats within its source', async () => { const {inspectorTagChips} = await load(); assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported'); - assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}), - [{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]); + // D154 amends D88: a linked tag that is also an image tag stays in its own labelled section. + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}), + [{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'}, + {name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]); // D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter. assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}), - [{name:'Editorial', filter:true}, {name:'cover', filter:false}]); + [{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]); // An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones. - assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]); + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]); assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']); assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []); }); @@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => { } }); +// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the +// prompt blocks, so per-image tags never read as part of the negative prompt. +function renderVariant(record, variant, options = {}) { + const f = mountedDom(), scope = createScope(); + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options}); + view.setState({loading:false, error:false, record}); + return {f, done() {view.dispose(); scope.dispose(); f.restore();}}; +} +const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? '')); +function sectionSummary(f, section) { + const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0]; + const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? '')); + return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)}; +} +test('chips name their source: the image or the linked prompt', async () => { + const {inspectorTagChips, tagSectionLabel} = await load(); + assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source), + ['image', 'image', 'linked', 'linked', 'linked']); + assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags'); + assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags'); + assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags'); +}); +test('image tags and linked tags render in their own labelled sections', async () => { + for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) { + const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options); + try { + const sections = tagSections(f).map(section => sectionSummary(f, section)); + assert.deepEqual(sections, [ + {className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]}, + {className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]}, + ], variant); + // No bare chip row follows the prompt blocks any more. + const inner = f.container.children.find(n => n.tagName === 'div'); + assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant); + } finally { done(); } + } +}); +test('a recipe names its tags and the context names the regions', async () => { + const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'}); + try { + assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [ + {className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']}, + {className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']}, + ]); + } finally { done(); } +}); +test('an empty tag source draws no section', async () => { + let view = renderVariant(tagged(null), 'full'); + try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); } + view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full'); + try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); } + view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full'); + try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); } +}); + // LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata // page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the // legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values. diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 2a3ba905..e35551a2 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -176,24 +176,32 @@ function extensionOf(record) { return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase(); } /** - * The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already - * shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and - * a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain. + * The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked + * tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also + * an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every + * image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip + * is plain. */ export function inspectorTagChips(record, {filterable}) { - const shown = new Set(), chips = []; - const add = (names, filter) => { + const chips = []; + const add = (names, filter, source) => { + const shown = new Set(); for (const name of names) { const key = name.toLowerCase(); if (shown.has(key)) continue; - shown.add(key); chips.push({name, filter}); + shown.add(key); chips.push({name, filter, source}); } }; - if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable); + if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image'); const {library} = record; - if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive'); + if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked'); return chips; } +/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */ +export function tagSectionLabel(source, library) { + if (source === 'image') return 'Image tags'; + return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags'; +} /** A concept chip whose visible text carries a visually hidden prefix naming what it is. */ /** * The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt" @@ -295,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the // D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own // image tags lead the row and, where a Tags filter exists, always set it (it matches them too). + // D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt. const chips = inspectorTagChips(record, {filterable:!!onTagFilter}); - if (chips.length) { - const tags = el('div', undefined, 'tags'); - for (const {name, filter} of chips) { - if (!filter) {tags.append(el('span', name, 'chip')); continue;} - const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip); - } - inner.append(tags); + for (const source of ['image', 'linked']) { + const own = chips.filter(chip => chip.source === source); + if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own)); } inner.append(...generationFacts(record, 'meta-grid', 'small')); if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record)); @@ -336,6 +341,22 @@ export function mountFileInspector(container, {api, sender, parentScope, variant } if (!full && footer.childNodes.length) container.append(footer); } + /** + * D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count. + * The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags"). + */ + function tagSection(source, label, chips) { + const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading'); + section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label); + heading.append(el('span', `${label} · ${chips.length}`, 'role-label')); + const tags = el('div', undefined, 'tags'); + for (const {name: text, filter} of chips) { + if (!filter) {tags.append(el('span', text, 'chip')); continue;} + const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip); + } + section.append(heading, tags); + return section; + } /** * D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt * copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused. diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index db268fe2..57ae93ce 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -230,6 +230,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } details, and an unavailable download stays visibly inert next to its reason. */ .file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } .file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } +/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept + .prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */ +.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; } +.file-inspector .tag-section .role-label { color: var(--ca-muted); } +.file-inspector .tag-section > .tags { margin-top: 0; } /* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts, where the toolkit's `nowrap` would push them past its edge. */ .ca-tools #viewer-details > button { white-space: normal; /* D20 */ } From dff08bc1615ab14ddab546313e6f6e70c7a6b5bd Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 02:55:55 +0000 Subject: [PATCH 2/7] 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 3/7] 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'; From 505c16a7247b4191f488271a97715903fc4aac68 Mon Sep 17 00:00:00 2001 From: Vito <vito.sansevero@gmail.com> Date: Thu, 29 Oct 2026 04:36:30 +0000 Subject: [PATCH 4/7] test(collections): order the review M1 reads by events, not the 300 ms timer (#459) (#463) The M1 test let the rename answer at once and changed the hash only after the toast, so on a loaded host the rename's 300 ms count read could start before Fresh's page read; the page then waited on that one read and the poll for two held reads failed. Hold the rename's answer until Fresh's read is held. --- tests/browser/workspace-collections.spec.mjs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index a77564ed..9decc16d 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -363,14 +363,26 @@ test('a list read superseded by another read still opens a collection made since const fresh = (await added.json()).collection.id; await expect(rows(page)).toHaveCount(2); const hold = await holdListReads(page); - // A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read - // supersedes that one while both are held. + // A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The + // timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so + // the timer starts only then: with the answer let through at once, a slow host could fire the timer before the + // hash change, and the page would then wait on the timer's read alone (#459). + let releaseRename; + const renameHeld = new Promise(resolve => { releaseRename = resolve; }); + await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => { + if (route.request().method() !== 'PATCH') return route.fallback(); + await renameHeld; + return route.fallback(); + }); await editButton(page).click(); await nameField(page).fill('Landscape grids'); await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); - await expect(toast(page)).toHaveText('Collection renamed.'); await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`); - await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2); + await expect.poll(() => hold.held.length).toBe(1); + releaseRename(); + // The rename landed (the sidebar row has the new name), so the count read is now due. + await expect(row(page, 'Landscape grids')).toHaveCount(1); + await expect.poll(() => hold.held.length).toBe(2); await expect(main(page)).not.toContainText('Collections could not be loaded.'); await hold.release(); await expect(heading(page)).toHaveText('Fresh'); From 2941a9b748fed0e564851279584572cf386bbcc0 Mon Sep 17 00:00:00 2001 From: Vito <vito.sansevero@gmail.com> Date: Thu, 29 Oct 2026 05:19:04 +0000 Subject: [PATCH 5/7] feat(inspect): choosing a file opens the viewer with its details (#448) (#458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(inspector): give image tags their own labelled section (#449) The inspector drew a file's tags as a bare chip row right under the negative prompt, so they read as part of it. The side panel, the viewer details and History's current file now draw them in labelled sections in the prompt sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags · N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are removed within a source only. Recorded as D154. * feat(inspect): choosing a file opens the viewer with its details (#448) On Inspect a file, choosing or dropping a file now opens the shared viewer on it at once, details showing, instead of previewing it and waiting for "Open file metadata". Details show on this page even when "Collapse viewer details by default" is on. An unreadable file or an AVI/WMV note stays on the page; the heading action reopens any selection. A new selection that starts while the viewer shows the previous file closes it first, so its blob URL is never revoked under an open element. Recorded as D155. * fix(inspect): open a video's viewer only once its preview settles (#448) Replacing a video while both the inline preview and the auto-opened viewer's video were still starting deadlocked WebKit (D62). The viewer now opens for a video once its inline preview reports metadata or an error (or after 15 s). * test(inspect): cover the AVI note and an open dialog holding the auto-open back (#448) Frontend coverage for inspect.mjs fell below its floor (branches 91.04 < 92, functions 92.85 < 93): no spec reached the AVI toast or the open-dialog guard, and the video wait had a page-end callback no spec reached. The wait now ends through its own callback at page end, and two specs cover the AVI note and a selection landing under another dialog. * test(comfyui): the Inspect sender spec takes the viewer choosing a file opens (#448) The real-host spec chose a file and then pressed "Open file metadata"; with D155 the viewer is already open, so the press waited behind the modal until the test timed out in the merge queue (classic-start and nodes2-start). --- TESTING.md | 40 ++++++- tests/browser/inspect-concept.spec.mjs | 31 +++-- tests/browser/workspace-inspect.spec.mjs | 145 +++++++++++++++++++++-- tests/comfyui/inspect-sender.spec.mjs | 4 +- web/js/workspace/inspect.mjs | 53 +++++++-- web/js/workspace/media-viewer.mjs | 13 +- 6 files changed, 243 insertions(+), 43 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4d8daa7d..d7c45ef7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2811,10 +2811,12 @@ mapped differences"): - **D60** (heading action, LI-3): "Open file metadata" (`ca-primary`) replaces the concept's "Open sample metadata". It is `aria-disabled="true"` and `aria-describedby="local-file-note"` (the drop note) until a classified selection is - ready, then opens the shared viewer on that file. + ready, then opens the shared viewer on that file. Amended by D155: choosing the file + already opens it; the action reopens it. - **D61** (copy, LI-3): the surface `p` reads "Read embedded metadata in this browser. The file is not uploaded or added to your library."; the drop note "Your file stays in - this browser. Its metadata opens with “Open file metadata”."; the chooser label "Choose + this browser. Its metadata opens with “Open file metadata”." (D155: "Your file stays in + this browser. Choosing it opens its metadata."); the chooser label "Choose an image or video for a local preview"; the toast "Local preview only. No file uploaded; metadata is available above.". The h2 "Inspect without importing" is the concept's, verbatim. @@ -2868,6 +2870,7 @@ mapped differences"): by the next selection attempt or after the concept's toast interval (6.5 s). The toast "Local preview only…; metadata is available above." shows only for a selection whose status is empty or the AVI/WMV note, never beside an unreadable or failed preview. + Amended by D155: only the AVI/WMV note keeps the toast; an empty status opens the viewer. - **D64** (drop, LI-6): `dragover`/`drop` on `.file-drop` accept exactly one File, read only `dataTransfer.files`/`items` (never its strings) and change nothing visually. While the page is mounted, `window` `dragover`/`drop` call `preventDefault`, so a missed @@ -2880,6 +2883,36 @@ mapped differences"): Modified with no Path row and no Show in folder, and ends with "Local file; no saved library association was checked." and Download original. There is no Prompt history action and no category or tag chips. +- **D155** (#448, maintainer choice 2026-10-28, beyond the concept): choosing or dropping + a file opens the shared viewer on it at once, with `#viewer-details` showing, in one + step; the concept previews it and waits for its heading action. The viewer opens once + per selection, after the session has selected the record (a microtask after `ready`), + only while that selection is current and no other dialog is open. A new selection + that starts while the viewer shows the previous file closes it first, so no element + holds the old `blob:` URL when the session revokes it (`URLs and media end with their + owning session`); the new file's viewer opens once it is ready. A video opens the + viewer only once its inline preview has settled (metadata, an error, or D62's 15 s): + replacing a video while both the inline and the viewer's video were still starting + deadlocked WebKit (`a video replaced while still starting is reset once it settles` + hung 2 of 2 runs before, passes 5 of 5 after). It opens for a selection with no status and for one + without a preview URL ("The preview could not be displayed. Metadata and the original + remain available in the viewer."); an unreadable file stays on the page with its + alert, and AVI/WMV keeps its note and the toast (the viewer could not play it and + reads no metadata); "Open file metadata" opens either. An image or video that fails to + decode after the viewer opened shows the viewer's own media error; the page's alert + is there on close. The toast no longer shows for an opened file (the viewer is the + confirmation), and the drop note reads "Your file stays in this browser. Choosing it + opens its metadata.". On this page the viewer, by choice or by the heading action, + opens with the details showing even when "Collapse viewer details by default" is on + (D97); the toggle is still drawn then and starts as "Hide details" (`aria-expanded` + true). Closing returns focus to the chooser (the heading action when it opened the + viewer), with the preview, the chooser naming the file and the heading action in + place; the next file chosen or dropped replaces it and opens again. A drop is still + ignored while the viewer is open (D64). Nothing else changes: the file is read in this + browser only. Browser tests: "D155: choosing a file opens the viewer with its details + in one click", "D155: Inspect shows the details even when the viewer collapses them by + default (amends D97)", "D155: an unreadable file stays on the page with its alert" and + "D155: at 390px the opened details are reachable below the media". - **D117** (Copy settings summary, LI-4): the legacy metadata page's COPY ALL, consolidated into the shared full inspector (the viewer's `#viewer-details`, so Images, the recorded history's current file and Inspect all have @@ -3558,7 +3591,8 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer details by default" (`sidebarCollapsedByDefault`; the viewer opens with `#viewer-details` hidden and a "Show details"/"Hide details" toggle with - `aria-expanded`, the stage taking the whole width), and the operation "Reset display + `aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file + the viewer opens with the details showing and the toggle reading "Hide details"), and the operation "Reset display defaults" ("Restore this browser’s display defaults, then save. Other settings are kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only. The node search timeout shows the server's real value, not the sample 30. CSS under D97. diff --git a/tests/browser/inspect-concept.spec.mjs b/tests/browser/inspect-concept.spec.mjs index 48be585d..99298fc5 100644 --- a/tests/browser/inspect-concept.spec.mjs +++ b/tests/browser/inspect-concept.spec.mjs @@ -9,7 +9,7 @@ import { parityPng } from './local-inspect-files.mjs'; // `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722, // 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the // same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded -// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it +// differences D60–D66 and D155 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it // is measured, so the browser lays the concept out with production's words. After that the diff must be empty. // Paired captures are artifacts only; there are no PNG baselines. test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); @@ -22,7 +22,8 @@ const COPY = { 'Read embedded metadata in this browser. The file is not uploaded or added to your library.'], chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'], note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.', - 'Your file stays in this browser. Its metadata opens with “Open file metadata”.'], + // D155 (#448): choosing the file opens its metadata. + 'Your file stays in this browser. Choosing it opens its metadata.'], toast: ['Local preview only. No file uploaded; sample metadata is available above.', 'Local preview only. No file uploaded; metadata is available above.'], }; @@ -212,23 +213,29 @@ async function productionStates(page, theme, testInfo, tag) { await settle(page); const initial = await page.evaluate(measureInspect, '#workspace-main'); await captureCanonicalState(page, testInfo, `production-initial-${tag}`); + // D155 (#448): choosing the file opens the viewer with its details at once (the concept waits for its heading + // action), so the viewer state is measured first; the selected page is measured after Escape, with the focus the + // close returned to the chooser removed (the concept's page has no focused control there). await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE)); - await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible(); - await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {})); - await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]); - await expect(action).toHaveAttribute('aria-disabled', 'false'); - // D63: a readable PNG shows no status line. - await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0); - await settle(page); - const selected = await page.evaluate(measureInspect, '#workspace-main'); - await captureCanonicalState(page, testInfo, `production-selected-${tag}`); - await action.click(); await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible(); await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible(); await page.mouse.move(0, 0); await settle(page); const viewer = await page.evaluate(measureViewer); await captureCanonicalState(page, testInfo, `production-viewer-${tag}`); + await page.keyboard.press('Escape'); + await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0); + await page.evaluate(() => document.activeElement?.blur()); + await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible(); + await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {})); + // D155: no toast; the opened viewer was the confirmation. + await expect(page.locator('#workspace-toast')).toHaveText(''); + await expect(action).toHaveAttribute('aria-disabled', 'false'); + // D63: a readable PNG shows no status line. + await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0); + await settle(page); + const selected = await page.evaluate(measureInspect, '#workspace-main'); + await captureCanonicalState(page, testInfo, `production-selected-${tag}`); return { initial, selected, viewer }; } diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs index 2640cff4..3d77f112 100644 --- a/tests/browser/workspace-inspect.spec.mjs +++ b/tests/browser/workspace-inspect.spec.mjs @@ -8,10 +8,11 @@ import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, const workspace = '/prompt_manager/workspace'; const TOAST = 'Local preview only. No file uploaded; metadata is available above.'; +const NO_TOAST = ''; const ACTION = 'Open file metadata'; const COPY = { intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.', - note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.', + note: 'Your file stays in this browser. Choosing it opens its metadata.', chooser: 'Choose an image or video for a local preview', multiple: 'Drop one file at a time.', directory: 'Folders cannot be inspected. Choose one image or video file.', @@ -53,13 +54,26 @@ async function choose(page, file) { await chooser(page).setInputFiles(file); } +/** D155: choosing a file opens the viewer on it with its details; waits for that. */ async function openViewer(page) { - await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); - await action(page).click(); await expect(viewer(page)).toBeVisible(); await expect(rail(page).locator('.panel-heading')).toBeVisible(); } +/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */ +async function reopenViewer(page) { + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await action(page).click(); + await openViewer(page); +} + +/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */ +async function closeOpened(page) { + await openViewer(page); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); +} + /** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */ function drop(page, selector, { files = [], strings = [] } = {}) { return page.evaluate(async ({ selector, files, strings }) => { @@ -152,7 +166,8 @@ test('chooser opens the shared full inspector without a second read', async ({ p const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop })); expect(box.height).toBeLessThanOrEqual(400); expect(box).toMatchObject({ fit: 'contain', margin: '20px' }); - await expect(page.locator('#workspace-toast')).toHaveText(TOAST); + // D155: the open viewer is the confirmation, so no toast. + await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST); await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/); @@ -195,18 +210,120 @@ test('chooser opens the shared full inspector without a second read', async ({ p await page.keyboard.press('Escape'); await expect(viewer(page)).toHaveCount(0); - await expect(action(page)).toBeFocused(); + // D155: focus returns to the chooser that opened it. + await expect(chooser(page)).toBeFocused(); // Opening the viewer again shows the same record without reading the File a second time. await page.evaluate(() => { window.__slices = 0; const slice = Blob.prototype.slice; Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); }; }); - await openViewer(page); + await reopenViewer(page); expect(await railContent(rail(page))).toEqual(served); expect(await page.evaluate(() => window.__slices)).toBe(0); }); +// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it +// returns to the page with the file still chosen and focus on the chooser, ready for the next file. +test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => { + await openInspect(page); + await choose(page, parityPng('f02.png')); + await expect(viewer(page)).toBeVisible(); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); + await expect(rail(page)).toBeVisible(); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur'); + await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0); + // The open viewer is the confirmation: no toast says the metadata is "available above". + await expect(page.locator('#workspace-toast')).toHaveText(''); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + await expect(chooser(page)).toBeFocused(); + await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + // A dropped second file replaces the first and opens at once. + await drop(page, '.file-drop', { files: [sendablePng()] }); + await expect(viewer(page)).toBeVisible(); + await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png'); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + await expect(chooser(page)).toBeFocused(); + // The same file again is a new selection and opens again; the heading action reopens the current one. + expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe(''); + await choose(page, parityPng('f02.png')); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); + await page.keyboard.press('Escape'); + await action(page).click(); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); + await expect(rail(page)).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(action(page)).toBeFocused(); +}); + +test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true }))); + await openInspect(page); + await choose(page, parityPng('f02.png')); + await expect(viewer(page)).toBeVisible(); + await expect(rail(page)).toBeVisible(); + const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true }); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await toggle.click(); + await expect(rail(page)).toBeHidden(); + await page.keyboard.press('Escape'); + await action(page).click(); + await expect(rail(page)).toBeVisible(); + await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true'); +}); + +test('D155: an unreadable file stays on the page with its alert', async ({ page }) => { + await page.addInitScript(() => { + const slice = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) }; + return slice.apply(this, args); + }; + }); + await openInspect(page); + await choose(page, parityPng('f02.png', 'gone.png')); + await expect(status(page)).toHaveText('Embedded metadata could not be read.'); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await expect(viewer(page)).toHaveCount(0); +}); + +test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => { + await openInspect(page); + // AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note. + const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) }; + await choose(page, avi); + await expect(page.locator('#workspace-toast')).toHaveText(TOAST); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await expect(viewer(page)).toHaveCount(0); + await reopenViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi'); +}); + +test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => { + await openInspect(page); + await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); }); + await choose(page, parityPng('f02.png')); + await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); + await expect(viewer(page)).toHaveCount(0); + await page.evaluate(() => document.querySelector('#other-dialog').close()); + await reopenViewer(page); + await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); +}); + +test('D155: at 390px the opened details are reachable below the media', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openInspect(page); + await choose(page, parityPng('f02.png')); + await expect(viewer(page)).toBeVisible(); + const facts = rail(page).locator('.meta-grid').first(); + await facts.scrollIntoViewIfNeeded(); + await expect(facts).toBeInViewport(); + expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true); +}); + // PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the // prompts, their status lines and the facts Images shows for the same bytes in the output folder. const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12', @@ -260,7 +377,9 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => { await expect(preview(page)).toBeEmpty(); expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true }); await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); - await expect(page.locator('#workspace-toast')).toHaveText(TOAST); + // D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first. + await closeOpened(page); + await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST); // The chooser names the dropped file, never the one it replaced and never "No file chosen". expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']); const first = await preview(page).locator('img').getAttribute('src'); @@ -275,7 +394,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => { await expect(preview(page).locator('img')).toHaveAttribute('src', first); } await expect(action(page)).toHaveAttribute('aria-disabled', 'false'); - await openViewer(page); + await reopenViewer(page); await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png'); await expect(page).toHaveURL(/#\/inspect$/); }); @@ -285,6 +404,7 @@ test('cancel and same-file reselect', async ({ page }) => { await openInspect(page); await choose(page, parityPng('f02.png')); await expect(preview(page).locator('img.local-file-preview')).toBeVisible(); + await closeOpened(page); const first = await preview(page).locator('img').getAttribute('src'); // The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is. expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png'); @@ -308,6 +428,7 @@ test('cancel and same-file reselect', async ({ page }) => { const state = await urls(page); expect(state.created).toHaveLength(2); expect(state.revoked).toEqual([first]); + // The new selection opened the viewer again (D155). await openViewer(page); await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png'); }); @@ -337,6 +458,7 @@ test('preview does not imply metadata support', async ({ page }) => { // Text, markup, SVG and a playlist under media names are unsupported and never get a URL. const before = (await urls(page)).created.length; for (const file of disguised()) { + // Unsupported: nothing opens (D155). await choose(page, file); await expect(status(page)).toHaveText(COPY.unsupported); await expect(status(page)).toHaveAttribute('role', 'alert'); @@ -360,6 +482,7 @@ test('local video plays inline and in the viewer with real tags', async ({ page, await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7'); await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1); await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); await choose(page, tinyWebm()); await expect(preview(page).locator('video.local-file-preview')).toBeVisible(); await openViewer(page); @@ -386,6 +509,9 @@ test('the toast and a rejected drop never contradict the status line', async ({ // concept's toast interval. await choose(page, undecodableMp4()); const failed = status(page).filter({ hasText: COPY.previewFailed }); + // D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it. + await expect(failed).toHaveCount(1); + await closeOpened(page); await expect(failed).toHaveCount(1); await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] }); const rejected = status(page).filter({ hasText: COPY.multiple }); @@ -442,7 +568,8 @@ test('URLs and media end with their owning session', async ({ page }) => { let state = await urls(page); expect(state.revoked).toEqual([state.created[0]]); expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1); - // Route change. + // Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert). + await closeOpened(page); await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click(); await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible(); state = await urls(page); diff --git a/tests/comfyui/inspect-sender.spec.mjs b/tests/comfyui/inspect-sender.spec.mjs index 2fcb8f10..4fede99c 100644 --- a/tests/comfyui/inspect-sender.spec.mjs +++ b/tests/comfyui/inspect-sender.spec.mjs @@ -36,11 +36,11 @@ async function watchHost(page,id,reset=true) { } const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);}); -/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */ +/** Chooses `file` on the Inspect page, which opens the shared viewer on its local record (D155, #448). */ async function inspect(workspace,{file,positive,shown}) { await workspace.locator('#local-file').setInputFiles(file()); const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true}); - await expect(open).toHaveAttribute('aria-disabled','false');await open.click(); + await expect(open).toHaveAttribute('aria-disabled','false'); const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details'); await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive); await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown); diff --git a/web/js/workspace/inspect.mjs b/web/js/workspace/inspect.mjs index c514d02b..ee67d7ec 100644 --- a/web/js/workspace/inspect.mjs +++ b/web/js/workspace/inspect.mjs @@ -1,6 +1,6 @@ // The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729): -// one local file, chosen or dropped, previews inline; the heading's one action opens the shared viewer with the full -// inspector on a record read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its +// one local file, chosen or dropped, previews inline and (D155, #448) opens the shared viewer with its details showing +// at once; the heading's one action opens it again. The record is read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its // blob: URL and its record belong to one local session (local-session.mjs) that ends with this page. import { createButton, createScope } from '../shared/toolkit/core.js'; import { pageHeading } from './heading.mjs'; @@ -17,7 +17,8 @@ export const INSPECT_COPY = Object.freeze({ heading: 'Inspect without importing', intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.', chooser: 'Choose an image or video for a local preview', - note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.', + // D155 (#448): choosing a file opens its metadata (D61's note named the heading action). + note: 'Your file stays in this browser. Choosing it opens its metadata.', toast: 'Local preview only. No file uploaded; metadata is available above.', }); // D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only). @@ -38,8 +39,11 @@ function element(tag, className, text) { return node; } -// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available". -const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]); +// D155 (#448): a selection with no status, or one without a preview URL (its copy points at the viewer), opens the viewer +// at once. AVI/WMV's note keeps the toast instead (the viewer could not play it and reads no metadata), and an +// unreadable file stays on the page with its alert; the heading action opens either. +const AUTO_OPEN = new Set(['', LOCAL_SESSION_MESSAGES.previewFailed]); +const TOASTABLE = new Set([LOCAL_SESSION_MESSAGES.containerOnly]); // How long a replaced video still starting may stay in place before it is released anyway. const STARTING_VIDEO_MS = 15000; @@ -131,9 +135,10 @@ export function mountInspect(main, { parentScope, api, sender }) { }; const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL }); const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender, - adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } }); + adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); }, + onClose: () => { viewerKey = null; } }); - let media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null; + let viewerKey = null, media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null; function statusElement(message) { // /* D63 */ one status line for the states the mock never shows. const line = element('p', 'small spaced-16', message); @@ -202,14 +207,25 @@ export function mountInspect(main, { parentScope, api, sender }) { else preview.prepend(node); } scope.cleanup(session.subscribe(state => { + // D155: a new selection can start while the viewer shows the previous one; it closes first, so no element holds + // the old URL when the session revokes it (the new file's viewer opens once it is ready). + if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { viewer.close(); viewerKey = null; } showMedia(state); showStatus(state.message); const entry = session.entry; setAvailable(state.phase === 'ready' && !!entry); if (state.phase === 'ready' && entry && announced !== entry.key) { announced = entry.key; + // The session selects the record right after it publishes `ready`, so the viewer opens after that, and only + // while this selection is still the current one and no other dialog is open (the viewer is replaced). D62: + // a video opens it once the inline preview has settled, so a replacement while it starts never tears down a + // second starting video in the viewer (that deadlocked WebKit). + if (AUTO_OPEN.has(state.message)) whenSettled(media, () => { + if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return; + openViewer(input); + }); // An unreadable or failed preview says so in its alert; the toast never claims metadata is available. - if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast); + else if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast); } })); // A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the @@ -261,10 +277,25 @@ export function mountInspect(main, { parentScope, api, sender }) { }); // A drop that misses the area never navigates away from the page while it is mounted. for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault()); - scope.listen(open, 'click', () => { + /** Runs `run` after this task, or once `preview`'s video stops starting (metadata, an error, or the D62 limit). */ + function whenSettled(preview, run) { + if (!preview || !isStarting(preview.node)) { queueMicrotask(run); return; } + // `run` checks that the page and the selection are still current, so the page's end may call `done` too. + const settled = new AbortController(); + const done = () => { settled.abort(); clearTimeout(timer); run(); }; + const timer = setTimeout(done, STARTING_VIDEO_MS); + for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, done, { once: true, signal: settled.signal }); + scope.cleanup(done); + } + /** The viewer on the current selection, details showing (D155 amends D97); focus returns to `trigger` on close. */ + function openViewer(trigger) { const entry = session.entry; - if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return; - viewer.open({ items: [entry], selectedKey: entry.key, trigger: open }); + if (!entry || session.state.phase !== 'ready') return; + viewer.open({ items: [entry], selectedKey: entry.key, trigger, details: 'shown' }); + viewerKey = entry.key; + } + scope.listen(open, 'click', () => { + if (open.getAttribute('aria-disabled') !== 'true') openViewer(open); }); /** Settles when no video on the page is still starting; `null` when none is. */ function leaveGuard() { diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 10645720..da424337 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -39,7 +39,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle function open(options) { if (scope.signal.aborted) return; active?.close(false); - const {items,selectedKey,trigger = document.activeElement} = options; + const {items,selectedKey,trigger = document.activeElement,details:detailsMode} = options; let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false; let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null; const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => { @@ -79,11 +79,12 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();}); controls.append(download); const next = button('Next media','Next →',() => session.navigate(1)); - // D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. - const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true; + // D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155 + // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. + const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown'; let detailsToggle = null; - if (collapsed) { - detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false'); + if (collapsible) { + detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed)); detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle); } const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`; @@ -93,7 +94,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle controls.append(position,reason,slideshowReason);left.append(stage,controls); const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); if (detailsToggle) { - details.hidden = true; + details.hidden = collapsed; local.listen(detailsToggle,'click',() => { details.hidden = !details.hidden; detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; From 1ce25777335d0850cf7571af4f159327a892886d Mon Sep 17 00:00:00 2001 From: Vito <vito.sansevero@gmail.com> Date: Thu, 29 Oct 2026 05:38:39 +0000 Subject: [PATCH 6/7] fix(library): show a loaded page of prompts without waiting for an animation frame (#464) (#466) The shelf laid out each loaded page only in its next animation frame. A browser that holds frames (a background tab, headless WebKit under CPU load, seen holding them for more than 5 s) left the grid empty while the caption already showed the count. A loaded batch is now laid out at once when the grid is in the document; measurements still flush per frame. The regression test holds every animation frame and fails before the fix on all four projects. --- tests/browser/workspace-library.spec.mjs | 20 ++++++++++++++++++++ web/js/workspace/library.mjs | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 9c18949c..b91a73df 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -57,6 +57,26 @@ test('library groups reusable negatives under one positive without exposing nega for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`); }); +// #464: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for seconds; +// a loaded page of prompts is shown without waiting for one. +test('a held animation frame does not hide a loaded page of prompts, the first or a later one',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:45}})).toBeOK(); + const {total}=await(await request.get('/prompt_manager/library?limit=100')).json(); + await page.addInitScript(()=>{ + const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window); + const held=new Map(),real=new Map();let holding=true,next=0; + window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;}; + window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);}; + window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();}; + }); + await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + await expect(more).toHaveAttribute('aria-disabled','false');await expect(cards(page)).toHaveCount(40); + await more.evaluate(node=>node.click());await expect(page.locator('.library-status')).toHaveText('You’re all caught up.'); + await expect(cards(page)).toHaveCount(total); + await page.evaluate(()=>window.__releaseAnimationFrames());await nextFrame(page); + await expect(cards(page)).toHaveCount(total);await expect(page.locator('.library-row-spacer')).toHaveCount(0); +}); test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{ const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 97de6b37..fe187f9e 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -394,7 +394,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); - schedule();scheduleLoad(); + // #464: a loaded page is laid out now, not on the next frame: a starved renderer (a background tab, headless + // WebKit under load) can hold frames for seconds, which left a loaded shelf empty. Measurements still flush per frame. + if(batch && grid.isConnected) {cancelAnimationFrame(frame);flush();} else schedule(); + scheduleLoad(); } const stream=createLibraryStream({api,onChange:render,restore:restorable?{filters,state:restorable.state}:undefined}); function maybeLoad() { From 2c54daf2d8df95224589c115b4158c55168c3860 Mon Sep 17 00:00:00 2001 From: Vito <vito.sansevero@gmail.com> Date: Thu, 29 Oct 2026 06:30:15 +0000 Subject: [PATCH 7/7] feat(viewer): full-screen toggle beside the close button, and a System default (#450) (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(inspector): give image tags their own labelled section (#449) The inspector drew a file's tags as a bare chip row right under the negative prompt, so they read as part of it. The side panel, the viewer details and History's current file now draw them in labelled sections in the prompt sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags · N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are removed within a source only. Recorded as D154. * feat(inspect): choosing a file opens the viewer with its details (#448) On Inspect a file, choosing or dropping a file now opens the shared viewer on it at once, details showing, instead of previewing it and waiting for "Open file metadata". Details show on this page even when "Collapse viewer details by default" is on. An unreadable file or an AVI/WMV note stays on the page; the heading action reopens any selection. A new selection that starts while the viewer shows the previous file closes it first, so its blob URL is never revoked under an open element. Recorded as D155. * feat(viewer): full-screen toggle beside the close button, and a System default (#450) Every viewer gets a button before the close button that switches between Centered (today's framed dialog) and Full screen (the whole browser window, edge to edge); it names the view it switches to. In full screen the details stay toggleable. System > Appearance gains a Viewer surface with "Open viewer in: Centered / Full screen" and the moved "Collapse viewer details by default"; the choice is a browser display preference (viewerOpenMode). The button changes only the current view and hides at 700px and below, where the viewer is already full-bleed. Recorded as D156. * fix(inspect): open a video's viewer only once its preview settles (#448) Replacing a video while both the inline preview and the auto-opened viewer's video were still starting deadlocked WebKit (D62). The viewer now opens for a video once its inline preview reports metadata or an error (or after 15 s). * test(viewer): pin the D97 view-switch spec to the desktop width (#450) * test(inspect): cover the AVI note and an open dialog holding the auto-open back (#448) Frontend coverage for inspect.mjs fell below its floor (branches 91.04 < 92, functions 92.85 < 93): no spec reached the AVI toast or the open-dialog guard, and the video wait had a page-end callback no spec reached. The wait now ends through its own callback at page end, and two specs cover the AVI note and a selection landing under another dialog. --- TESTING.md | 32 ++++- tests/browser/full-inspector.spec.mjs | 8 +- tests/browser/system-concept.spec.mjs | 13 +- tests/browser/workspace-system.spec.mjs | 3 +- tests/browser/workspace-viewer-mode.spec.mjs | 142 +++++++++++++++++++ tests/browser/workspace.spec.mjs | 2 +- tests/js/gallery-settings.test.js | 13 +- tests/js/helpers/mounted-dom.mjs | 1 + tests/js/workspace-media-viewer.test.js | 10 +- tests/js/workspace-system-appearance.test.js | 33 +++-- web/js/gallery-settings.js | 6 +- web/js/workspace/icons.mjs | 5 +- web/js/workspace/media-viewer.mjs | 41 ++++-- web/js/workspace/preferences.mjs | 2 +- web/js/workspace/system-appearance.mjs | 60 +++++--- web/js/workspace/workspace.css | 7 + 16 files changed, 318 insertions(+), 60 deletions(-) create mode 100644 tests/browser/workspace-viewer-mode.spec.mjs diff --git a/TESTING.md b/TESTING.md index d7c45ef7..29ede7fc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3592,10 +3592,40 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati details by default" (`sidebarCollapsedByDefault`; the viewer opens with `#viewer-details` hidden and a "Show details"/"Hide details" toggle with `aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file - the viewer opens with the details showing and the toggle reading "Hide details"), and the operation "Reset display + the viewer opens with the details showing and the toggle reading "Hide details"; amended by D156: the row moved + to the "Viewer" surface), and the operation "Reset display defaults" ("Restore this browser’s display defaults, then save. Other settings are kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only. The node search timeout shows the server's real value, not the sample 30. CSS under D97. +- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the + shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has + `button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so + the concept's `.dialog-header > button` and D34's coarse 40px rule cover it; 8px from + ✕). It names the view it switches to in its accessible name and `title` ("Full screen + view" / "Centered view") with a `maximize` / `minimize` outline icon, and keeps focus + after a switch. Full screen (`#viewer-dialog.viewer-fullscreen`) fills the browser + window edge to edge: `inset: 0`, no width or height cap, no margin, border or radius + (`.ca-tools #viewer-dialog.viewer-fullscreen[open]`, beating the concept's box, D34's + max-height and the ≤700px height); the layout inside is the concept's. Centered is the + concept's framed dialog, unchanged. In full screen the details stay toggleable: the + D97 "Show details"/"Hide details" toggle is in the toolbar whenever the view is full + screen or "Collapse viewer details by default" is on; going back to Centered without + that setting removes the toggle and shows the details. The view is not the browser's + Fullscreen API (Esc keeps one meaning, closing the viewer; WebKit has no element + fullscreen for a modal dialog; the workspace also runs in ComfyUI popups). The viewer + opens in the view System › Appearance › **Viewer** › "Open viewer in" (Centered, + Full screen) chooses: a browser display preference, `viewerOpenMode` (`centered` by + default, normalized in `gallery-settings.js`) in the shared `gallerySettings`; no + server setting. The button changes only the current view (navigation in the viewer + keeps it; the next open uses the setting). "Viewer" is its own surface after + "Browsing & display" ("How the image viewer opens; the button beside ✕ switches the + current view."), holding "Open viewer in" and the moved "Collapse viewer details by + default"; "Reset display defaults" still restores both. At ≤700px (the concept's + breakpoint, where the viewer is already 96vw × 94dvh) the button is hidden + (`display: none !important` against the concept's `.icon-btn` rule), popup windows + that narrow included; the stored view still applies there. Browser tests: + `tests/browser/workspace-viewer-mode.spec.mjs`; the concept comparisons add the + button to the concept's header at runtime and map the Viewer surface. - **D98** (Sources): each configured source's row has a plain Remove after its scan button; nothing changes until Save. A source added this session reads "<path> · not saved yet" with Remove only until Save; a configured source the server cannot use diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index fa740476..c35e1ddd 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -558,7 +558,8 @@ test.describe('full inspector coarse pointer',()=>{ } await openSelected(page);const viewer=page.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); - for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) { + // #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×. + for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) { const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};}); expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80); } @@ -621,6 +622,11 @@ test('viewer matches the concept shell and toolbar order',async({page,request})= const production=await page.evaluate(VIEWER_SHELL); // C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into. production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details')); + // #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none. + const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode')); + expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']); + expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close'); + production.paths=production.paths.filter(path=>!modePaths.includes(path)); // #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow // reasons), and Download stays the original-file link; every other control keeps the concept's text and order. const appended=production.toolbar.slice(9); diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 1d96d442..136cfb58 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -475,9 +475,9 @@ function measureAppearance(mainSelector) { } // D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them. +// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs). const ADDED_APPEARANCE_ROWS = [ ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true], - ['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false], ]; /** The concept's Appearance with the recorded deviations applied, given the server's real settings. */ @@ -488,10 +488,11 @@ function mapAppearanceConcept(reference, settings) { control: { ...toggleRow.control, id, checked } })); return { ...reference, - // #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance). - layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)], - // D97: two added toggles and the "Reset display defaults" operation close the surface. - surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] }, + // D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails", + // README: retained in Appearance). + layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)], + // D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface. + surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] }, rows: [...reference.rows.map(row => row.control?.id === 'timeout' // The node search timeout is the server's real `result_timeout`, not the concept's sample 30. ? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added], @@ -538,7 +539,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => { // Unmapped, the concept differs only where D97 and the real timeout are recorded. expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.')) .filter((path, index, all) => all.indexOf(path) === index).sort()) - .toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', + .toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.length', 'surface.tree'].sort()); const measure = () => page.evaluate(measureAppearance, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 1ea7505d..6581fe85 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -287,7 +287,8 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK(); await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK(); await page.goto(`${workspace}#/system/appearance`); - const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1); + // #450 D156: the Viewer surface sits between Browsing & display and Thumbnails. + const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2); await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails'); await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']); const format = surface.locator('#thumbnail-format'); diff --git a/tests/browser/workspace-viewer-mode.spec.mjs b/tests/browser/workspace-viewer-mode.spec.mjs new file mode 100644 index 00000000..704b89a4 --- /dev/null +++ b/tests/browser/workspace-viewer-mode.spec.mjs @@ -0,0 +1,142 @@ +// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to +// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance › +// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only. +import { test, expect } from './fixtures.mjs'; +import { seedConcept, openProduction } from './concept-inventory.mjs'; +import { parityPng } from './local-inspect-files.mjs'; + +test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); + +const CARDS = '.gallery-body > .image-grid > .media-card'; +const viewer = page => page.locator('dialog#viewer-dialog[open]'); +const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode'); +const details = page => viewer(page).locator('#viewer-details'); + +test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); }); + +async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) { + await page.setViewportSize({ width, height }); + await openProduction(page, 'images', theme); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible(); +} + +/** The dialog's box and edges against the window. */ +const frame = page => viewer(page).evaluate(node => { + const box = node.getBoundingClientRect(), style = getComputedStyle(node); + return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right), + bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius, + border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 }; +}); + +test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => { + await openImages(page); + const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName)); + expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']); + await expect(modeButton(page)).toHaveAccessibleName('Full screen view'); + await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view'); + await expect(modeButton(page).locator('svg.icon')).toHaveCount(1); + const centered = await frame(page); + expect(centered.left).toBeGreaterThan(0); + expect(centered.fills).toBe(false); + // Keyboard: the button is reachable and keeps focus after a switch. + await viewer(page).locator('#viewer-close').focus(); + await page.keyboard.press('Shift+Tab'); + await expect(modeButton(page)).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(modeButton(page)).toBeFocused(); + await expect(modeButton(page)).toHaveAccessibleName('Centered view'); + expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true }); + // The details stay toggleable in full screen. + const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true }); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await toggle.click(); + await expect(details(page)).toBeHidden(); + await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click(); + await expect(details(page)).toBeVisible(); + // Back to Centered: today's dialog, with no toggle while the collapse setting is off. + await modeButton(page).click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); + expect(await frame(page)).toEqual(centered); + await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0); + // Navigation keeps the current view; Esc closes from full screen; the next open is Centered again. + await modeButton(page).click(); + await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click(); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus(); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); +}); + +test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true }))); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'images', 'dark'); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).toBeVisible(); + await expect(details(page)).toBeHidden(); + await modeButton(page).click(); + await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible(); + await modeButton(page).click(); + await expect(details(page)).toBeHidden(); + await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false'); +}); + +test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'system/appearance', 'dark'); + const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) }); + await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']); + const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true }); + await expect(select).toHaveValue('centered'); + await select.selectOption('fullscreen'); + await page.getByRole('button', { name: 'Save preferences', exact: true }).click(); + await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen'); + await page.evaluate(() => { location.hash = '#/images'; }); + await page.locator(CARDS).first().locator('.image-open').click(); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(modeButton(page)).toHaveAccessibleName('Centered view'); + await modeButton(page).click(); + await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen'); +}); + +test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true }))); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, 'inspect', 'dark'); + await page.locator('#local-file').setInputFiles(parityPng('f02.png')); + await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/); + await expect(details(page)).toBeVisible(); + await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible(); +}); + +test('the Library gallery viewer has the button too', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark'); + await page.locator('.media-card .image-open').first().click(); + await expect(modeButton(page)).toHaveAccessibleName('Full screen view'); +}); + +test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => { + await openImages(page, { width: 390, height: 844 }); + await expect(viewer(page).locator('#viewer-mode')).toBeHidden(); + await expect(viewer(page).locator('#viewer-close')).toBeVisible(); +}); + +test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => { + await openImages(page); + await modeButton(page).click(); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + const zoomed = await frame(page); + expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 }); + await page.evaluate(() => { document.documentElement.style.zoom = '1'; }); + if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) { + const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]); + expect(Math.min(...size)).toBeGreaterThanOrEqual(40); + } + test.info().annotations.push({ type: 'engine', description: browserName }); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 3dd4b920..dff63d85 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible(); if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2); expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) - .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'], + .toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */, sources: ['div.notice', 'section.surface', 'div.save-row'], integrations: ['section.surface', 'section.surface', 'div.save-row'], maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'], diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index 54b22f4e..6b2666e8 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -79,6 +79,15 @@ test("the workspace keys have their own defaults and validation", () => { assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); }); +// #450 (D156): the viewer's default view, Centered unless Full screen was chosen. +test("the viewer open mode defaults to centered and accepts only its two values", () => { + const { VIEWER_OPEN_MODES } = require("../../web/js/gallery-settings.js"); + assert.deepEqual(VIEWER_OPEN_MODES, ["centered", "fullscreen"]); + assert.equal(DEFAULT_SETTINGS.viewerOpenMode, "centered"); + assert.equal(normalizeGallerySettings({ viewerOpenMode: "fullscreen" }).viewerOpenMode, "fullscreen"); + for (const value of ["full", true, 1, null]) assert.equal(normalizeGallerySettings({ viewerOpenMode: value }).viewerOpenMode, "centered"); +}); + // #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save // or a Reset from the legacy page keeps every other stored key. function legacyGallery(stored, controls, extra = {}) { @@ -112,7 +121,8 @@ const PAGE_OPTION_CONTROLS = () => ({ test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1, - imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" }, + imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list", + viewerOpenMode: "fullscreen" }, PAGE_OPTION_CONTROLS()); manager.saveSettings(); const result = saved(); @@ -123,6 +133,7 @@ test("a legacy gallery Save keeps the workspace keys and every other stored key" assert.equal(result.showImageInfo, false); assert.equal(result.videoMute, false); assert.equal(result.thumbnailPromptDismissed, "sig"); + assert.equal(result.viewerOpenMode, "fullscreen"); // Page options no longer edit the default view (S13 G5), so a stored one stays. assert.equal(result.defaultViewMode, "list"); // The page-only keys come from the dialog. diff --git a/tests/js/helpers/mounted-dom.mjs b/tests/js/helpers/mounted-dom.mjs index 3b8bffc4..1d1530d5 100644 --- a/tests/js/helpers/mounted-dom.mjs +++ b/tests/js/helpers/mounted-dom.mjs @@ -10,6 +10,7 @@ export function mountedDom() { // As in the DOM, a string argument becomes a text node. static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} + insertBefore(node,before){node.parent=this;const index=this.children.indexOf(before);this.children.splice(index<0?this.children.length:index,0,node);return node;} prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index 628ccdd7..d9370950 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -188,6 +188,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi } finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); } }); +const left0 = dialog => dialog.children[1].children[0]; test('viewer renders the concept shell and toolbar order', async () => { const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')]; @@ -199,7 +200,14 @@ test('viewer renders the concept shell and toolbar order', async () => { assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog'); const [header,layout] = dialog.children; assert.equal(header.className,'dialog-header'); - const [heading,close] = header.children, [title,subtitle] = heading.children; + const [heading,mode,close] = header.children, [title,subtitle] = heading.children; + // #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default). + assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Centered view'); + assert.ok(f.walk(left0(dialog),n => n.textContent === 'Hide details').length === 1,'full screen adds the details toggle'); + mode.fire('click'); + assert.equal(mode.attributes['aria-label'],'Full screen view'); assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']); assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']); assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']); diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js index f45fb008..9c63763d 100644 --- a/tests/js/workspace-system-appearance.test.js +++ b/tests/js/workspace-system-appearance.test.js @@ -65,7 +65,7 @@ function fakeDOM() { const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true, imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true, - sidebarCollapsedByDefault: false }; + sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }; async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) { const { mountAppearance } = await appearanceModule; @@ -80,9 +80,9 @@ async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: }); await drafts.load('browser'); const scope = createScope(); - const [surface] = mountAppearance(null, { scope, drafts }); - const byId = id => dom.find(surface, node => node.dataset?.setting === id); - return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, + const [surface, viewer] = mountAppearance(null, { scope, drafts }); + const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id); + return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); }, fixHost: () => { hostFails = false; } }; } @@ -98,13 +98,26 @@ test('the surface is the concept section with the concept rows in order, then th assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection', 'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos', 'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards', - 'Collapse viewer details by default', 'Reset display defaults']); + 'Reset display defaults']); const continuous = rows[2]; assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']); assert.equal(continuous.children[1].className, 'chip blue'); assert.equal(continuous.children[1].textContent, 'Infinite scroll'); assert.equal(rows.at(-1).className, 'operation'); - assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13); + assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12); + } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } +}); + +// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults. +test('the Viewer surface holds Open viewer in and the moved collapse row', async () => { + const { VIEWER_ROWS } = await appearanceModule; + const { dom, viewer, scope } = await mounted(); + try { + assert.equal(viewer.className, 'surface'); + assert.equal(viewer.children[0].textContent, 'Viewer'); + assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.'); + assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']); + assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']); } finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); } }); @@ -127,9 +140,11 @@ test('added rows reach their consumers: each control edits its stored key with t byId(id).checked = false; byId(id).dispatch('change'); } for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); } + assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]); + byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change'); assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low', autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false, - videoAutoplay: true, sidebarCollapsedByDefault: true }); + videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }); assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' }); } finally { scope.dispose(); dom.restore(); } }); @@ -154,7 +169,7 @@ test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => test('reset restores defaults without touching other keys', async () => { const { DISPLAY_KEYS } = await appearanceModule; - const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true }; + const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' }; const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed }); try { await loadHost(); @@ -163,7 +178,7 @@ test('reset restores defaults without touching other keys', async () => { assert.equal(reset.textContent, 'Reset'); reset.click(); // #451: the default layout is Masonry. - assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false }); + assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' }); // 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 1fcb3dbd..552f6c6a 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -17,6 +17,7 @@ // The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry. const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"]; const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"]; + const VIEWER_OPEN_MODES = ["centered", "fullscreen"]; const LIMIT_OPTIONS = [50, 100, 200, 500]; const MIN_COLUMNS = 2; const MAX_COLUMNS = 12; @@ -43,6 +44,8 @@ // #451: Images opens in Masonry unless this browser chose another layout. workspaceLayout: "masonry", workspaceDensity: "comfortable", + // #450 (D156): how the workspace viewer opens. + viewerOpenMode: "centered", }); function toInt(value) { @@ -83,6 +86,7 @@ videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop), workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout, workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity, + viewerOpenMode: VIEWER_OPEN_MODES.includes(merged.viewerOpenMode) ? merged.viewerOpenMode : DEFAULT_SETTINGS.viewerOpenMode, }; } @@ -92,7 +96,7 @@ return `repeat(${n}, minmax(0, 1fr))`; } - const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle }; + const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, VIEWER_OPEN_MODES, normalizeGallerySettings, gridColumnsStyle }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.GallerySettings = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index 5ce605a3..52fdabaa 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -21,7 +21,10 @@ const paths = Object.freeze({ "tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5", "folder": "M3 5h7l2 3h9v12H3z", "history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2", - "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5" + "review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5", + // #450 (D156): the viewer's Full screen / Centered switch. + "maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5", + "minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" }); /** Create an approved decorative outline icon without injecting markup. */ diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index da424337..cda26c2c 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -3,6 +3,7 @@ import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; import {displayPreferences, readPreferences} from './preferences.mjs'; +import {createWorkspaceIcon} from './icons.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. @@ -56,7 +57,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const heading = element('div'), title = element('h2');title.id = 'viewer-title'; heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow')); const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true; - header.append(heading,close); + // #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view. + let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen'; + const mode = createButton('','icon-btn');mode.id = 'viewer-mode'; + header.append(heading,mode,close); const body = element('div','viewer-layout'), left = element('div','viewer-left'); const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0; stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview'); @@ -80,27 +84,36 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle controls.append(download); const next = button('Next media','Next →',() => session.navigate(1)); // D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155 - // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. + // (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450): + // the toggle is also there while the view is full screen. const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown'; - let detailsToggle = null; - if (collapsible) { - detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed)); - detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle); - } + const detailsToggle = createButton('');detailsToggle.setAttribute('aria-controls','viewer-details'); const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`; position.setAttribute('role','status'); const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`; const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; controls.append(position,reason,slideshowReason);left.append(stage,controls); const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); - if (detailsToggle) { - details.hidden = collapsed; - local.listen(detailsToggle,'click',() => { - details.hidden = !details.hidden; - detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; - detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); - }); + details.hidden = collapsed; + function paintToggle() { + detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details'; + detailsToggle.setAttribute('aria-expanded',String(!details.hidden)); } + /** The toggle is in the toolbar only in full screen or with the collapse setting; without both the details show. */ + function syncToggle() { + if (collapsible || fullscreen) { if (!detailsToggle.isConnected) controls.insertBefore(detailsToggle,position); } + else { detailsToggle.remove();details.hidden = false; } + paintToggle(); + } + function paintMode() { + if (fullscreen) dialog.classList.add('viewer-fullscreen'); else dialog.classList.remove('viewer-fullscreen'); + const label = fullscreen ? 'Centered view' : 'Full screen view'; + mode.setAttribute('aria-label',label);mode.title = label; + mode.replaceChildren(createWorkspaceIcon(fullscreen ? 'minimize' : 'maximize')); + } + local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();}); + local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();}); + paintMode();syncToggle(); body.append(left,details);dialog.append(header,body);container.append(dialog); const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs index c964d846..1c420389 100644 --- a/web/js/workspace/preferences.mjs +++ b/web/js/workspace/preferences.mjs @@ -21,7 +21,7 @@ */ export const PREFERENCES_KEY = 'gallerySettings'; export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata', - 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']); + 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault', 'viewerOpenMode']); // The changes this tab could not store, overlaid on every read until storage accepts them. const tabOnly = new Map(); diff --git a/web/js/workspace/system-appearance.mjs b/web/js/workspace/system-appearance.mjs index 2f6210e7..13b59eed 100644 --- a/web/js/workspace/system-appearance.mjs +++ b/web/js/workspace/system-appearance.mjs @@ -13,7 +13,10 @@ * (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset * display defaults", which drafts the gallery's defaults for these browser rows and touches no other key. * - * - #317: a second surface, "Thumbnails", holds the server's thumbnail format, quality and size limit + * - #450 (D156, amends D97): a "Viewer" surface after it holds "Open viewer in" (Centered→`centered`, Full + * screen→`fullscreen`, `viewerOpenMode`) and the moved "Collapse viewer details by default"; "Reset display defaults" + * covers both surfaces' browser rows. + * - #317: a further surface, "Thumbnails", holds the server's thumbnail format, quality and size limit * (system-thumbnails.mjs). * * Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row @@ -50,14 +53,20 @@ export const APPEARANCE_ROWS = Object.freeze([ { type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }], ['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout', { type: 'number' }], - // D97: the legacy gallery's card and viewer settings. + // D97: the legacy gallery's card setting (its viewer setting moved to VIEWER_ROWS, D156). ['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN], +]); + +/** #450 (D156): the "Viewer" surface's rows, in the same shape. */ +export const VIEWER_ROWS = Object.freeze([ + ['viewer-mode', 'Open viewer in', 'Full screen fills the browser window; Centered is the framed viewer.', 'browser', 'viewerOpenMode', + { type: 'select', options: [['centered', 'Centered'], ['fullscreen', 'Full screen']] }], ['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser', 'sidebarCollapsedByDefault', BOOLEAN], ]); -/** The browser keys "Reset display defaults" restores (D97). */ -export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); +/** The browser keys "Reset display defaults" restores (D97), on both surfaces. */ +export const DISPLAY_KEYS = Object.freeze([...APPEARANCE_ROWS, ...VIEWER_ROWS].filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4])); /** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */ export function resultTimeout(text) { @@ -104,29 +113,36 @@ function paint(input, control, value) { /** * Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes: "Browsing & display", - * then the server's thumbnail settings (#317, system-thumbnails.mjs). + * "Viewer" (D156), then the server's thumbnail settings (#317, system-thumbnails.mjs). */ export function mountAppearance(container, { scope, drafts, api, activity }) { const surface = el('section', undefined, 'surface'); surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.')); const controls = []; - for (const row of APPEARANCE_ROWS) { - if (row === 'continuous') { surface.append(continuousRow()); continue; } - const [id, label, description, domain, key, control] = row; - const onChange = value => { - if (key === 'result_timeout') { - const seconds = resultTimeout(input.value); - if (seconds === null) { drafts.reject(domain, key); return; } - value = seconds; - } - drafts.set(domain, key, value); - }; - const node = settingRow({ id, label, description, control, scope, onChange }); - surface.append(node); - const input = controlOf(node, id); - input.dataset.key = key; - controls.push({ input, control, domain, key, text: node.children[0], note: null }); + function addRows(target, rows) { + for (const row of rows) { + if (row === 'continuous') { target.append(continuousRow()); continue; } + const [id, label, description, domain, key, control] = row; + const onChange = value => { + if (key === 'result_timeout') { + const seconds = resultTimeout(input.value); + if (seconds === null) { drafts.reject(domain, key); return; } + value = seconds; + } + drafts.set(domain, key, value); + }; + const node = settingRow({ id, label, description, control, scope, onChange }); + target.append(node); + const input = controlOf(node, id); + input.dataset.key = key; + controls.push({ input, control, domain, key, text: node.children[0], note: null }); + } } + addRows(surface, APPEARANCE_ROWS); + // D156: the viewer's defaults in their own surface. + const viewer = el('section', undefined, 'surface'); + viewer.append(el('h2', 'Viewer'), el('p', 'How the image viewer opens; the button beside ✕ switches the current view.')); + addRows(viewer, VIEWER_ROWS); surface.append(operationRow({ title: 'Reset display defaults', description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope, onClick: () => { @@ -146,5 +162,5 @@ export function mountAppearance(container, { scope, drafts, api, activity }) { }; scope.cleanup(drafts.subscribe(repaint)); repaint(); - return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })]; + return [surface, viewer, mountThumbnailSettings(container, { scope, drafts, api, activity })]; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 527f9675..de08fa2f 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -187,6 +187,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */ .ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ } #viewer-dialog .dialog-header > div { min-width: 0; } +/* #450 D156: the view switch sits before ✕ (8px apart; the header's gap is the concept's 18px), and Full screen fills the + browser window edge to edge (no outer margin), beating the concept's box, D34's max-height and the ≤700px height. + At ≤700px the viewer is already full-bleed, so the switch hides there. */ +#viewer-dialog .dialog-header > #viewer-mode { margin-inline-start: auto; margin-inline-end: -10px; } +.ca-tools #viewer-dialog.viewer-fullscreen[open] { inset: 0; width: auto; height: auto; max-width: none; max-height: none; margin: 0; border: 0; border-radius: 0; } +/* `!important` only to beat the concept's `.icon-btn { display: inline-grid !important }`. */ +@media (max-width: 700px) { #viewer-dialog .dialog-header > #viewer-mode { display: none !important; } } #viewer-title { overflow-wrap: anywhere; /* D34 */ } #viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; } #viewer-details { overscroll-behavior: contain; /* D34 */ }