refactor(workspace): one concept heading and footer for every route
This commit is contained in:
@@ -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')];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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` */
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
</select></label><a class="canvas-link" href="../">ComfyUI <span aria-hidden="true">↗</span></a></div>
|
||||
</header>
|
||||
<main id="workspace-main" tabindex="-1"></main>
|
||||
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
|
||||
<footer class="app-footer"><span>ComfyAssets · Local tools. Your library.</span></footer>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
Reference in New Issue
Block a user