From 01817d9712203016e5f338c63b9dfc939de35dac Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 15:19:11 -0700 Subject: [PATCH] feat(workspace): concept sidebar with real navigation counts --- tests/browser/full-inspector.spec.mjs | 3 +- tests/browser/workspace-images.spec.mjs | 20 +- tests/browser/workspace-library.spec.mjs | 8 +- tests/browser/workspace-visual.spec.mjs | 14 +- tests/browser/workspace-vocabulary.spec.mjs | 15 +- tests/browser/workspace.spec.mjs | 218 ++++++++++++++++++-- tests/comfyui/workspace.spec.mjs | 2 +- tests/js/workspace-state.test.js | 36 ++++ web/js/workspace/icons.mjs | 10 +- web/js/workspace/images.css | 6 +- web/js/workspace/index.mjs | 55 +++-- web/js/workspace/inspector.css | 2 +- web/js/workspace/library.mjs | 7 + web/js/workspace/state.mjs | 22 ++ web/js/workspace/workspace.css | 30 +-- web/workspace.html | 14 +- 16 files changed, 362 insertions(+), 100 deletions(-) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 421e291c..fdce9d51 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -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'); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 520c4384..723eed0f 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -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(); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 63124938..85674cb8 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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();} }); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 8243a422..564e1647 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -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'); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 7d91eb97..0e69d019 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -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) { diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 69ecc886..cc8cf973 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -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); +}); diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs index ad561f2f..1d3e06eb 100644 --- a/tests/comfyui/workspace.spec.mjs +++ b/tests/comfyui/workspace.spec.mjs @@ -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); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 6ebacbf7..1592b2cd 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -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)); +}); diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs index 28626061..88cf39fe 100644 --- a/web/js/workspace/icons.mjs +++ b/web/js/workspace/icons.mjs @@ -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); diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 7c8d45ad..3cff41ba 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -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; } diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 55325428..9b5dcf68 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -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'); } diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css index c360a7ac..710d14b3 100644 --- a/web/js/workspace/inspector.css +++ b/web/js/workspace/inspector.css @@ -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) { diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index b114bb6e..df2d0e77 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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 diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index c3cbf3da..8963730f 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -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 = () => { diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index f3dd9da5..491d9f2e 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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 */ } diff --git a/web/workspace.html b/web/workspace.html index d24da00f..5147dc2c 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -18,14 +18,18 @@ - -
+ +
-
+
ComfyAssets / Images