From 950cca11819248f8b1e6612fbbe3fa618feac034 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 21 Oct 2026 04:44:07 -0700 Subject: [PATCH] refactor(workspace): drop the dead embedded caption and tighten the review guards - images.mjs: an embedded collection never has a caption (D47), so the caption option, its "Linked images" heading, the embedded count string and the heading arm of the focus fallback are removed; callers no longer pass caption:false. Behavior is unchanged. - workspace.css: delete the never-applied .recorded-run-dialog::backdrop rule (the concept's .ca-tools dialog::backdrop always won) and note that the D3 row's min-inline-size: 0 is load-bearing for the concept's <=700px 1fr track. - concept-css test: the waiting-rule guard is case-insensitive and is proven against the exact retired comments and the kept provenance labels. - history-concept spec: function replacement for the singular run label, and the DOM mutation removes Compare negatives by role. - D47 markers sit where the caption trim happens. --- tests/browser/history-concept.spec.mjs | 4 +-- tests/browser/workspace-images.spec.mjs | 4 ++- tests/vendor/concept-css.test.mjs | 34 ++++++++++++++++++++++--- web/js/workspace/images.mjs | 24 ++++++++--------- web/js/workspace/library.mjs | 9 ++++--- web/js/workspace/workspace.css | 4 +-- 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/tests/browser/history-concept.spec.mjs b/tests/browser/history-concept.spec.mjs index 198da72..bac5df4 100644 --- a/tests/browser/history-concept.spec.mjs +++ b/tests/browser/history-concept.spec.mjs @@ -116,7 +116,7 @@ async function conceptAsRecorded(page) { const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')]; const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]); lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); - for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,'$11 run'); + for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`); const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative'); const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt'); archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived); @@ -288,7 +288,7 @@ test('history comparison detects mutations',async({page},testInfo)=>{ await restoreCss();await settle(page); assertHistoryMatch(reference,await measure()); - await mutate(page,{remove:['.timeline-panel .row-actions > button[aria-pressed]:first-child']}); + await compareButton(page).evaluate(node=>node.remove()); const domMutated=await measure(); expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/); }); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index b5ce950..2168a33 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -174,7 +174,9 @@ for (const embedded of [false, true]) { if (close !== 'parent abort') { await expect(preview).toBeFocused(); await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']); - await expect(caption(page)).toContainText(embedded ? '80 loaded' : '80 of 80'); + // An embedded collection has no caption (D47); its loaded cards show the continuation instead. + if (embedded) { await expect(cards(page)).toHaveCount(80); await expect(caption(page)).toHaveCount(0); } + else await expect(caption(page)).toContainText('80 of 80'); } await page.evaluate(() => window.continuationFixture.scope.dispose()); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); diff --git a/tests/vendor/concept-css.test.mjs b/tests/vendor/concept-css.test.mjs index 6cedb95..34364ea 100644 --- a/tests/vendor/concept-css.test.mjs +++ b/tests/vendor/concept-css.test.mjs @@ -74,11 +74,39 @@ test('overlay rules on concept selectors carry a decision marker', () => { }); // #249 PR F-b Task 3: every page is rebuilt, so no rule waits for a page PR any more and the history page's -// production-only rules live in the overlay in the concept's tokens (D48). +// production-only rules live in the overlay in the concept's tokens (D48). A comment that opens with a page PR +// ("PR F: ...", "PR E/F: ...") or defers to one ("until ... PR", "retired in PR F") is a waiting rule; a provenance +// label inside a heading ("(#249 PR E)") is not. +const WAITING_RULE = [ + /\/\*\s*PR [A-F](\/[A-F]|-[ab])?\b/i, + /\buntil\b[^*]*\b(PR [A-F]?|rebuild)/i, + /\bretired in PR\b/i, + /\binterim page guards\b/i, +]; +const waitingRules = css => WAITING_RULE.filter(pattern => pattern.test(css)).map(String); + +test('the waiting-rule guard catches the retired comments and spares provenance labels', () => { + // The exact comments this PR retired (workspace.css and library.css at 10a4847). + for (const retired of [ + '/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text', + ' that keep their own design until their page PR rebuilds them in concept markup. */', + '.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */', + '.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */', + ' .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */', + '/* Until their pages rebuild (PR F history): the history page\'s and the recorded run\'s prompt blocks keep their', + '/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */', + " keeps the concept's) and are retired in PR F (history). */", + ]) assert.notDeepEqual(waitingRules(retired), [], retired); + for (const kept of [ + "/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */", + '/* History variants panel (#249 PR F). */', + ]) assert.deepEqual(waitingRules(kept), [], kept); +}); + test('the overlay keeps no rule waiting for a page rebuild', () => { const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8'); - for (const retired of ['until PR', 'PR D */', 'PR F */', 'PR E/F', 'until their pages rebuild', 'Interim page guards', - '.library-chip', '--library-positive', '--library-negative', '--prompt-role']) { + assert.deepEqual(waitingRules(css), []); + for (const retired of ['.library-chip', '--library-positive', '--library-negative', '--prompt-role']) { assert.equal(css.includes(retired), false, `${retired} is retired`); } }); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index c840de5..44393b5 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -97,11 +97,10 @@ function deliverFavorite(key, favorite) { } /** - * `caption: false` (the history page's shared gallery and its All images and Repeat run dialogs, D47) leaves out the - * embedded collection's "Linked images" heading and count, whose page heading or dialog title already names it; - * `focusTarget` then takes focus where that heading did. + * An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no + * caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus. */ -export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, caption: showCaption = true, focusTarget = null} = {}) { +export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -197,17 +196,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const body = el('div', undefined, 'gallery-layout image-workspace'); const collection = el('section', undefined, 'gallery-body'); collection.setAttribute('aria-label', 'Images'); - // The concept's caption replaces the old "All images" heading; the embedded gallery keeps its own heading. + // The concept's caption replaces the old "All images" heading. const caption = el('div', undefined, 'section-caption'); - const collectionHeading = embedded && showCaption ? el('h2', 'Linked images') : null; - if (collectionHeading) { collectionHeading.tabIndex = -1; caption.append(collectionHeading); } const count = el('span', 'Loading images…'); - if (!embedded) count.id = 'result-count'; + count.id = 'result-count'; const captionDay = el('span'); captionDay.hidden = true; - caption.append(count); - if (!embedded) caption.append(captionDay); - const focusFallback = collectionHeading || focusTarget || heading?.h1 || main; + caption.append(count, captionDay); + const focusFallback = focusTarget || heading?.h1 || main; const grid = el('div', undefined, 'image-grid'); grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection'); const continuation = el('div', undefined, 'infinite-continuation'); @@ -218,12 +214,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const retry = createButton('Retry'); continuation.append(message, more, retry); // The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards. - // An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620). /* D47 */ + // An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620). const showsEmpty = !embedded || typeof emptyText === 'string'; const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty'); empty.hidden = true; // D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption. - if (showCaption) collection.append(caption); + if (!embedded) collection.append(caption); collection.append(grid); if (showsEmpty) collection.append(empty); collection.append(continuation); @@ -517,8 +513,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index); } state = next; - setText(count, embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next)); if (!embedded) { + setText(count, countLine(next)); setText(captionDay, captionDate(next.items, filters.sort)); if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index e8e43a3..fc42fdd 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -615,7 +615,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all); variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower); // The concept's shared gallery (app.js a616-621): its heading names the selected variant; the embedded - // collection renders the PR C media cards. Recorded executions (#209) stay below it. /* D47 */ + // collection renders the PR C media cards. Recorded executions (#209) stay below it. const context=el('span','','small');context.setAttribute('role','status'); const galleryTitle=el('h2','Shared gallery');galleryTitle.tabIndex=-1; const galleryHeading=el('div',undefined,'panel-heading');galleryHeading.append(galleryTitle,context); @@ -691,8 +691,9 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const archived=pairId!==null && archivedSelection(pairId); updateGalleryContext(); galleryScope?.dispose();galleryScope=createScope();images.replaceChildren(); - // An archived pair's images are read in the archived scope; the gallery stays authoritative. - mountImages(images,api,galleryScope,{embedded:true,caption:false,focusTarget:galleryTitle,emptyText:pairId===null?undefined:'Saved variant · no runs yet.',fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); + // An archived pair's images are read in the archived scope; the gallery stays authoritative. The + // embedded collection has no caption: the gallery heading names it (D47). + mountImages(images,api,galleryScope,{embedded:true,focusTarget:galleryTitle,emptyText:pairId===null?undefined:'Saved variant · no runs yet.',fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); updateSend();updateCompare(); } /** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */ @@ -767,7 +768,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}}); // D47: the dialog's own title names its images, as in the concept (a1282-1304), so the collection has no // "Linked images" heading or count; a stranded focus returns to the dialog's close button. - mountImages(grid,api,dialogScope,{embedded:true,caption:false,focusTarget:grid.closest('dialog')?.querySelector('.dialog-header > button') ?? null,fetchPage}); + mountImages(grid,api,dialogScope,{embedded:true,focusTarget:grid.closest('dialog')?.querySelector('.dialog-header > button') ?? null,fetchPage}); } const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal}); /** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */ diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index c5a8413..5baece7 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -189,7 +189,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ } /* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid` - tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. */ + tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's + `min-inline-size: 0` is load-bearing: it keeps cards out of the concept's ≤700px `1fr` track's min-content. */ .library-main .library-grid { overflow-anchor: none; /* D3 */ } .library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; /* D3 */ } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; /* D3 */ } @@ -246,7 +247,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; /* D48 */ } /* D48: the Recorded run dialog (#209, D26). */ .ca-tools dialog.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border-radius: 7px; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; /* D48 */ } -.recorded-run-dialog::backdrop { background: #0009; /* D48 */ } .recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; /* D48 */ } .recorded-run-header h2 { font-size: 20px; /* D48 */ } .recorded-run-body, .recorded-run-body > section { min-inline-size: 0; /* D48 */ }