From c858329f7d0452054757d46a0d24cb8b9aa9e2ea Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 16:43:23 -0700 Subject: [PATCH] refactor(workspace): one concept heading and footer for every route --- .../vocabulary-canonical-comparison.mjs | 6 +- tests/browser/workspace-images.spec.mjs | 2 +- .../browser/workspace-library-editor.spec.mjs | 2 +- tests/browser/workspace-library.spec.mjs | 6 +- tests/browser/workspace-visual.spec.mjs | 16 ++--- tests/browser/workspace-vocabulary.spec.mjs | 10 +-- tests/browser/workspace.spec.mjs | 70 +++++++++++++++++++ tests/comfyui/library-sender.spec.mjs | 6 +- tools/vendor/frontend-coverage-baseline.json | 6 ++ web/js/workspace/heading.mjs | 41 +++++++++++ web/js/workspace/images.mjs | 8 +-- web/js/workspace/index.mjs | 15 ++-- web/js/workspace/library.css | 13 ++-- web/js/workspace/library.mjs | 17 +++-- web/js/workspace/vocabulary.mjs | 7 +- web/js/workspace/workspace.css | 15 +--- web/workspace.html | 2 +- 17 files changed, 172 insertions(+), 70 deletions(-) create mode 100644 web/js/workspace/heading.mjs diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs index c57c3fe..ac8d0a3 100644 --- a/tests/browser/vocabulary-canonical-comparison.mjs +++ b/tests/browser/vocabulary-canonical-comparison.mjs @@ -3,7 +3,7 @@ // mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling. export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs'; -export function pageInventoryOf(main, production) { +export function pageInventoryOf(main) { const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; const px=value=>Math.round(value); const visible=node=>node.getClientRects().length>0; @@ -18,8 +18,8 @@ export function pageInventoryOf(main, production) { // Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged. const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;}; - const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading'); - const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild; + const heading=main.querySelector(':scope > .heading'); + const copy=heading.firstElementChild; const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions'); const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect(); const surfaces=[...grid.querySelectorAll(':scope > section.surface')]; diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index cca9ff7..83109df 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -688,7 +688,7 @@ test('Images uses approved geometry while keeping real controls', async ({page,r await page.setViewportSize({width:1600,height:1100}); await page.goto(workspace); await expect(cards(page)).toHaveCount(24); - await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Browse your images, explore the details, keep your creative context.'); await expect(details(page)).toHaveCSS('width','310px'); await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px'); for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible(); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index c67ce2d..1efc62b 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -433,7 +433,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark',' // Real entry actions: Library, Images and prompt history headings, and the Negative variants panel. const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true}); -const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions'); +const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions'); const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true}); const toast=page=>page.locator('#workspace-toast'); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index c3d9467..7efae6d 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -188,7 +188,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json(); await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark')); const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); - await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click(); + await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click(); const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'}); @@ -279,8 +279,8 @@ test('Library natural rows retain long prompt content without overflow',async({p for(const [width,height] of [[1600,1100],[390,844]]) { await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6); expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); - await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.'); - await expect(page.locator('.library-shelf > .intro')).toHaveCount(0); + await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.'); + await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1); for(const theme of ['dark','light']) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 64ab830..1bc7bea 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) { const screenshotPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: screenshotPath, fullPage }); await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); - const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { const node = document.querySelector(selector); if (!node) return [selector, null]; const box = node.getBoundingClientRect(), style = getComputedStyle(node); @@ -104,7 +104,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible(); // As in the concept, the selected pair's send sits in the history heading, not among the saved texts. await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0); - await expect(page.locator('.workspace-heading button.send')).toBeEnabled(); + await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled(); await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2); await page.locator('.library-shared-gallery .image-open').first().click(); await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png'); @@ -197,7 +197,7 @@ test('shell matches approved stable geometry in both themes', async ({ page, req for (const route of ['images', 'library']) { await page.goto(`${workspace}#/${route}`); if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px'); - await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible(); + await expect(page.locator('#workspace-main > .heading > div > h1#page-title')).toBeVisible(); await groupedNavigation(page); await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible(); await expect(page.locator('.brand')).toHaveAttribute('href', '#/images'); @@ -207,9 +207,9 @@ test('shell matches approved stable geometry in both themes', async ({ page, req await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active); await expect(page.locator('h1')).toHaveCSS('font-weight', '620'); await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px'); - await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px'); + await expect(page.locator('#workspace-main > .heading')).toHaveCSS('margin-block-end', '22px'); await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px'); - await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px'); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveCSS('line-height', '18px'); await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px'); if (width === 1600) { await expect(page.locator('.sidebar')).toHaveCSS('width', '218px'); @@ -519,10 +519,10 @@ test('Library visual evidence keeps natural cards and returns from real history await noOverflow(page); await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); - await expect(page.locator('.library-history > div > .intro')).toHaveCount(1); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.'); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); - await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.'); expect(errors).toEqual([]); } finally {await saveCoverage(page,browserName);await context.close();} } @@ -618,7 +618,7 @@ test('embedded history gallery keeps the history heading and disposes its layout for (const layout of ['List','Masonry','Grid']) { await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); await expect(page.locator('h1')).toHaveText(title); - await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.'); } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 9bffaee..7d00491 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -43,8 +43,8 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName); test('tags route renders the concept composition without a legacy handoff',async({page})=>{ await page.goto(route); await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories'); - await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.'); - const actions=main(page).locator('.workspace-heading .heading-actions button'); + await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.'); + const actions=main(page).locator(':scope > .heading > .heading-actions button'); await expect(actions).toHaveText(['+ Create tag']); await expect(actions.first()).not.toHaveClass(/ca-primary/); await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0); @@ -195,10 +195,10 @@ test('390px layout stacks without horizontal page scroll; table scrolls locally' expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); const layout=await main(page).evaluate(node=>{ const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap'); - const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation'); + const heading=node.querySelector(':scope > .heading'),operation=node.querySelector('.operation'); return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom, overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection, - actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width}; + actions:node.querySelector(':scope > .heading > .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width}; }); expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true}); }); @@ -672,7 +672,7 @@ async function openProductionTags(page,theme,motion) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1); } -const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production); +const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf); /** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */ async function chromeOf(page,production) { const scope=page.locator(production?'#workspace-main':'#main'); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index c692ac3..117a580 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -560,3 +560,73 @@ test('a superseded count read never lands and a failed refresh removes the stale await expect(page.locator('#primary-nav a')).toHaveCount(4); await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3); }); + +// Task A5: the concept heading() (app.js 303-305) with its typography (style.css 73-103, 283-300; <=700 1408-1416) on +// every native route, and the production footer copy in the concept `.app-footer` (D29). +const headingShape = node => { + const style = element => getComputedStyle(element); + const shape = element => `${element.localName}${element.id ? `#${element.id}` : ''}${[...element.classList].map(name => `.${name}`).join('')}`; + const [copy, actions] = node.children; + const [h1, description] = copy.children; + const main = node.parentElement.getBoundingClientRect(); + return { + children: [...node.children].map(shape), copy: [...copy.children].map(shape), tabIndex: h1.tabIndex, + direction: style(node).flexDirection, marginBottom: style(node).marginBottom, + h1: { fontSize: style(h1).fontSize, lineHeight: Math.round(parseFloat(style(h1).lineHeight) / parseFloat(style(h1).fontSize) * 100) / 100, fontWeight: style(h1).fontWeight, letterSpacing: style(h1).letterSpacing, marginTop: style(h1).marginTop }, + description: { text: description.textContent.length > 0, fontSize: style(description).fontSize, marginTop: style(description).marginTop, muted: style(description).color === style(document.querySelector('.connection small')).color }, + actions: { gap: style(actions).columnGap, left: Math.round(actions.getBoundingClientRect().left), fullWidth: Math.round(actions.getBoundingClientRect().width) === Math.round(node.getBoundingClientRect().width) }, + mainLeft: Math.round(main.left + parseFloat(getComputedStyle(node.parentElement).paddingLeft)), + pageTitles: document.querySelectorAll('#page-title').length, + retired: document.querySelectorAll('.workspace-heading, .workspace-heading-copy, #workspace-main .intro').length, + }; +}; + +test('every route renders the concept heading', async ({ page, request }) => { + const library = await (await request.get('/prompt_manager/library?limit=1')).json(); + const positiveId = library.items[0].id; + const pages = [['overview', 'Overview'], ['images', 'Images'], ['library', 'Prompt library'], ['tags', 'Tags & categories'], [`library/${positiveId}`, null]]; + for (const width of [1600, 390]) { + await page.setViewportSize({ width, height: 900 }); + for (const [hash, title] of pages) { + await page.goto(`${workspace}#/${hash}`); + const heading = page.locator('#workspace-main > .heading'); + await expect(heading).toHaveCount(1); + if (title) await expect(heading.locator('h1')).toHaveText(title); + else await expect(heading.getByRole('button', { name: '← Prompt library', exact: true })).toBeVisible(); + await expect(heading.locator('p.muted')).not.toHaveText(''); + const shape = await heading.evaluate(headingShape); + expect(shape, hash).toMatchObject({ + children: ['div', 'div.heading-actions'], copy: ['h1#page-title', 'p.muted'], tabIndex: -1, marginBottom: '22px', + h1: width === 1600 ? { fontSize: '28px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' } + : { fontSize: '25px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' }, + description: { text: true, fontSize: '12px', marginTop: '6px', muted: true }, + actions: { gap: '8px' }, pageTitles: 1, retired: 0, + }); + if (width === 1600) { + expect(shape.direction, hash).toBe('row'); + } else { + // The concept stacks the heading and gives the actions the full row, starting at main's 14px gutter. + expect(shape.direction, hash).toBe('column'); + expect(shape.actions, hash).toMatchObject({ left: 14, fullWidth: true }); + expect(shape.mainLeft, hash).toBe(14); + } + } + } +}); + +test('the footer is the concept app footer with production copy', async ({ page }) => { + for (const [width, padding, direction] of [[1600, '22px 30px', 'row'], [390, '20px 14px', 'column']]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(`${workspace}#/overview`); + const footer = page.locator('.workspace > footer.app-footer'); + await expect(footer).toHaveCount(1); + await expect(page.locator('.page-footer')).toHaveCount(0); + expect(await footer.evaluate(node => ({ + children: [...node.children].map(child => child.localName), text: node.textContent, + padding: getComputedStyle(node).padding, fontSize: getComputedStyle(node).fontSize, direction: getComputedStyle(node).flexDirection, + muted: getComputedStyle(node).color === getComputedStyle(document.querySelector('.connection small')).color, + afterMain: node.previousElementSibling.id, + }))).toEqual({ children: ['span'], text: 'ComfyAssets · Local tools. Your library.', padding, fontSize: '10px', direction, muted: true, afterMain: 'workspace-main' }); + } +}); + diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index 92f2775..1f38ff3 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -28,7 +28,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); // The pair send lives in the history heading, as in the approved concept. - await workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click(); + await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click(); const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1}); await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1}); @@ -60,7 +60,7 @@ test('native editor preview sends exact saved roles across renderer transitions' try { // Created through the native editor entry action, with an explicitly empty negative. await workspace.goto('/prompt_manager/workspace#/library'); - await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click(); + await workspace.locator('#workspace-main > .heading').getByRole('button',{name:'+ New prompt',exact:true}).click(); const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled(); await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive); await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0); @@ -75,7 +75,7 @@ test('native editor preview sends exact saved roles across renderer transitions' pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))}; }; const before=await evidence();expect(before.pairMetadata).toHaveLength(1); - const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true}); + const send=workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true}); for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) { await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer); await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index c9b5e77..fcdbfb2 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -161,6 +161,12 @@ "functions": 88, "statements": 95 }, + "workspace/heading.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "workspace/history-media.mjs": { "lines": 98, "branches": 86, diff --git a/web/js/workspace/heading.mjs b/web/js/workspace/heading.mjs new file mode 100644 index 0000000..dacba4a --- /dev/null +++ b/web/js/workspace/heading.mjs @@ -0,0 +1,41 @@ +// The concept's page heading (docs/concepts/workspace/app.js heading(), 303-305), shared by every workspace route. + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function partsOf(heading) { + return { + heading, + h1: heading.querySelector(':scope > div > h1'), + description: heading.querySelector(':scope > div > p.muted'), + actions: heading.querySelector(':scope > .heading-actions'), + }; +} + +/** + * Replace `main`'s content with `.heading` > div (`h1#page-title[tabindex=-1]`, `p.muted`) + `.heading-actions`. + * The h1 is the focus target after a save or a route change, as the concept's page title is. + */ +export function renderHeading(main, { title = '', description = '', actions = [] } = {}) { + const heading = element('div', 'heading'); + const copy = element('div'); + const h1 = element('h1', undefined, title); + h1.id = 'page-title'; + h1.tabIndex = -1; + copy.append(h1, element('p', 'muted', description)); + const actionRow = element('div', 'heading-actions'); + actionRow.append(...actions); + heading.append(copy, actionRow); + main.replaceChildren(heading); + return partsOf(heading); +} + +/** The heading the shell rendered into `main`, or null when a view is mounted without one. */ +export function pageHeading(main) { + const heading = main.querySelector(':scope > .heading'); + return heading ? partsOf(heading) : null; +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8e31918..8ce578d 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -8,6 +8,7 @@ import { promptRoles } from './prompt-blocks.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { createMediaViewer } from './media-viewer.mjs'; import { noun } from './plural.mjs'; +import { pageHeading } from './heading.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -49,11 +50,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }); main.classList.add('images-main'); scope.cleanup(() => main.classList.remove('images-main')); - if (!embedded) { - const intro = el('p', 'Browse your images, explore the details, keep your creative context.', 'intro'); - (main.querySelector('.workspace-heading-copy') || main).append(intro); - scope.cleanup(() => intro.remove()); - } + const description = embedded ? null : pageHeading(main)?.description; + if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.'; const filtersBar = el('div', undefined, 'image-filters'); const search = el('input'); search.value = filters.q; diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 95471fd..b725a03 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -7,6 +7,7 @@ import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mj import { mountVocabulary } from './vocabulary.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { bindCanvasLink, canvasRoot } from './topbar.mjs'; +import { renderHeading } from './heading.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -87,14 +88,8 @@ function mountWorkspace() { else link.removeAttribute('aria-current'); } document.title = `${routeTitle(route)} · Prompt Manager`; - const heading = element('h1', route.label); - // Focus target after a save, as the concept's page title. - heading.tabIndex = -1; - const headingCopy = element('div', undefined, 'workspace-heading-copy'); - headingCopy.append(heading); - const headingRegion = element('header', undefined, 'workspace-heading'); - headingRegion.append(headingCopy); - main.replaceChildren(headingRegion); + // The concept's heading(); each view fills its description and actions. + const { description } = renderHeading(main, { title: route.label }); if (!firstRender) main.focus(); firstRender = false; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); @@ -112,7 +107,7 @@ function mountWorkspace() { return; } if (isHandoff(route)) { - headingCopy.append(element('p', route.description, 'intro')); + description.textContent = route.description; const panel = element('section', undefined, 'surface handoff'); panel.append(element('h2', 'Your current tools are ready'), element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'), @@ -120,7 +115,7 @@ function mountWorkspace() { main.append(panel); return; } - headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro')); + description.textContent = 'A home for your prompts, images and creative history.'; const stats = element('section', undefined, 'overview-stats'); stats.setAttribute('aria-label', 'Library statistics'); main.append(stats); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 4b7b482..a1b71bc 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -123,16 +123,11 @@ main.library-main { --library-negative: #ff8c98; } .history-run-marker[aria-pressed="true"] { outline-color: Highlight; } } -/* Heading and panel actions follow the concept (style.css 283-300, 540-549, 1408-1414). */ -.workspace-heading .heading-actions, .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } -.workspace-heading .heading-actions { min-inline-size: 0; max-inline-size: 100%; } -.ca-tools .workspace-heading button.send { color: var(--ca-focus); border-color: var(--ca-line); display: inline-flex; align-items: center; gap: 6px; } -.workspace-heading h1:focus { outline: none; } -.workspace-heading h1:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } +/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5). */ +.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } .library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; } -@media (max-width: 700px) { .workspace-heading .heading-actions { inline-size: 100%; } } -@media (pointer: coarse) { .ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 40px; } } +@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } } /* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */ .library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; } .ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index df2d0e7..389df2c 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -10,6 +10,7 @@ import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; +import {pageHeading} from './heading.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -31,8 +32,15 @@ function title(text) {const first=text.split('\n')[0].trim();return first.length /** The full stored title; the first-line fallback only when it is blank. */ function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);} function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();} +/** The shell heading's action row (concept `.heading-actions`); a view mounted without one gets its own row. */ function headingActions(main,fallback) { - const actions=el('div',undefined,'heading-actions');(main.querySelector('.workspace-heading') || fallback).append(actions);return actions; + const actions=pageHeading(main)?.actions;if(actions) return actions; + const row=el('div',undefined,'heading-actions');fallback.append(row);return row; +} +/** The shell heading's description (concept `p.muted`); a view mounted without one shows it at the top of `fallback`. */ +function headingDescription(main,fallback,text) { + const description=pageHeading(main)?.description; + if(description) description.textContent=text;else fallback.prepend(el('p',text,'muted')); } /** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */ @@ -129,7 +137,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const restorable=visit?.state?visit:null; const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); - const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro'); const toolbar=el('search',undefined,'library-toolbar'); const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500; const category=createSelect([['','All categories']], '');category.value=filters.category; @@ -169,7 +176,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } finally {exporting=false;} } scope.listen(exportButton,'click',exportLibrary); - (main.querySelector('.workspace-heading-copy') || section).append(introduction); + headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.'); continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; @@ -363,6 +370,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history); const body=el('div');history.append(body); // Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI. + headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.'); const actions=headingActions(main,history); const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send'); send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send); @@ -405,7 +413,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return; } detail=state.detail;const content=contentScope; - const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro'); const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip); const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer); const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status'); @@ -506,7 +513,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context); const images=el('div',undefined,'library-linked-images');gallery.append(images); const recorded=el('div',undefined,'library-recorded-container'); - body.append(subtitle,summary,variants,recorded,gallery); + body.append(summary,variants,recorded,gallery); const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null); const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true); function rolesFor(pairId) { diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs index ccdcc8e..6fb98ea 100644 --- a/web/js/workspace/vocabulary.mjs +++ b/web/js/workspace/vocabulary.mjs @@ -12,6 +12,7 @@ import { browseFilters, createVocabularyCommand, createVocabularyStream } from ' import { countOf } from './plural.mjs'; import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs'; import { createVocabularyDialog } from './vocabulary-dialog.mjs'; +import { pageHeading } from './heading.mjs'; const COPY = { tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'}, @@ -155,12 +156,10 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) { export function mountVocabulary(main, api, scope) { main.classList.add('vocabulary-main'); scope.cleanup(() => main.classList.remove('vocabulary-main')); - const heading = main.querySelector('.workspace-heading'); - heading.querySelector('.workspace-heading-copy').append(el('p', 'Organize once. Reuse everywhere.', 'intro')); - const actions = el('div', undefined, 'heading-actions'); + const { description, actions } = pageHeading(main); + description.textContent = 'Organize once. Reuse everywhere.'; const create = createButton('+ Create tag'); actions.append(create); - heading.append(actions); const grid = el('div', undefined, 'two-col'); const tagSurface = el('section', undefined, 'surface'); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 1f70c6a..b8f59c4 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -3,6 +3,9 @@ /* Production-only shell states. */ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } +/* The page title takes focus after a save or route change (concept app.js 1168); a keyboard user sees where it went. */ +#page-title:focus { outline: none; } +#page-title:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* D28: the sidebar shows no connection state, so its dot is neutral instead of the concept's demo green. */ .connection i { background: var(--ca-muted); } /* D31: bounded rendering reads the grid's real width, so at extreme zoom the one-column shell must not grow to the @@ -51,18 +54,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; } } -/* Interim shell: production markup the concept classes replace in Task A5 (heading, footer). These selectors match - nothing in the concept; each block is deleted by the task named. */ -.workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 22px; } /* A5 */ -.workspace-heading-copy { min-width: 0; } /* A5 */ -.workspace-heading-copy .intro { font-size: 12px; color: var(--ca-muted); margin-top: 6px; } /* A5 */ -.intro { color: var(--ca-muted); } /* A5 */ -.page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--line); color: var(--ca-muted); font-size: 11px; } /* A5 */ -@media (max-width: 700px) { - .workspace-heading { align-items: flex-start; flex-direction: column; } /* A5 */ - .page-footer { margin: 0 14px; flex-wrap: wrap; } /* A5 */ -} - /* 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. */ .panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */ diff --git a/web/workspace.html b/web/workspace.html index 949481b..c136436 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -40,7 +40,7 @@ ComfyUI
- +