feat(workspace): concept sidebar with real navigation counts

This commit is contained in:
Vito Sansevero
2026-10-19 22:48:38 -07:00
parent 9ec23f8615
commit 01817d9712
16 changed files with 362 additions and 100 deletions
+2 -1
View File
@@ -295,7 +295,8 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
// A grid page, not the sidebar's `limit=1` Images count.
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1});
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
+11 -9
View File
@@ -3,6 +3,8 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
test.beforeEach(async ({ page, request, browserName }) => {
@@ -54,7 +56,7 @@ test('workspace defaults to the native Images collection without a hash route',
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page');
});
test('image selection is announced on the selected image button', async ({ page }) => {
@@ -83,7 +85,7 @@ test('closing details preview restores its trigger focus', async ({ page }) => {
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
let reads = 0;
await page.route('**/prompt_manager/images/output?*', async route => {
await page.route(imagePages, async route => {
const current = reads++;
await route.fulfill({ json: {
success: true, images: [real.images[current]], total: 2,
@@ -435,7 +437,7 @@ test('collection viewer describes video transforms and releases native playback
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
await page.route(imagePages, route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
}), { times: 1 });
await page.goto(workspace);
@@ -446,7 +448,7 @@ test('failed image requests offer retry while stale results cannot replace a new
const pending = new Promise(resolve => { release = resolve; });
let arrived;
const received = new Promise(resolve => { arrived = resolve; });
await page.route('**/prompt_manager/images/output?*', async route => {
await page.route(imagePages, async route => {
const response = await route.fetch();
arrived();
await pending;
@@ -454,7 +456,7 @@ test('failed image requests offer retry while stale results cannot replace a new
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
release();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
@@ -492,7 +494,7 @@ async function staleSourceRecovery(page, request, roots) {
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
let oldPage;
await page.route('**/prompt_manager/images/output?*', async route => {
await page.route(imagePages, async route => {
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
const response = await route.fetch();oldPage = { route, data: await response.json() };
} else await route.continue();
@@ -557,7 +559,7 @@ test('source-change recovery ignores superseded discovery and route disposal', a
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
@@ -588,7 +590,7 @@ test('a delayed search cannot replace results for a newer filename query', async
const received = new Promise(resolve => { arrived = resolve; });
const completed = new Promise(resolve => { complete = resolve; });
let delayed = false;
await page.route('**/prompt_manager/images/output?*', async route => {
await page.route(imagePages, async route => {
if (delayed) return route.continue();
delayed = true;
const response = await route.fetch();
@@ -672,7 +674,7 @@ test('leaving during Refresh cancels it without replacing the next workspace', a
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await requested;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
release();
+4 -4
View File
@@ -129,7 +129,7 @@ test('returning from history restores library context and mobile content stays i
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
@@ -140,9 +140,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
const position=await page.evaluate(()=>scrollY);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click());
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
await expect(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
@@ -203,7 +203,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
});
+7 -7
View File
@@ -8,14 +8,14 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
const primary = ['images', 'library', 'tags', 'inspect'];
const tools = ['overview', 'system'];
const tools = ['system', 'overview'];
async function noOverflow(page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
async function groupedNavigation(page) {
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
const region = page.locator(`.workspace-nav-${group}`);
for (const [group, routes] of [['primary', primary], ['system', tools]]) {
const region = page.locator(`#${group}-nav`);
await expect(region).toHaveCount(1);
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
}
@@ -216,7 +216,7 @@ test('shell matches approved stable geometry in both themes', async ({ page, req
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
} else {
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
expect(await page.locator('.app-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
}
await noOverflow(page);
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
@@ -533,11 +533,11 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
await page.goto(`${workspace}#/images`);
const icons = await page.evaluate(async () => {
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
const otherDocument = document.implementation.createHTMLDocument();
const rendered = names.map(name => {
const icon = createWorkspaceIcon(name, otherDocument);
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
return { name, svg: icon instanceof SVGSVGElement, className: icon.getAttribute('class'), owner: icon.ownerDocument === otherDocument,
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
@@ -549,7 +549,7 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
});
expect(icons.unknown).toEqual([true, true, true, true, true]);
for (const icon of icons.rendered) {
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon.path.length).toBeGreaterThan(10);
}
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
+9 -6
View File
@@ -89,7 +89,7 @@ test('category Browse sets only the category constraint',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true}).click();
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{category:'Landscapes',count:3});
@@ -127,7 +127,8 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
let failures=0;
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
// Tag list pages only: the sidebar's Tags count is its own `limit=1` read.
await page.route(url=>url.pathname==='/prompt_manager/vocabulary/tags' && url.searchParams.get('limit')!=='1',route=>{
if(failures++) return route.continue();
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
});
@@ -149,7 +150,7 @@ test('breadcrumb and document title are Tags',async({page})=>{
await page.goto(route);
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
await expect(page).toHaveTitle('Tags · Prompt Manager');
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/})).toHaveAttribute('aria-current','page');
await page.reload();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
await page.goto('/prompt_manager/workspace#/library');
@@ -161,7 +162,8 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await page.addInitScript(()=>{
window.__vocabularySignals=[];window.__vocabularyObservers=[];
const fetch=window.fetch;
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
// The page's own reads; the sidebar's `limit=1` Tags count belongs to the shell.
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && !/[?&]limit=1(?:&|$)/.test(String(input)) && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
const Observer=window.IntersectionObserver;
window.IntersectionObserver=class extends Observer {
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
@@ -175,7 +177,7 @@ test('route disposal aborts requests and removes observers',async({page})=>{
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
@@ -482,7 +484,8 @@ test('lost response is outcome-unknown',async({page,request})=>{
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
});
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
// Concept nav links end in their real `.nav-count` once it loads.
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
async function heldRename(page) {
+197 -21
View File
@@ -4,6 +4,11 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
const workspace = '/prompt_manager/workspace';
const statistic = (page, label) => page.locator('#workspace-main dt')
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
// Concept sidebar: WORKSPACE links carry a real `.nav-count` once loaded, so their accessible name may end in it.
const escapeName = label => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const navName = label => new RegExp(`^${escapeName(label)}(?:\\s*[\\d,]+)?$`);
const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: navName(label) });
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
['images', 'Images', '/prompt_manager/gallery'],
@@ -66,8 +71,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.goto(`${workspace}#/${route}`);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
@@ -81,11 +85,11 @@ test('all shell destinations deep-link, reload and expose working existing appli
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
const images = navigation.getByRole('link', { name: 'Images', exact: true });
const images = navigation.getByRole('link', { name: navName('Images') });
await images.focus();
await page.keyboard.press('Enter');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
await navigation.getByRole('link', { name: navName('Prompt library') }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
@@ -93,7 +97,7 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goto(`${workspace}#/not-a-page`);
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
await expect(navigation.getByRole('link', { name: navName('Images') }))
.toHaveAttribute('aria-current', 'page');
});
@@ -101,7 +105,7 @@ test('skip to content focuses the current view without changing its route', asyn
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
await expect(page.getByRole('link', { name: 'Skip to workspace', exact: true })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(`${workspace}#/library`);
await expect(page.locator('#workspace-main')).toBeFocused();
@@ -113,12 +117,12 @@ test('returning to overview refreshes persisted counts without duplicating navig
const prompts = statistic(page, 'Prompts');
await expect(prompts).toHaveText('2');
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await sidebarLink(page, 'Images').click();
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
await sidebarLink(page, 'Overview').click();
await expect(prompts).toHaveText('3');
await expect(navigation).toHaveCount(1);
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await expect(sidebarLinks(page)).toHaveCount(routes.length);
});
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
@@ -173,9 +177,8 @@ test('overview reports loading, offers retry after failure and renders real empt
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
for (const [, label] of routes) {
await navigation.getByRole('link', { name: label, exact: true }).click();
await sidebarLink(page, label).click();
await expect(page.locator('#workspace-main h1')).toHaveText(label);
}
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
@@ -196,15 +199,14 @@ test('leaving a loading overview prevents its delayed result from replacing the
});
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await sidebarLink(page, 'Images').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
release();
await finished;
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(page.locator('#workspace-main')).not.toContainText('999');
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(page.locator('#primary-nav')).toHaveCount(1);
await expect(page.locator('#system-nav')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
await countAppearanceWrites(page);
@@ -227,13 +229,12 @@ test('cached-page restoration remounts one working shell and refreshes its data'
await expect(statistic(page, 'Prompts')).toHaveText('2');
}
expect(reads).toBe(3);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(sidebarLinks(page)).toHaveCount(routes.length);
await expect(page.locator('#primary-nav')).toHaveCount(1);
await expect(page.locator('#system-nav')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await countAppearanceWrites(page);
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await sidebarLink(page, 'Images').click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
@@ -276,7 +277,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
expect(await card.locator('p.small').evaluate(node => getComputedStyle(node).fontSize)).toBe('12px');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'System', exact: true }).click();
await sidebarLink(page, 'System').click();
const handoff = main.locator('section.handoff');
await expect(handoff).toHaveClass(/\bsurface\b/);
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
@@ -284,3 +285,178 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
await expect(main.locator('.panel')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
const NAV_TOTALS = [
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['library', 'Prompt library', '/prompt_manager/library?limit=1'],
['tags', 'Tags & categories', '/prompt_manager/vocabulary/tags?limit=1'],
];
async function serverTotals(request) {
const totals = {};
for (const [id, , url] of NAV_TOTALS) {
const response = await request.get(url);
await expect(response).toBeOK();
totals[id] = (await response.json()).total;
expect(Number.isSafeInteger(totals[id]) && totals[id] >= 0).toBe(true);
}
return totals;
}
const navCount = (page, route) => page.locator(`#primary-nav a[data-route="${route}"] .nav-count`);
const childShape = locator => locator.evaluate(node => [...node.children]
.map(child => `${child.localName}${child.id ? `#${child.id}` : ''}${[...child.classList].map(name => `.${name}`).join('')}`));
test('sidebar matches the concept groups and shows real counts', async ({ page, request }) => {
const totals = await serverTotals(request);
expect(totals.library).toBe(2);
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
await expect(page.locator('body > .app-shell > aside.sidebar')).toHaveCount(1);
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
const links = await primary.evaluate(node => [...node.children].map(link => ({ tag: link.localName,
href: link.getAttribute('href'), icon: link.querySelector('svg')?.getAttribute('class'),
nodes: [...link.childNodes].map(child => child.nodeType === Node.TEXT_NODE ? `text:${child.textContent}`
: `${child.localName}${[...child.classList].map(name => `.${name}`).join('')}`) })));
expect(links).toEqual([
{ tag: 'a', href: '#/images', nodes: ['svg.icon', 'text:Images', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/library', nodes: ['svg.icon', 'text:Prompt library', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/tags', nodes: ['svg.icon', 'text:Tags & categories', 'span.nav-count'], icon: 'icon' },
{ tag: 'a', href: '#/inspect', nodes: ['svg.icon', 'text:Inspect a file'], icon: 'icon' },
]);
await expect(primary.locator('a[href="#/images"]')).toHaveAttribute('aria-current', 'page');
await expect(primary.locator('a[aria-current]')).toHaveCount(1);
await expect(page.locator('#primary-nav a[data-route="inspect"] .nav-count')).toHaveCount(0);
// Concept tokens: 38px rows, 12px muted labels, 10px muted counts pushed to the end.
const style = await primary.locator('a[href="#/library"]').evaluate(link => {
const own = getComputedStyle(link), count = link.querySelector('.nav-count');
return { minHeight: own.minHeight, fontSize: own.fontSize, gap: own.columnGap, count: getComputedStyle(count).fontSize,
end: Math.round(link.getBoundingClientRect().right - count.getBoundingClientRect().right) };
});
expect(style).toEqual({ minHeight: '38px', fontSize: '12px', gap: '11px', count: '10px', end: 12 });
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
test('nav counts stay hidden until loaded and when their request fails', async ({ page, request }) => {
const totals = await serverTotals(request);
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/library?limit=1', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.route('**/prompt_manager/vocabulary/tags?limit=1', route => route.fulfill({ json: { success: true, total: -4 } }));
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// Nothing renders while a read is outstanding: no "0" placeholder.
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(0);
release();
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
await expect(navCount(page, 'library')).toHaveCount(0);
await expect(navCount(page, 'tags')).toHaveCount(0);
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(1);
});
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
await page.goto(`${workspace}#/system`);
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
const system = page.locator('nav#system-nav');
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
['#/system', 'System'], ['#/overview', 'Overview'],
]);
await expect(system.locator('.nav-count')).toHaveCount(0);
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
await expect(system.locator('svg.icon')).toHaveCount(2);
const connection = bottom.locator('.connection');
expect(await childShape(connection)).toEqual(['i', 'div']);
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
await expect(connection.locator('small')).toHaveText('Your library, on your server');
// D28: no fake connection state. The dot is the muted text colour, not the concept's demo green.
const dot = await connection.evaluate(node => ({ dot: getComputedStyle(node.querySelector('i')).backgroundColor,
muted: getComputedStyle(node.querySelector('small')).color }));
expect(dot.dot).toBe(dot.muted);
await page.locator('nav#system-nav').getByRole('link', { name: 'Overview', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(system.locator('a[href="#/overview"]')).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar a[aria-current]')).toHaveCount(1);
});
test('counts refresh after a confirmed editor save', async ({ page, request }) => {
const before = await serverTotals(request);
await page.goto(`${workspace}#/library`);
await expect(navCount(page, 'library')).toHaveText(String(before.library));
await expect(navCount(page, 'tags')).toHaveText(String(before.tags));
const reads = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.searchParams.get('limit') === '1') reads.push(url.pathname);
});
await page.getByRole('button', { name: '+ New prompt', exact: true }).click();
const dialog = page.locator('dialog.library-editor');
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
await dialog.getByLabel('Title', { exact: true }).fill('Harbor at dawn');
await dialog.getByLabel('Positive prompt', { exact: true }).fill('A quiet harbor at dawn for the sidebar count');
await dialog.getByLabel('Tags, separated by commas', { exact: true }).fill('sidebar_count_tag');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toHaveCount(0);
const after = await serverTotals(request);
expect(after.library).toBe(before.library + 1);
expect(after.tags).toBeGreaterThan(before.tags);
await expect(navCount(page, 'library')).toHaveText(String(after.library));
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
});
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
const totals = await serverTotals(request);
await page.goto(`${workspace}#/overview`);
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
const held = [];
let reads = 0;
await page.route('**/prompt_manager/library?limit=1', route => {
if (reads++ === 0) { held.push(route); return undefined; }
return route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
const invalidate = () => page.evaluate(async () => {
const { invalidateLibraryAfterWrite } = await import('/prompt_manager/js/workspace/library.mjs');
invalidateLibraryAfterWrite({ kind: 'test', receipt: {} });
});
await invalidate();
await expect.poll(() => held.length).toBe(1);
await invalidate();
await expect(navCount(page, 'library')).toHaveCount(0);
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
await held[0].fulfill({ json: { success: true, total: 999 } }).catch(() => {});
await page.waitForTimeout(100);
await expect(navCount(page, 'library')).toHaveCount(0);
await page.unroute('**/prompt_manager/library?limit=1');
// Leaving the page with a read in flight aborts it; the restored shell reads the counts again.
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/library?limit=1', async route => { await pending; await route.continue().catch(() => {}); }, { times: 1 });
await invalidate();
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});
release();
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
await expect(page.locator('#primary-nav a')).toHaveCount(4);
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
});
+1 -1
View File
@@ -24,7 +24,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
});
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
const images = await imageResponse;
expect(images.ok()).toBe(true);
+36
View File
@@ -76,3 +76,39 @@ test('positive library deep links preserve the selected identity without duplica
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
});
test('nav counts reject negative, non-integer and missing totals', async () => {
const { summarizeNavCounts } = await source;
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
});
test('nav counts issue three bounded requests and drop only the failed count', async () => {
const { loadNavCounts } = await source;
const calls = [];
const signal = new AbortController().signal;
const api = {get: async (url, options) => {
calls.push([url, options.signal]);
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
const urls = calls.map(([url]) => new URL(url, 'http://host'));
assert.deepEqual(urls.map(url => url.pathname),
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.ok(calls.every(([, used]) => used === signal));
});
+8 -2
View File
@@ -13,7 +13,13 @@ const paths = Object.freeze({
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
"send": "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13",
"plus": "M12 4v16M4 12h16",
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0",
// The concept's remaining vocabulary (docs/concepts/workspace/app.js `paths`), for the pages rebuilt next.
"star": "m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z",
"tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5",
"folder": "M3 5h7l2 3h9v12H3z",
"history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2",
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5"
});
/** Create an approved decorative outline icon without injecting markup. */
@@ -24,7 +30,7 @@ export function createWorkspaceIcon(name, doc = document) {
const namespace = 'http://www.w3.org/2000/svg';
const icon = doc.createElementNS(namespace, 'svg');
for (const [attribute, value] of Object.entries({
class: 'workspace-icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
class: 'icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
focusable: 'false', fill: 'none', stroke: 'currentColor',
'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
})) icon.setAttribute(attribute, value);
+3 -3
View File
@@ -14,7 +14,7 @@
.image-toolbar p { margin: 0; font-size: 11px; }
.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
.image-layouts .workspace-icon { inline-size: 14px; block-size: 14px; }
.image-layouts .icon { inline-size: 14px; block-size: 14px; }
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
.images-main { container: images-page / inline-size; }
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
@@ -29,7 +29,7 @@
.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
.image-card-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
.image-card-footer .icon { inline-size: 14px; block-size: 14px; }
.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
@@ -113,7 +113,7 @@
.prompt-actions { display: flex; flex-wrap: wrap; gap: 4px; }
.ca-tools .prompt-actions button { display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
.prompt-actions .prompt-send-button span { display: none; }
.prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; }
.prompt-actions .icon { inline-size: 14px; block-size: 14px; }
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
.prompt-copy-status:empty { display: none; }
.image-preview-details .prompt-inspector { margin-block-start: 0; }
+36 -19
View File
@@ -1,9 +1,9 @@
import '../api-client.js';
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff } from './state.mjs';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary, mountNewPromptAction } from './library.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
@@ -26,7 +26,8 @@ function mountWorkspace() {
const scope = createScope();
const api = createApiClient();
const main = document.getElementById('workspace-main');
const nav = document.querySelector('nav[aria-label="Workspace"]');
const primaryNav = document.getElementById('primary-nav');
const systemNav = document.getElementById('system-nav');
const appearance = document.getElementById('appearance-control');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
@@ -39,21 +40,37 @@ function mountWorkspace() {
appearanceField.classList.add('workspace-appearance');
appearance.replaceChildren(appearanceField);
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' };
const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]];
nav.replaceChildren(...groups.map(([name, label, ids]) => {
const group = element('div', undefined, `workspace-nav-${name}`);
group.append(element('p', label, 'sidebar-label'));
for (const id of ids) {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icons[id]), element('span', route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
group.append(link);
}
return group;
}));
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
const navLinks = ids => ids.map(([id, icon]) => {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
});
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
const sidebar = document.querySelector('aside.sidebar');
let countsRequest;
function refreshCounts() {
countsRequest?.abort();
const request = countsRequest = new AbortController();
loadNavCounts(api, request.signal).then(counts => {
if (request.signal.aborted) return;
countsRequest = null;
// A count that failed to refresh is removed, never left stale.
for (const link of primaryNav.querySelectorAll('[data-route]')) {
link.querySelector('.nav-count')?.remove();
const count = counts.get(link.dataset.route);
if (Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
}
});
}
scope.cleanup(() => countsRequest?.abort());
scope.cleanup(onLibraryWrite(refreshCounts));
refreshCounts();
let viewScope;
let firstRender = true;
scope.cleanup(() => viewScope?.dispose());
@@ -62,7 +79,7 @@ function mountWorkspace() {
viewScope?.dispose();
viewScope = createScope();
const view = viewScope;
for (const link of nav.querySelectorAll('[data-route]')) {
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
+1 -1
View File
@@ -38,7 +38,7 @@
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
.file-inspector-footer .icon { inline-size: 14px; block-size: 14px; }
.file-status { padding: 17px; }
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
@media (pointer: coarse) {
+7
View File
@@ -36,10 +36,17 @@ function headingActions(main,fallback) {
}
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
const writeListeners=new Set();
export function invalidateLibraryAfterWrite(commit) {
if(!commit || typeof commit.kind!=='string' || !commit.receipt || typeof commit.receipt!=='object') throw new TypeError('Invalid editor commit');
writeGeneration++;
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
for(const listener of [...writeListeners]) listener(commit);
}
/** Observe confirmed Library and vocabulary writes (the sidebar re-reads its counts); returns the unsubscribe. */
export function onLibraryWrite(listener) {
if(typeof listener!=='function') throw new TypeError('Invalid Library write listener');
writeListeners.add(listener);return ()=>{writeListeners.delete(listener);};
}
/**
* Open the Library shelf with exactly these filters (Tags & categories Browse): the
+22
View File
@@ -42,6 +42,28 @@ export function summarizeStats(stats) {
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
const NAV_COUNT_SOURCES = Object.freeze([
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['library', '/prompt_manager/library?limit=1'],
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
]);
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
export function summarizeNavCounts(pages = {}) {
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
const total = pages?.[id]?.total;
return [id, Number.isSafeInteger(total) && total >= 0 ? total : null];
}));
}
/** Read the three counts; a failed read yields null for its route only. */
export async function loadNavCounts(api, signal) {
const results = await Promise.allSettled(NAV_COUNT_SOURCES.map(([, url]) => api.get(url, { signal })));
return summarizeNavCounts(Object.fromEntries(NAV_COUNT_SOURCES.map(([id], index) =>
[id, results[index].status === 'fulfilled' ? results[index].value : undefined])));
}
export function mountRouter(win, onRoute) {
let current;
const update = () => {
+9 -21
View File
@@ -3,6 +3,13 @@
/* Production-only shell states. */
main: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); }
/* D3: bounded rendering reads the grid's real width, so at extreme zoom the one-column shell must not grow to the
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
@media (max-width: 950px) {
.app-shell { grid-template-columns: minmax(0, 1fr); /* D3 */ }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
}
@@ -44,17 +51,8 @@ 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 A3 (sidebar), A4 (topbar) and A5 (heading,
footer). These selectors match nothing in the concept; each block is deleted by the task named. */
.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: 100dvh; } /* A3 */
.workspace-body { min-width: 0; display: flex; flex-direction: column; } /* A3 */
.workspace-body > main { flex: 1; width: 100%; min-width: 0; } /* A3 */
nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; gap: 5px; } /* A3 */
.workspace-nav-primary, .workspace-nav-tools { display: grid; gap: 3px; } /* A3 */
.workspace-nav-tools { margin-top: auto; padding-top: 32px; } /* A3 */
.workspace-icon { display: block; width: 17px; height: 17px; flex: 0 0 auto; } /* A3: becomes the concept `.icon` */
.sidebar-footer { margin: 17px 6px 0; padding-top: 17px; border-top: 1px solid var(--line); color: var(--ca-muted); font-size: 10px; display: flex; align-items: center; gap: 8px; } /* A3 */
.connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; } /* A3 */
/* Interim shell: production markup the concept classes replace in Task A4 (topbar) and A5 (heading, footer). These
selectors match nothing in the concept; each block is deleted by the task named. */
.topbar > span { font-size: 12px; } /* A4 */
#appearance-control { min-width: 0; max-width: 100%; } /* A4 */
.ca-tools .workspace-appearance { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; min-width: 0; max-width: 100%; font-size: 11px; } /* A4 */
@@ -64,19 +62,9 @@ nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; ga
.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: 1250px) {
.workspace-shell { grid-template-columns: 185px minmax(0, 1fr); } /* A3 */
}
@media (max-width: 950px) {
.workspace-shell { grid-template-columns: minmax(0, 1fr); } /* A3 */
.sidebar-footer { display: none; } /* A3 */
.workspace-nav-primary, .workspace-nav-tools { display: flex; flex-wrap: wrap; gap: 4px; } /* A3 */
.workspace-nav-tools { margin: 0; padding-top: 5px; border-top: 1px solid var(--line); } /* A3 */
}
@media (max-width: 700px) {
.topbar > span { overflow-wrap: anywhere; } /* A4 */
.workspace-heading { align-items: flex-start; flex-direction: column; } /* A5 */
.sidebar nav .workspace-icon { width: 14px; height: 14px; } /* A3 */
.page-footer { margin: 0 14px; flex-wrap: wrap; } /* A5 */
}
+9 -5
View File
@@ -18,14 +18,18 @@
<script type="module" src="js/workspace/index.mjs"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#workspace-main">Skip to content</a>
<div class="workspace-shell">
<a class="skip" href="#workspace-main">Skip to workspace</a>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
<nav aria-label="Workspace"></nav>
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
<div class="sidebar-label">WORKSPACE</div>
<nav id="primary-nav" aria-label="Workspace"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
</div>
</aside>
<div class="workspace-body">
<div class="workspace">
<header class="topbar"><span>ComfyAssets <span class="muted">/ <span id="workspace-breadcrumb">Images</span></span></span><div id="appearance-control"></div></header>
<main id="workspace-main" tabindex="-1"></main>
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>