diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 3107e9a..0d920b3 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -9,8 +9,9 @@ code. This plan proposes implementation order and acceptance gates. - Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198). - [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available. - Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206). -- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) now records new job outcomes; prompt contexts, output associations and the execution timeline remain pending under #209. -- Prompt execution contexts and timeline, tagging/review and System consolidation remain subsequent slices under #196; #209 now records durable job-level outcomes. +- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. +- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). +- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. Full inspector/viewer parity remains pending under [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216); this presentation slice does not establish whole-application parity. Tagging/review, System consolidation and the remaining organization capabilities continue under #196. ## Scope and compatibility diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 8d072b6..49cb015 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -190,7 +190,7 @@ test('responsive gallery defers observed size writes and still fits the new view await expect(page.locator('.image-card')).toHaveCount(4); await page.setViewportSize({width:390,height:844}); await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390); - await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeGreaterThan(900); + await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0); expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0); }); @@ -203,3 +203,22 @@ test('unverified saved associations explain uncertainty and cannot be sent',asyn await expect(role(details(page),'Positive')).toContainText('cannot be verified against this file'); await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0); }); + + +test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{ + await page.goto(workspace);await select(page,'0:shared/same.png'); + for (const name of ['Positive','Negative']) { + const block=role(details(page),name),heading=block.locator('.prompt-block-heading'); + await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible(); + await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible(); + await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible(); + await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1); + expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true); + } + await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow'); + await select(page,'0:empty-negative.png'); + await expect(role(details(page),'Negative')).toContainText('Empty negative prompt'); + await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1); + await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0); + await expect(details(page)).toContainText('Not recorded in this file.'); +}); diff --git a/tests/browser/server.py b/tests/browser/server.py index dc1e783..b2575af 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -14,6 +14,8 @@ from aiohttp import web from PIL import Image from PIL.PngImagePlugin import PngInfo +from tests.browser.workspace_visual_fixture import seed_workspace_visual + class TestModel: default_prompt = "Describe the image" @@ -223,6 +225,7 @@ def create_app(root: Path) -> web.Application: @routes.post("/__test__/reset") async def reset(request: web.Request) -> web.Response: data = await request.json() if request.can_read_body else {} + workspace_visual = data.get("workspace_visual", False) extra = data.get("extra_images", 0) include_video = data.get("include_video", False) prompt_inspector = data.get("prompt_inspector", False) @@ -230,7 +233,22 @@ def create_app(root: Path) -> web.Application: extra_prompts = data.get("extra_prompts", 0) extra_variants = data.get("extra_variants", 0) if ( - isinstance(extra, bool) + not isinstance(workspace_visual, bool) + or ( + workspace_visual + and any( + key in data + for key in ( + "extra_images", + "include_video", + "prompt_inspector", + "native_library", + "extra_prompts", + "extra_variants", + ) + ) + ) + or isinstance(extra, bool) or not isinstance(extra, int) or not 0 <= extra <= 200 or not isinstance(include_video, bool) @@ -244,6 +262,9 @@ def create_app(root: Path) -> web.Application: or not 0 <= extra_variants <= 200 ): raise web.HTTPBadRequest() + if workspace_visual: + await api._run_in_executor(seed_workspace_visual, api, root) + return web.json_response({"success": True}) await api._run_in_executor( seed, extra, diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 9cd9931..219170e 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -17,7 +17,13 @@ test.afterEach(async ({ page, browserName }) => { expect(page.imageErrors).toEqual([]); }); -test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }) => { +test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }, testInfo) => { + await page.setViewportSize({width:1600,height:1100}); + async function capture(name) { + await page.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(image=>image.decode().catch(()=>{})));}); + const path=testInfo.outputPath(`${name}.png`);await page.screenshot({path}); + await testInfo.attach(name,{path,contentType:'image/png'}); + } const snapshot = await (await request.get('/prompt_manager/stats')).json(); await page.goto(workspace); await expect(cards(page)).toHaveCount(2); @@ -29,8 +35,17 @@ test('native Images keeps colliding filenames distinct and inspects the selected await expect(details(page)).toContainText('same.png'); await expect(details(page)).toContainText('shared/same.png'); await expect(details(page)).toContainText(/input/i); + await capture('after-images-source-collision-input-1600'); await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click(); await expect(details(page)).toContainText(/output/i); + await capture('after-images-source-collision-output-1600'); + await page.setViewportSize({width:390,height:844}); + for (const [index,source] of [[1,'input'],[0,'output']]) { + await cards(page).nth(index).getByRole('button',{name:'Open same.png',exact:true}).click(); + await expect(details(page)).toContainText(new RegExp(source,'i')); + await details(page).scrollIntoViewIfNeeded(); + await capture(`after-images-source-collision-${source}-390`); + } expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats); }); @@ -480,3 +495,64 @@ test('leaving during Refresh cancels it without replacing the next workspace', a await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible(); await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0); }); + + +test('Images uses approved geometry while keeping real controls', async ({page,request}) => { + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + await page.setViewportSize({width:1600,height:1100}); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(24); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); + await expect(details(page)).toHaveCSS('width','310px'); + await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px'); + for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible(); + await expect(page.getByRole('searchbox',{name:'Search filenames',exact:true})).toHaveAttribute('placeholder',/filename or folder/); + await expect(page.getByRole('link',{name:'Group by positive prompt',exact:true})).toHaveAttribute('href','#/library'); + const geometry = await cards(page).evaluateAll(nodes=>nodes.slice(0,4).map(node=>{ + const box=node.getBoundingClientRect(),media=node.querySelector('.image-open').getBoundingClientRect(); + return {x:box.x,y:box.y,right:box.right,bottom:box.bottom,width:box.width,mediaWidth:media.width,mediaHeight:media.height}; + })); + expect(geometry[1].x-geometry[0].right).toBeCloseTo(13,0); + expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0); + expect(geometry[3].x).toBeCloseTo(geometry[0].x,0); + expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2); + expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y); + await expect(cards(page).first()).toHaveCSS('outline-style','solid'); + const cover=details(page).locator(':scope > img'); + await expect(cover).toHaveCSS('height','150px'); + expect(await details(page).evaluate(node=>node.firstElementChild.tagName)).toBe('IMG'); + await page.setViewportSize({width:390,height:844}); + await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0); + await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); +}); + +test('density changes and resize preserve identity focus and selection', async ({page,request}) => { + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + await page.setViewportSize({width:1600,height:1100});await page.goto(workspace); + await expect(cards(page)).toHaveCount(24); + const first=cards(page).first();await first.evaluate(node=>{window.originalImageCard=node;}); + await first.getByRole('button',{name:/^Open /}).click(); + const key=await first.getAttribute('data-image-key'); + let imageReads=0;page.on('request',request=>{if(request.url().includes('/images/output?')) imageReads++;}); + for (const layout of ['Grid','List','Masonry']) for (const [density,columns] of [['comfortable',3],['large',2],['compact',4]]) { + await page.getByRole('button',{name:layout,exact:true}).click(); + await page.getByRole('combobox',{name:'Thumbnail size',exact:true}).selectOption(density); + await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown'); + await expect(cards(page).nth(layout==='List'?1:columns).getByRole('button',{name:/^Open /})).toBeFocused(); + await expect(first).toHaveClass(/selected/); + expect(await first.evaluate(node=>node===window.originalImageCard)).toBe(true); + } + expect(imageReads).toBe(0); + await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('h1')).toHaveText('Overview'); + await page.evaluate(()=>{location.hash='#/images';});await expect(page.getByRole('combobox',{name:'Thumbnail size',exact:true})).toHaveValue('compact'); + await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/); + await page.getByRole('button',{name:'Grid',exact:true}).click(); + for (const [width,columns] of [[951,3],[950,3],[700,2],[390,2]]) { + await page.setViewportSize({width,height:844}); + await expect.poll(()=>cards(page).nth(columns).evaluate(node=>node.offsetLeft)).toBe(0); + await first.getByRole('button',{name:/^Open /}).focus();await page.keyboard.press('ArrowDown'); + await expect(cards(page).nth(columns).getByRole('button',{name:/^Open /})).toBeFocused(); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + } +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 182f95b..a6a530d 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -97,6 +97,25 @@ test('failed library reads retry while late search responses cannot overwrite ne await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); }); +test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{ + await page.goto(route);await expect(cards(page)).toHaveCount(3); + const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id'); + await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK(); + await expect(await request.put(`/prompt_manager/prompts/${id}/rating`,{data:{rating:5}})).toBeOK(); + await page.evaluate(()=>{ + const native=requestAnimationFrame;const held=[];let paused=true;window.releaseLibraryFrames=()=>{paused=false;window.requestAnimationFrame=native;for(const callback of held)callback();}; + window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);}); + }); + try { + await page.getByRole('button',{name:'Refresh library',exact:true}).click(); + await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…'); + } finally {await page.evaluate(()=>window.releaseLibraryFrames());} + await expect(cards(page)).toHaveCount(3); + const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape'); + await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1); + await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden(); +}); + test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{ await page.setViewportSize({width:390,height:844});await page.goto(route); await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals'); @@ -110,6 +129,29 @@ test('returning from history restores library context and mobile content stays i await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); +test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ + await page.setViewportSize({width:1600,height:300});await page.goto(route); + await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive'); + await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.'); + 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 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 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); + const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape'); + await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused(); + await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1); + expect(await page.evaluate(()=>scrollY)).toBe(0); + const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); + await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + await page.setViewportSize({width:390,height:844});await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape'); +}); + test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{ let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;}); await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{ @@ -168,3 +210,218 @@ test('large variant histories append bounded pages without replacing the selecte await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative); }); + +async function observeLibrary(page, hold=false) { + await page.addInitScript(({hold})=>{ + const NativeResizeObserver=window.ResizeObserver; + const records=[];let holding=hold; + const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window); + const pending=new Set(),heldFrames=new Map();let requested=0,mutations=0,unmounted=0,holdingFrames=false; + window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{const deliver=()=>{pending.delete(id);callback(time);};if(holdingFrames)heldFrames.set(id,deliver);else deliver();});pending.add(id);return id;}; + window.cancelAnimationFrame=id=>{pending.delete(id);heldFrames.delete(id);cancel(id);}; + window.ResizeObserver=class { + constructor(callback) { + const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false}; + record.native=new NativeResizeObserver(entries=>{ + const ready=[]; + for(const entry of entries) { + if(entry.target.matches('.library-card')) { + if(!entry.target.isConnected)unmounted++; + record.latest.set(entry.target,entry); + if(holding) {record.entries.set(entry.target,entry);continue;} + } + ready.push(entry); + } + if(ready.length)callback(ready,this); + });records.push(record);this.record=record; + } + observe(target,options){this.record.targets.add(target);this.record.native.observe(target,options);} + unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);} + disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();} + }; + new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']}); + window.libraryObservation={ + hold(){holding=true;}, + release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}}, + holdFrames(){holdingFrames=true;}, + releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();}, + equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}}, + stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};}, + }; + },{hold}); +} +async function settleLibrary(page) { + await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0); + await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60))); + await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0); +} +async function firstVisibleParent(page) { + return cards(page).evaluateAll(nodes=>{ + const card=nodes.find(node=>{const box=node.getBoundingClientRect();return box.bottom>0&&box.topMath.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2); +} + +test('Library natural rows retain long prompt content without overflow',async({page,request})=>{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + const data=await(await request.get('/prompt_manager/library')).json(); + for(const [width,height] of [[1600,1100],[390,844]]) { + await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6); + expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); + await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.'); + await expect(page.locator('.library-shelf > .intro')).toHaveCount(0); + for(const theme of ['dark','light']) { + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); + } + await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px'); + await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px'); + await expect(page.locator('.library-grid')).toHaveCSS('gap','18px'); + const long=cards(page).filter({hasText:'A study in very long creative context'}); + const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500); + expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); + for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) { + expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true); + } + await prompt.focus();await expect(prompt).toBeFocused(); + await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded(); + await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible(); + for(const item of data.items) { + const card=page.locator(`article[data-prompt-id="${item.id}"]`); + await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1); + await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags); + } + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i); + const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};})); + for(let i=0;i{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page,true);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/serve/**',async route=>{await held;await route.continue().catch(()=>{});}); + try { + await page.setViewportSize({width:1600,height:1100});await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25)); + await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); + const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor); + expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after); + await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + expect(await cards(page).count()).toBeLessThan(80); + const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length); + for(const changedWidth of [false,true]) { + const before=await firstVisibleParent(page); + await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + if(changedWidth)await page.setViewportSize({width:1600,height:1100}); + await page.getByRole('link',{name:'Back to library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before); + expect(await cards(page).count()).toBeLessThan(80); + } + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {release();} +}); + +test('queued row growth preserves newer native wheel movement and correction events',async({page,request},testInfo)=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await page.setViewportSize({width:1600,height:1100});await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25)); + await settleLibrary(page);await page.mouse.move(1100,550); + const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + try { + // The same queued-frame wheel sequence is stable without changed measurements. + const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY); + await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160); + await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(controlY+160); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page); + expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160}); + + const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); + const parent=page.locator(`article[data-prompt-id="${before.id}"]`); + const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset; + expect(growth).toBeGreaterThan(0);expect(await page.evaluate(()=>scrollY)).toBe(beforeY); + await page.evaluate(()=>{window.libraryObservation.holdFrames();window.libraryObservation.release();}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.mouse.wheel(0,160);await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(beforeY+160); + expect(await page.evaluate(()=>document.documentElement.scrollHeight-innerHeight-scrollY)).toBeGreaterThan(1000); + await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page); + await testInfo.attach('queued-growth-wheel-geometry',{body:JSON.stringify({beforeY,offset:before.offset,growth,wheel:160,finalY:await page.evaluate(()=>scrollY),finalOffset:await parent.evaluate(node=>node.getBoundingClientRect().top)}),contentType:'application/json'}); + await retainedParent(page,{...before,offset:before.offset-160}); + expect(Math.abs(await page.evaluate(()=>scrollY)-(beforeY+160+growth))).toBeLessThanOrEqual(2); + + // The correction's own native scroll event must not become new user motion. + const corrected=await firstVisibleParent(page); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); + await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length); + await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.evaluate(()=>{location.hash='#/overview';});await expect(cards(page)).toHaveCount(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).heldFrames).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {await page.evaluate(()=>{window.libraryObservation.release();window.libraryObservation.releaseFrames();});} +}); + +test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + let session,breakpoint; + try { + if(browserName==='chromium') { + session=await page.context().newCDPSession(page);await session.send('Debugger.enable'); + await page.addInitScript(()=>{window.libraryLayoutCalls=0;}); + const scripts=[];session.on('Debugger.scriptParsed',script=>scripts.push(script)); + await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page); + const script=scripts.find(script=>script.url.endsWith('/workspace/library-layout.mjs'));expect(script).toBeTruthy(); + const {scriptSource}=await session.send('Debugger.getScriptSource',{scriptId:script.scriptId}); + const lines=scriptSource.split('\n');const start=lines.findIndex(line=>line.includes('export function layoutLibraryRows('));expect(start).toBeGreaterThanOrEqual(0); + const possible=await session.send('Debugger.getPossibleBreakpoints',{start:{scriptId:script.scriptId,lineNumber:start},end:{scriptId:script.scriptId,lineNumber:start+3},restrictToFunction:false}); + const location=possible.locations.find(location=>location.lineNumber>start);expect(location).toBeTruthy(); + breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId; + } else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);} + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page); + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0); + const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); + await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page); + const after=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); + expect(after.stats.requested-before.stats.requested).toBeLessThanOrEqual(1);expect(after.stats.mutations).toBe(before.stats.mutations);if(session)expect(after.calls).toBe(before.calls); + for(let i=0;i<3;i++) {await page.evaluate(()=>scrollBy(0,1));await settleLibrary(page);} + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls); + const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page); + expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page); + const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page); + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1); + await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page); + expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();} +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs new file mode 100644 index 0000000..75d2f1a --- /dev/null +++ b/tests/browser/workspace-visual.spec.mjs @@ -0,0 +1,513 @@ +import { writeFile } from 'node:fs/promises'; +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; + +test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' }); +const workspace = '/prompt_manager/workspace'; +const primary = ['images', 'library', 'tags', 'inspect']; +const tools = ['overview', 'system']; + +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}`); + await expect(region).toHaveCount(1); + expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes); + } +} +async function attachEvidence(page, testInfo, name, fullPage = true) { + await page.evaluate(async () => { + await document.fonts.ready; + for (const image of document.images) image.loading = 'eager'; + await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); + }); + const screenshotPath = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: screenshotPath, fullPage }); + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { + const node = document.querySelector(selector); + if (!node) return [selector, null]; + const box = node.getBoundingClientRect(), style = getComputedStyle(node); + return [selector, { x: box.x, y: box.y, width: box.width, height: box.height, padding: style.padding, fontSize: style.fontSize }]; + }))); + const geometryPath = testInfo.outputPath(`${name}-geometry.json`); + await writeFile(geometryPath, JSON.stringify(geometry, null, 2)); + await testInfo.attach(`${name}-geometry`, { path: geometryPath, contentType: 'application/json' }); +} + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await expect(await request.post('/__test__/reset')).toBeOK(); +}); +test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); }); + +test('visual fixture exposes real stable media and library records', async ({ request }) => { + expect((await request.post('/__test__/reset', { data: { workspace_visual: 'true' } })).status()).toBe(400); + for (const option of ['extra_images', 'include_video', 'prompt_inspector', 'native_library', 'extra_prompts', 'extra_variants']) { + expect((await request.post('/__test__/reset', { data: { workspace_visual: true, [option]: option.startsWith('extra') ? 1 : true } })).status()).toBe(400); + } + await expect(await request.post('/__test__/reset', { data: { workspace_visual: true } })).toBeOK(); + const images = await (await request.get('/prompt_manager/images/output?limit=40')).json(); + expect(images.total).toBe(24); + expect(new Set(images.images.map(image => image.root_index))).toEqual(new Set([0, 1])); + const image = images.images[0]; + await expect(await request.get(image.url)).toBeOK(); + const thumbnail = await request.get(image.thumbnail_url); + await expect(thumbnail).toBeOK(); + expect(thumbnail.headers()['content-type']).toBe('image/png'); + const inspection = await (await request.get(`/prompt_manager/images/inspect/${encodeURIComponent(image.path)}?root=${image.root_index}`)).json(); + expect(inspection.success).toBe(true); + expect(inspection.image.path).toBe(image.path); + expect(inspection.prompts.positive.text).toContain('Amber geometry'); + expect(inspection.prompts.negative.text).toBe('blurred edges, watermark'); + expect(new Set(images.images.map(image => image.modified_time)).size).toBe(24); + const library = await (await request.get('/prompt_manager/library')).json(); + expect(library.total).toBe(6); + expect(library.items.reduce((sum, item) => sum + item.variant_count, 0)).toBe(10); + const recipes = await Promise.all(library.items.map(async item => (await request.get(`/prompt_manager/library/${item.id}`)).json())); + expect(recipes.flatMap(recipe => recipe.variants).some(variant => variant.negative_text === '')).toBe(true); + const options = await (await request.get('/prompt_manager/library/options')).json(); + expect(options.success).toBe(true); + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + const repeated = await (await request.get('/prompt_manager/images/output?limit=40')).json(); + const identity = items => items.map(({root_index, path, modified_time}) => ({root_index, path, modified_time})); + expect(identity(repeated.images)).toEqual(identity(images.images)); + await expect(await request.post('/__test__/reset')).toBeOK(); + expect((await (await request.get('/prompt_manager/stats')).json()).stats.total_prompts).toBe(2); +}); + +test('shell matches approved stable geometry in both themes', async ({ page, request }, testInfo) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + for (const [width, height] of [[1600, 1100], [390, 844]]) { + await page.setViewportSize({ width, height }); + for (const route of ['images', 'library']) { + await page.goto(`${workspace}#/${route}`); + if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px'); + await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible(); + await groupedNavigation(page); + await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible(); + await expect(page.locator('.brand')).toHaveAttribute('href', '#/images'); + await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0); + for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) { + await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme); + await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active); + await expect(page.locator('h1')).toHaveCSS('font-weight', '620'); + await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px'); + await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px'); + await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px'); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px'); + await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px'); + if (width === 1600) { + await expect(page.locator('.sidebar')).toHaveCSS('width', '218px'); + 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); + } + await noOverflow(page); + await attachEvidence(page, testInfo, `${route}-${width}-${theme}`); + } + } + } +}); + +test('long content and zoom keep grouped navigation reachable', async ({ page, request }) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.goto(`${workspace}#/library`); + await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await groupedNavigation(page); + for (const route of [...primary, ...tools]) { + const link = page.locator(`nav [data-route="${route}"]`); + await link.focus(); await expect(link).toBeFocused(); + } + await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus(); + await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused(); + const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' }); + await text.scrollIntoViewIfNeeded(); + expect((await text.textContent()).length).toBeGreaterThan(1500); + await text.focus(); await expect(text).toBeFocused(); + expect(await text.evaluate(node => { + const scroller = node.closest('.library-card-prompts'); + return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1; + })).toBe(true); + await noOverflow(page); + await page.evaluate(() => { document.documentElement.style.zoom = ''; }); + for (const width of [1251, 1250, 951, 950, 780, 700]) { + await page.setViewportSize({ width, height: 1100 }); + await expect(page.locator('.sidebar')).toHaveCSS('position', width > 950 ? 'sticky' : 'static'); + if (width > 950) await expect(page.locator('.sidebar')).toHaveCSS('width', width > 1250 ? '218px' : '185px'); + await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width > 1250 ? '30px' : width > 700 ? '24px' : '14px'); + await noOverflow(page); + } +}); + +test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await expect(page.locator('article[data-image-key]')).toHaveCount(24); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + const preference = page.getByRole('combobox', { name: 'Appearance', exact: true }); + const bounds = await preference.evaluate(select => { + const field = select.closest('label'); + return { intendedWrapper: field.classList.contains('workspace-appearance'), + boxes: [field, field.querySelector('.ca-field-label'), select].map(node => { + const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width }; + }), width: document.documentElement.clientWidth }; + }); + for (const box of bounds.boxes) { + expect(box.width).toBeGreaterThan(0); + expect(box.left).toBeGreaterThanOrEqual(-1); + expect(box.right).toBeLessThanOrEqual(bounds.width + 1); + } + expect(bounds.intendedWrapper).toBe(true); + for (const theme of ['follow', 'light', 'dark']) { + await preference.selectOption(theme); await expect(preference).toHaveValue(theme); + } + await preference.focus(); await expect(preference).toBeFocused(); + await noOverflow(page); + await attachEvidence(page, testInfo, 'zoom-shell-390', false); +}); + +async function containedImageContent(page) { + return page.locator('article[data-image-key]').evaluateAll(nodes => { + const failures = []; + for (const card of nodes) { + const outer = card.getBoundingClientRect(); + for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) { + const node = card.querySelector(selector), box = node.getBoundingClientRect(); + if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 || + box.top < outer.top - 1 || box.bottom > outer.bottom + 1 || + (selector === 'small' && node.scrollHeight > node.clientHeight + 1)) { + failures.push({ key: card.dataset.imageKey, selector, + card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom }, + content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } }); + } + } + const action = card.querySelector('.image-card-footer button').getBoundingClientRect(); + const range = document.createRange(); range.selectNodeContents(card.querySelector('small')); + for (const text of range.getClientRects()) { + if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 && + Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) { + failures.push({ key: card.dataset.imageKey, selector: 'facts/action overlap', + text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom }, + action: { left: action.left, right: action.right, top: action.top, bottom: action.bottom } }); + } + } + } + return failures; + }); +} + +for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of ['Grid', 'List', 'Masonry']) { + test(`${layout} contains real card text and keyboard actions at ${width}px with 200 percent zoom`, async ({ page, request }, testInfo) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width, height }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'); + await expect(cards).toHaveCount(24); + await page.getByRole('button', { name: layout, exact: true }).click(); + const firstKey = await cards.first().getAttribute('data-image-key'); + for (const zoom of [1, 2]) { + await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom); + for (const density of ['comfortable', 'large', 'compact']) { + await page.getByRole('combobox', { name: 'Thumbnail size', exact: true }).selectOption(density); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + const columns = await cards.evaluateAll(nodes => { + const top = nodes[0].getBoundingClientRect().top; + return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length; + }); + if (zoom === 1) expect(columns).toBe(layout === 'List' ? 1 : width === 390 || density === 'large' ? 2 : density === 'compact' ? 4 : 3); + else expect(columns).toBe(layout === 'List' || width === 390 ? 1 : density === 'large' ? 2 : density === 'compact' ? 4 : 3); + if (zoom === 1 && layout === 'List') await expect(cards.first()).toHaveCSS('height', '97px'); + await cards.first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown'); + await expect(cards.nth(columns).locator('.image-open')).toBeFocused(); + await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused(); + expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey); + } + const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' }); + const filename = await longCard.locator('strong').textContent(); + expect(filename).toMatch(/layered-context-.*\.png/); + await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter'); + await expect(page.locator('.image-details h2')).toHaveText(filename); + const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true }); + await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible(); + await expect(page.locator('.image-preview > header h2')).toHaveText(filename); + await page.keyboard.press('Escape'); + await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab'); + await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused(); + await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + await page.evaluate(() => scrollTo(0, 0)); + await attachEvidence(page, testInfo, `zoom-${layout.toLowerCase()}-${width}-${zoom}`, false); + } + }); +} + +test('zoomed virtual Images keeps the visible real media mounted during native scrolling', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await request.post('/__test__/reset', { data: { extra_images: 200 } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'); + await expect(cards).toHaveCount(40); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0); + const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + } + await expect.poll(() => cards.count()).toBeLessThan(120); + await page.evaluate(stride => { + const grid = document.querySelector('.image-grid'); + scrollTo(0, grid.getBoundingClientRect().top + scrollY + stride * 100 - 120); + }, stride); + await expect(page.locator('article[data-index="100"]')).toBeVisible(); + await expect(page.locator('article[data-index="101"]')).toHaveCount(1); + const visible = page.locator('article[data-index="100"]'); + await visible.locator('.image-open').focus(); await page.keyboard.press('ArrowDown'); + await expect(page.locator('article[data-index="101"] .image-open')).toBeFocused(); + await page.keyboard.press('ArrowUp'); await expect(visible.locator('.image-open')).toBeFocused(); + await expect(visible).toBeInViewport(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + await expect.poll(() => cards.count()).toBeLessThan(120); +}); + +test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); + await expect(cards).toHaveCount(24); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0); + const selected = cards.nth(16), key = await selected.getAttribute('data-image-key'); + await selected.locator('.image-open').click(); await selected.locator('.image-open').focus(); + const filename = await selected.locator('strong').textContent(); + await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120)); + await page.evaluate(() => { + const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')]; + const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get; + const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]); + const restore = () => { for (const [node, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight; + } }; + window.footerMeasurementProbe = { before: null, restore }; + for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() { + const height = nativeHeight.call(this); + if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' && + Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) { + const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight); + const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; }); + window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey, + top: first.getBoundingClientRect().top - top } : null; + restore(); + } + return height; + } }); + document.documentElement.style.zoom = '4'; + const box = grid.getBoundingClientRect(); + // The approved initial reservation is 54px with 4:3 media and a 13px row gap. + const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13; + // Place row eight inside the 52px scaled gap so the preceding row is not visible. + const index = 8; + scrollTo(0, box.top + scrollY + index * stride * 4 - 16); + }); + try { + await expect.poll(() => page.evaluate(() => window.footerMeasurementProbe.before?.index)).toBe(8); + const anchor = await page.evaluate(() => window.footerMeasurementProbe.before); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + await expect.poll(() => containedImageContent(page)).toEqual([]); + const visibleAnchor = page.locator(`article[data-index="${anchor.index}"]`); + const anchorTop = await visibleAnchor.evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)); + expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2); + await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/); + await expect(selected.locator('.image-open')).toBeFocused(); + await expect(page.locator('.image-details h2')).toHaveText(filename); + const mutations = await grid.evaluate(async node => { + let changes = 0; + const observer = new MutationObserver(records => { changes += records.length; }); + observer.observe(node, { subtree: true, attributes: true, attributeFilter: ['style'] }); + for (let frame = 0; frame < 6; frame++) await new Promise(requestAnimationFrame); + observer.disconnect(); return changes; + }); + expect(mutations).toBe(0); + await page.getByRole('button', { name: 'List', exact: true }).click(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match'); + await expect(cards).toHaveCount(0); + expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill(''); + await expect(cards).toHaveCount(24); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height'))); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeLessThan(extremeFooter); + await expect(cards.first()).toHaveCSS('height', '97px'); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await page.getByRole('button', { name: 'Grid', exact: true }).click(); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54); + await orderedMedia(page); + } finally { await page.evaluate(() => { + window.footerMeasurementProbe?.restore(); delete window.footerMeasurementProbe; + }); } +}); + +test('supplemental wrapped real footer reservation survives append without moving the visible card', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await request.post('/__test__/reset', { data: { extra_images: 200 } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); + await expect(cards).toHaveCount(40); + await cards.first().locator('.image-open').click(); + const selectedKey = await cards.first().getAttribute('data-image-key'); + await page.evaluate(() => { document.documentElement.style.zoom = '4'; }); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + let release, pending = false; + const held = new Promise(resolve => { release = resolve; }); + await page.route('**/images/output?**', async route => { + if (new URL(route.request().url()).searchParams.has('cursor')) { pending = true; await held; } + await route.continue(); + }); + try { + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + await more.click(); await expect.poll(() => pending).toBe(true); + const anchor = await cards.evaluateAll(nodes => { + const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight); + const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; }); + return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null; + }); + expect(anchor).not.toBeNull(); + release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images'); + await expect(cards).toHaveCount(80); + await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1); + await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/); + await expect(more).toBeFocused(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + } finally { release(); } +}); + +test('Library visual evidence keeps natural cards and returns from real history in both themes',async({browser,browserName,request},testInfo)=>{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + for(const [width,height] of [[1600,1100],[390,844]]) for(const theme of ['dark','light']) { + const context=await browser.newContext({viewport:{width,height},locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); + const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName); + try { + await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false); + await noOverflow(page); + await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); + await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); + await expect(page.locator('.library-history > div > .intro')).toHaveCount(1); + await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); + expect(errors).toEqual([]); + } finally {await saveCoverage(page,browserName);await context.close();} + } +}); + + +test('approved icons are decorative safe SVGs and reject unknown vocabulary', async ({ page }) => { + 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 otherDocument = document.implementation.createHTMLDocument(); + const rendered = names.map(name => { + const icon = createWorkspaceIcon(name, otherDocument); + return { name, svg: icon instanceof SVGSVGElement, 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 }; + }); + const unknown = ['missing', '__proto__', '', null, 1].map(name => { + try { createWorkspaceIcon(name); return false; } catch (error) { return error instanceof TypeError; } + }); + return { rendered, unknown }; + }); + 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.path.length).toBeGreaterThan(10); + } + expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4'); +}); + + +async function orderedMedia(page) { + const boxes=await page.locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>{ + const box=node.getBoundingClientRect();return {x:box.x,y:box.y,right:box.right,bottom:box.bottom}; + })); + for (let index=0;index{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + let release, mediaRequests=0;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/serve/**',async route=>{mediaRequests++;await held;await route.continue().catch(()=>{});}); + try { + await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'}); + await expect(page.locator('article[data-image-key]')).toHaveCount(24); + await expect.poll(()=>mediaRequests).toBeGreaterThan(0); + expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false); + await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px'); + await orderedMedia(page); + } finally {release();} + for (const [width,height] of [[1600,1100],[390,844]]) { + await page.setViewportSize({width,height}); + for (const theme of ['dark','light']) { + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await page.locator('.image-card .image-open').first().click(); + await expect(page.locator('.image-details h2')).toHaveText('study-01.png'); + for (const layout of ['Grid','List','Masonry']) { + await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0)); + if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px'); + await orderedMedia(page); + await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false); + } + await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded(); + const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'}); + await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/); + await longCard.locator('.image-open').click(); + await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent()); + await orderedMedia(page); + await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false); + } + } + await page.evaluate(()=>{location.hash='#/overview';}); + await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0); +}); + +test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + for (const width of [1600,390]) { + await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`); + await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); + await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); + await expect(page.locator('h1')).not.toHaveText('Prompt library'); + const title=await page.locator('h1').textContent();expect(title).not.toBe('Images'); + await expect(page.locator('article[data-image-key]').first()).toBeVisible(); + for (const layout of ['List','Masonry','Grid']) { + await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); + await expect(page.locator('h1')).toHaveText(title); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0); + } + await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); + await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0); + } +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index ba64873..7dc713b 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -13,6 +13,17 @@ const routes = [ ['system', 'System', '/prompt_manager/admin'], ]; +async function countAppearanceWrites(page) { + await page.evaluate(() => { + window.__appearanceChanges = 0; + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function (...args) { + if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++; + return original.apply(this, args); + }; + }); +} + test.beforeEach(async ({ page, request, browserName }) => { await startCoverage(page, browserName); await expect(await request.post('/__test__/reset')).toBeOK(); @@ -191,7 +202,13 @@ test('leaving a loading overview prevents its delayed result from replacing the 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.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1); await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible(); + await countAppearanceWrites(page); + await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light'); + expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1); }); test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => { @@ -211,8 +228,13 @@ test('cached-page restoration remounts one working shell and refreshes its data' 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(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1); + await countAppearanceWrites(page); await navigation.getByRole('link', { name: 'Images', exact: true }).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'); + expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1); }); diff --git a/tests/browser/workspace_visual_fixture.py b/tests/browser/workspace_visual_fixture.py new file mode 100644 index 0000000..3504d46 --- /dev/null +++ b/tests/browser/workspace_visual_fixture.py @@ -0,0 +1,161 @@ +"""Deterministic original artwork and saved prompts for workspace visual checks.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +from typing import TYPE_CHECKING + +from PIL import Image, ImageDraw +from PIL.PngImagePlugin import PngInfo + +from utils.hashing import generate_prompt_hash + +if TYPE_CHECKING: + from py.api import PromptManagerAPI + + +_POSITIVES = ( + ( + "Amber geometry\nLayered sandstone arches, warm afternoon light, " + "precise silhouettes." + ), + ( + "Tidal observatory\nCobalt circles over a quiet sea, " + "a pale horizon and measured reflections." + ), + ( + "Forest atlas\nOverlapping emerald triangles, paper textures " + "and a soft misty background." + ), + ( + "Rose pavilion\nCoral blocks and floating windows, " + "balanced shadows on a terracotta plaza." + ), + "Violet orbit\nConcentric rings, lavender haze and a tiny silver moon.", + "A study in very long creative context\n" + + "Preserve every geometric detail, layered color relationship and quiet edge. " + * 18 + + "unbroken-" + + "context" * 40, +) +_NEGATIVES = ( + ("blurred edges, watermark", "flat lighting, clipped highlights", ""), + ("low contrast, noise", "distorted horizon"), + ("oversaturated foliage", "soft geometry"), + ("unbalanced composition",), + ("banding, harsh gradients",), + ("cropped details, illegible marks",), +) + + +def _write_art(path: Path, index: int, positive: str, negative: str) -> None: + sizes = ((720, 480), (480, 720), (560, 560), (900, 400)) + palettes = ( + ("#252c46", "#e0a75e", "#b76445"), + ("#153b57", "#64bfc6", "#dfdba8"), + ("#1d4947", "#71ac83", "#d5d8aa"), + ("#523547", "#ef9c87", "#bc6764"), + ("#342b59", "#a79bd6", "#e5c0bf"), + ("#303d4d", "#b1c4c5", "#d6a05f"), + ) + width, height = sizes[index % len(sizes)] + background, accent, highlight = palettes[index // 4] + graph = { + "1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}}, + "3": { + "class_type": "KSampler", + "inputs": {"positive": ["1", 0], "negative": ["2", 0]}, + }, + "4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}}, + } + info = PngInfo() + info.add_text("prompt", json.dumps(graph)) + path.parent.mkdir(parents=True, exist_ok=True) + with Image.new("RGB", (width, height), background) as image: + draw = ImageDraw.Draw(image) + draw.rectangle((0, height * 3 // 4, width, height), fill=highlight) + radius = min(width, height) // 3 + center_x, center_y = width // 2 + (index % 3 - 1) * 25, height // 2 + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=accent, + ) + draw.polygon( + ( + (width // 8, height * 7 // 8), + (width // 2, height // 4), + (width * 7 // 8, height * 7 // 8), + ), + outline=highlight, + width=8, + ) + draw.line( + (width // 6, height // 3, width * 5 // 6, height // 3), + fill=highlight, + width=4, + ) + image.save(path, pnginfo=info) + stamp = 1_760_000_000 - index * 60 + os.utime(path, (stamp, stamp)) + + +def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None: + """Replace only the owned browser harness records and media roots.""" + with api.db.model.get_connection() as conn: + for table in ( + "image_prompt_pairs", + "prompt_pair_tags", + "prompt_pairs", + "generated_images", + "prompt_tags", + "tags", + "prompts", + ): + conn.execute(f"DELETE FROM {table}") + for name in ("output", "input"): + shutil.rmtree(root / name) + (root / name).mkdir() + for parent_index, (positive, negatives) in enumerate(zip(_POSITIVES, _NEGATIVES)): + category = ("Architecture", "Landscape", "Abstract")[parent_index % 3] + tags = ["geometric", ("warm", "cool")[parent_index % 2]] + if parent_index == 5: + tags.append("long-context-" + "detail" * 28) + prompt_id = api.db.save_prompt( + positive, + category=category, + tags=tags, + prompt_hash=generate_prompt_hash(positive), + rating=parent_index if parent_index else None, + ) + for variant_index, negative in enumerate(negatives): + api.db.save_prompt_pair( + positive, + negative, + title=f"Composition {variant_index + 1}", + category=category, + tags=tags, + ) + for offset in range(4): + index = parent_index * 4 + offset + directory = ("output", "input")[offset % 2] + filename = f"study-{index + 1:02}.png" + if index == 23: + filename = ( + "a-very-long-geometric-composition-" + + "layered-context-" * 9 + + ".png" + ) + path = root / directory / category.lower() / filename + _write_art(path, index, positive, negatives[offset % len(negatives)]) + api.db.link_image_to_prompt(prompt_id, str(path)) + api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150)) + api.invalidate_gallery_cache() diff --git a/tests/js/workspace-layout.test.js b/tests/js/workspace-layout.test.js index bcfdccb..59039dc 100644 --- a/tests/js/workspace-layout.test.js +++ b/tests/js/workspace-layout.test.js @@ -75,6 +75,63 @@ test('binary-searched windows handle boundaries, empty collections and focus wit test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{ const {layoutImages,visibleImages}=await source; const geometry=layoutImages([{}, {}, {}],10000,'grid',20000); - assert.equal(geometry.maxHeight,7572); + assert.equal(geometry.maxHeight,7554.5); assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]); }); + + +test('approved workspace densities share viewport metrics and reject unknown values', async () => { + const {workspaceImageMetrics, layoutImages} = await source; + assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported'); + for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) { + assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected); + for (const density of ['comfortable','large','compact']) { + const metrics = workspaceImageMetrics(width,density); + assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97}); + assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0); + } + } + assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError); + assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable')); +}); + +test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => { + const {workspaceImageMetrics,layoutImages} = await source; + assert.equal(typeof workspaceImageMetrics,'function'); + const metrics = workspaceImageMetrics(1600); + const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics); + assert.equal(positions[1].x-positions[0].width,13); + assert.equal(positions[3].x,0); + assert.equal(positions[3].y-positions[0].height,13); + assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75); + assert.equal(layoutImages([{}],990,'list',240,metrics).height,97); + const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100}); + assert.equal(custom.positions[0].width,485); + assert.equal(custom.height,(485-2)*.75+62); + assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100); +}); + +test('masonry uses real finite proportions and falls back for invalid dimensions', async () => { + const {layoutImages}=await source; + const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}]; + const {positions}=layoutImages(images,990,'masonry',240,{columns:3}); + assert.equal(positions[0].height,(positions[0].width-2)*10+56); + assert.equal(positions[1].height,(positions[1].width-2)/10+56); + for (const position of positions) assert.ok(Number.isFinite(position.height)); +}); + +test('gallery width limits density columns without changing ordinary workspace geometry', async () => { + const {workspaceImageMetrics, layoutImages} = await source; + for (const [viewport, width, density, columns] of [ + [1600,990,'comfortable',3], [1600,990,'compact',4], + [951,428,'compact',3], [390,362,'comfortable',2], + [1600,190,'comfortable',1], [1600,190,'compact',1], + [390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1], + ]) { + const metrics = workspaceImageMetrics(viewport,density,width); + assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`); + const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics); + assert.equal(positions[columns].x,0); + assert.ok(positions[columns].y > 0); + } +}); diff --git a/tests/js/workspace-library-layout.test.js b/tests/js/workspace-library-layout.test.js new file mode 100644 index 0000000..c6a9822 --- /dev/null +++ b/tests/js/workspace-library-layout.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/library-layout.mjs'); + +test('natural Library rows use the tallest measured card and estimate only missing heights', async () => { + const {layoutLibraryRows} = await source; + const heights = Object.freeze([300, 470, 600, 250, null]); + const layout = layoutLibraryRows(heights, 2); + assert.deepEqual(layout.rows, [ + {first: 0, last: 1, top: 0, height: 470}, + {first: 2, last: 3, top: 488, height: 600}, + {first: 4, last: 4, top: 1106, height: 468}, + ]); + assert.equal(layout.height, 1574); + assert.deepEqual(layoutLibraryRows([300, 470], 1), { + rows: [{first: 0, last: 0, top: 0, height: 300}, {first: 1, last: 1, top: 318, height: 470}], height: 788, + }); + assert.deepEqual(layoutLibraryRows([], 2), {rows: [], height: 0}); +}); + +test('row measurements validate geometry and invalid heights reserve the configured estimate', async () => { + const {layoutLibraryRows} = await source; + assert.deepEqual(layoutLibraryRows([0, -1, NaN, Infinity, undefined, '300'], 2, {estimate: 448, gap: 0}).rows.map(row => row.height), [448, 448, 448]); + for (const columns of [0, -1, 1.5, Infinity, NaN, '2']) assert.throws(() => layoutLibraryRows([], columns), TypeError); + for (const estimate of [0, -1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {estimate}), TypeError); + for (const gap of [-1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {gap}), TypeError); + const before = layoutLibraryRows([300, 470, 600], 2); + const appended = layoutLibraryRows([300, 470, 600, 250, 310], 2); + assert.deepEqual(appended.rows[0], before.rows[0]); + assert.deepEqual(appended.rows[1], {first: 2, last: 3, top: 488, height: 600}); + assert.equal(appended.height, 1416); +}); + +test('measured windows add two overscan rows and pin distant focus in ascending DOM order', async () => { + const {layoutLibraryRows, visibleLibraryRows} = await source; + const {rows} = layoutLibraryRows(Array(40).fill(100), 2, {gap: 10}); + for (const row of rows) Object.freeze(row); + Object.freeze(rows); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 0), [0, 3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 39), [3, 4, 5, 6, 7, 19]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 40), [3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, -1), [3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows([], 0, 900), []); + assert.deepEqual(visibleLibraryRows(rows, NaN, Infinity), [0, 1, 2]); +}); + +test('measured-window lookup binary searches a 50000-row shelf without walking it', async () => { + const {visibleLibraryRows} = await source; + let reads = 0; + const rows = new Proxy({length: 50000}, {get(target, key) { + if (/^\d+$/.test(String(key))) { reads++; const index = Number(key); return {first: index * 2, last: index * 2 + 1, top: index * 118, height: 100}; } + return Reflect.get(target, key); + }}); + assert.deepEqual(visibleLibraryRows(rows, 1180000, 100, 0), [0, 9998, 9999, 10000, 10001, 10002]); + assert.ok(reads < 500, `${reads} numeric row reads`); +}); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 7efa59a..219db80 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -42,6 +42,12 @@ test('parent shelf window bounds DOM and pins focused rows without changing logi const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]); }); +test('uniform compatibility window handles huge counts without collection allocation',async()=>{ + const {libraryWindow}=await source;const original=Array.from;const lengths=[]; + Array.from=function(value,...args){lengths.push(value.length);assert.ok(!Number.isFinite(value.length)||value.length<32,'collection-sized allocation');return original.call(this,value,...args);}; + try {const indexes=libraryWindow(Number.MAX_SAFE_INTEGER,2,50000,900,0);assert.ok(indexes.length<32);assert.ok(indexes.includes(0));assert.ok(indexes.includes(2*Math.floor(50000/578)));assert.deepEqual(indexes,[...new Set(indexes)].sort((a,b)=>a-b));assert.ok(lengths.every(length=>length<32));} + finally {Array.from=original;} +}); test('negative variants append continuously without losing selected identity, and failures retry same cursor',async()=>{ const {createLibraryDetail}=await source;const calls=[];let count=0;const v=id=>({id,negative_text:id===9?'':'bad',explicit_empty:id===9}); const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}}); diff --git a/tests/vendor/workflow-artifacts.test.mjs b/tests/vendor/workflow-artifacts.test.mjs new file mode 100644 index 0000000..e41c1f4 --- /dev/null +++ b/tests/vendor/workflow-artifacts.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import workflowConfig from '../../tools/vendor/playwright.workflows.config.mjs'; + +const configUrl = new URL('../../tools/vendor/playwright.workflows.config.mjs', import.meta.url); +const runner = fileURLToPath(new URL('../../tools/vendor/node_modules/@playwright/test/cli.js', import.meta.url)); +const testModule = new URL('../../tools/vendor/node_modules/@playwright/test/index.mjs', import.meta.url).href; + +async function evidenceFiles(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await evidenceFiles(path)); + else if (entry.name === 'evidence.json') files.push(path); + } + return files; +} + +test('sequential workflow project runs retain all four project evidence sets', async t => { + const root = await mkdtemp(join(tmpdir(), 'pm-workflow-artifacts-')); + t.after(() => rm(root, { recursive: true, force: true })); + const uploadedAncestor = resolve(root, 'test-results/workflows'); + const isolateOutput = value => { + assert.equal(typeof value, 'string'); + assert.equal(isAbsolute(value), false, 'configured output must remain relative'); + const output = resolve(root, value); + assert.ok(output.startsWith(root + sep), 'runner output must stay inside its owned temporary root'); + const uploadedRelative = relative(uploadedAncestor, output); + assert.ok(uploadedRelative === '' || (!uploadedRelative.startsWith('..') && !isAbsolute(uploadedRelative)), + 'project evidence must remain beneath the uploaded workflow ancestor'); + return output; + }; + // Validate every output before launching a runner that removes output directories. + const outputDir = isolateOutput(workflowConfig.outputDir); + const projects = workflowConfig.projects.map(project => ({ + ...project, outputDir: isolateOutput(project.outputDir ?? workflowConfig.outputDir), + })); + const testDir = join(root, 'specs'); + await mkdir(testDir); + await writeFile(join(testDir, 'evidence-retention.spec.mjs'), ` +import { test } from ${JSON.stringify(testModule)}; +import { writeFile } from 'node:fs/promises'; +test('writes project evidence without a browser or host', async ({}, testInfo) => { + await writeFile(testInfo.outputPath('evidence.json'), JSON.stringify({ project: testInfo.project.name })); +}); +`); + const configPath = join(root, 'playwright.config.mjs'); + await writeFile(configPath, ` +import config from ${JSON.stringify(configUrl.href)}; +export default { ...config, testDir: ${JSON.stringify(testDir)}, webServer: undefined, + outputDir: ${JSON.stringify(outputDir)}, projects: ${JSON.stringify(projects)} }; +`); + const run = names => execFileSync(process.execPath, [runner, 'test', '--config', configPath, + ...names.flatMap(name => ['--project', name])], { + cwd: root, encoding: 'utf8', timeout: 30_000, + env: { ...process.env, PM_JS_COVERAGE: '', NODE_V8_COVERAGE: '' }, + }); + assert.match(run(['chromium', 'touch']), /2 passed/); + const first = new Map(await Promise.all((await evidenceFiles(uploadedAncestor)).map(async path => [path, await readFile(path, 'utf8')]))); + assert.deepEqual([...first.values()].map(value => JSON.parse(value).project).sort(), ['chromium', 'touch']); + assert.match(run(['firefox', 'webkit']), /2 passed/); + const retained = new Map(await Promise.all((await evidenceFiles(uploadedAncestor)).map(async path => [path, await readFile(path, 'utf8')]))); + assert.deepEqual([...retained.values()].map(value => JSON.parse(value).project).sort(), ['chromium', 'firefox', 'touch', 'webkit']); + for (const [path, contents] of first) assert.equal(retained.get(path), contents, 'later runs preserve earlier evidence bytes and paths'); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 3806c1e..4bfb204 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -214,5 +214,17 @@ "branches": 80, "functions": 93, "statements": 94 + }, + "workspace/icons.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "workspace/library-layout.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 } } diff --git a/tools/vendor/playwright.workflows.config.mjs b/tools/vendor/playwright.workflows.config.mjs index 313ca8e..40226f1 100644 --- a/tools/vendor/playwright.workflows.config.mjs +++ b/tools/vendor/playwright.workflows.config.mjs @@ -19,9 +19,9 @@ export default defineConfig({ gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 }, }, projects: [ - { name: 'chromium', use: { browserName: 'chromium' } }, - { name: 'firefox', use: { browserName: 'firefox' } }, - { name: 'webkit', use: { browserName: 'webkit' } }, - { name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } }, + { name: 'chromium', outputDir: './test-results/workflows/chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', outputDir: './test-results/workflows/firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', outputDir: './test-results/workflows/webkit', use: { browserName: 'webkit' } }, + { name: 'touch', outputDir: './test-results/workflows/touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } }, ], }); diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs new file mode 100644 index 0000000..2d87210 --- /dev/null +++ b/web/js/workspace/icons.mjs @@ -0,0 +1,33 @@ +const paths = Object.freeze({ + "images": "M3 3h18v18H3z M3 16l5-5 5 5 4-4 4 4 M8 7h.01", + "library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z", + "tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01", + "upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6", + "overview": "M3 12h4l3-8 4 16 3-8h4", + "settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2", + "grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", + "list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01", + "masonry": "M3 3h7v11H3zM14 3h7v6h-7zM3 18h7v3H3zM14 13h7v8h-7z", + "search": "M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm5.5 13 5 5", + "info": "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z", + "copy": "M8 8h13v13H8z M16 8V3H3v13h5", + "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" +}); + +/** Create an approved decorative outline icon without injecting markup. */ +export function createWorkspaceIcon(name, doc = document) { + if (typeof name !== 'string' || !Object.hasOwn(paths, name)) { + throw new TypeError('Unknown workspace icon'); + } + 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', + focusable: 'false', fill: 'none', stroke: 'currentColor', + 'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', + })) icon.setAttribute(attribute, value); + const path = doc.createElementNS(namespace, 'path'); + path.setAttribute('d', paths[name]); + icon.append(path); + return icon; +} diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs index 7c6b629..0fdfb42 100644 --- a/web/js/workspace/image-layout.mjs +++ b/web/js/workspace/image-layout.mjs @@ -1,14 +1,25 @@ -export function layoutImages(images, width, mode, minWidth = 240) { - const gap = 16; - const columns = mode === 'list' ? 1 : Math.max(1, Math.floor((width + gap) / (minWidth + gap))); +export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) { + if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density'); + const columns = viewportWidth <= 700 || density === 'large' ? 2 : + density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) : + viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3; + const gap = 13, minimumCardWidth = 120; + const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap))); + return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97}; +} + +export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) { + const {gap = 13, footerHeight = 54, listHeight = 97} = metrics; + const columns = mode === 'list' ? 1 : metrics.columns ?? Math.max(1, Math.floor((width + gap) / (minWidth + gap))); const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns); const bottoms = Array(columns).fill(0); let maxHeight = 0; const positions = images.map((image, index) => { const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns; - const ratio = image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3; - const height = mode === 'list' ? 106 : - (mode === 'masonry' ? Math.max(130, Math.min(440, cardWidth / ratio)) : cardWidth * .75) + 72; + const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3; + const mediaWidth = Math.max(1, cardWidth - 2); + const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth * .75; + const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth * .75) + footerHeight + 2; maxHeight = Math.max(maxHeight, height); const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column]; bottoms[column] = y + height + gap; diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index cf225b7..89001c7 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -1,34 +1,42 @@ -.sidebar { position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; } -.brand-mark { position: relative; } -.brand-mark small { position: absolute; inset-inline-end: 4px; inset-block-end: 2px; font-size: 9px; color: white; } -main.images-main { max-inline-size: none; padding: 28px; } -.image-filters { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); } -.image-filters .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; } +.image-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; } +.image-filters .ca-field { display: flex; flex: 1 1 130px; min-inline-size: 0; flex-direction: column; align-items: stretch; gap: 5px; } .image-filters .ca-field:first-child { flex: 3 1 260px; } -.image-filters .ca-field-label { font-size: 11px; } -.image-filters :where(input, select) { inline-size: 100%; min-block-size: 36px; } -.image-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 18px; } -.image-toolbar p { margin: 0; flex: 1 1 160px; font-size: 12px; } -.image-layouts { display: inline-flex; gap: 3px; } +.image-filters .ca-field-label { font-size: 10px; color: var(--ca-muted); } +.ca-tools .image-filters :where(input, select) { inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 11px; } +.image-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-block: 14px 22px; } +.ca-tools .image-view-controls button { min-block-size: 36px; font-size: 11px; } +.image-view-controls > .workspace-link { gap: 7px; min-block-size: 36px; font-size: 11px; margin-inline-start: auto; } +.image-view-controls .ca-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-inline-size: 0; } +.image-view-controls .ca-field-label { font-size: 10px; color: var(--ca-muted); } +.ca-tools .image-view-controls select { min-block-size: 36px; font-size: 11px; } +.image-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin-block: 0 13px; } +.image-toolbar h2 { font-size: 14px; font-weight: 600; margin: 0; } +.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; } .ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); } -.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 22px; align-items: start; } +.images-main { container: images-page / inline-size; } +.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; } .image-collection { min-inline-size: 0; } .image-grid { position: relative; inline-size: 100%; } -.image-card { position: absolute; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) 72px; } -.image-card.selected { border-color: var(--ca-focus); box-shadow: 0 0 0 1px var(--ca-focus); } -.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 8px 8px 0 0; background: var(--ca-field); } +.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); } +.image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; } +.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); } .image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } -.image-card-footer { position: relative; min-inline-size: 0; padding: 12px 45px 10px 12px; } -.image-card-footer strong { display: block; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.image-card-footer small { display: block; color: var(--ca-muted); font-size: 10px; margin-block-start: 4px; } -.image-card-footer button { position: absolute; inset-inline-end: 8px; inset-block-start: 16px; } -.image-grid[data-layout="list"] .image-card { grid-template-columns: 125px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } -.image-grid[data-layout="list"] .image-open { border-radius: 8px 0 0 8px; } -.image-grid[data-layout="list"] .image-card-footer { align-self: center; } -.image-details { position: sticky; inset-block-start: 18px; padding: 18px; max-block-size: calc(100dvh - 36px); overflow: auto; scrollbar-gutter: stable; } -.image-details h2 { font-size: 15px; overflow-wrap: anywhere; margin-block: 8px 16px; } -.image-details > :where(img, video) { display: block; inline-size: 100%; aspect-ratio: 4 / 3; object-fit: contain; background: var(--ca-field); border-radius: 6px; } -.image-facts { font-size: 11px; margin-block: 18px; } +.image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; } +.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; } +.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-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; } +.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; } +.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; } +.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; } +.image-facts { font-size: 10px; margin-block: 17px; } .image-facts > div { padding-block: 8px; border-block-end: 1px solid var(--ca-line); } .image-facts dt { color: var(--ca-muted); margin-block-end: 4px; } .image-facts dd { margin: 0; overflow-wrap: anywhere; } @@ -42,34 +50,45 @@ main.images-main { max-inline-size: none; padding: 28px; } .image-preview-stage { position: relative; block-size: min(75dvh, 850px); background: var(--ca-field); } .image-preview-stage > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } .image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; } -@media (max-width: 1150px) { - .image-workspace { grid-template-columns: minmax(0, 1fr) 250px; gap: 16px; } - main.images-main { padding: 24px 18px; } +@media (max-width: 1250px) { + .image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; } } -@media (max-width: 780px) { - .sidebar { position: static; block-size: auto; } +@media (max-width: 950px) { + .image-workspace { grid-template-columns: minmax(0, 1fr) 280px; } +} +@media (max-width: 700px) { + .image-workspace { grid-template-columns: minmax(0, 1fr); } + .image-details { position: static; max-block-size: none; padding: 17px; } + .image-details > :where(img, video) { block-size: 200px; } + .image-filters { gap: 8px; } + .image-filters .ca-field { flex-basis: 140px; } + .image-filters .ca-field:first-child { flex-basis: 100%; } + .image-view-controls { gap: 8px; } + .image-view-controls > .workspace-link { margin-inline-start: 0; } +} +@container images-page (inline-size < 600px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } .image-details { position: static; max-block-size: none; } - .image-details > :where(img, video) { max-block-size: 280px; } - .image-filters { gap: 10px; padding: 12px; } - .image-filters .ca-field { flex-basis: 120px; } - .image-layouts { flex-wrap: wrap; } } .image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; } .options-status { font-size: 12px; padding-inline: 4px; } .prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } -.prompt-block { border: 1px solid var(--ca-line); border-inline-start: 3px solid var(--prompt-role); border-radius: 7px; padding: 14px; margin-block-end: 14px; min-inline-size: 0; } -.prompt-positive { --prompt-role: #6bd6a4; } -[data-ca-theme="light"] .prompt-positive { --prompt-role: #14784f; } -.prompt-negative { --prompt-role: #ff7f7f; } -[data-ca-theme="light"] .prompt-negative { --prompt-role: #b51d2d; } -.prompt-block h3 { font-size: 12px; margin: 0 0 8px; color: var(--prompt-role); } -.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 11px; } -.prompt-text { font: inherit; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 12px; } +.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; } +.prompt-positive { --prompt-role: #73dda9; } +[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; } +.prompt-negative { --prompt-role: #ff8c98; } +[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; } +.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); } +.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; } +.prompt-source { margin-block: 6px 10px; } +.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; } .prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } -.prompt-actions { display: flex; flex-wrap: wrap; gap: 8px; } -.prompt-actions button { font-size: 11px; } +.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-copy-status { font-size: 11px; margin-block-end: 0; } .prompt-copy-status:empty { display: none; } .image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8fc9ba5..8e38d40 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,8 +1,9 @@ import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js'; import { createImageStream, imageKey } from './images-state.mjs'; -import { layoutImages, visibleImages } from './image-layout.mjs'; +import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs'; import { mountPromptInspector } from './prompt-inspector.mjs'; import { createPromptSender } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -28,18 +29,24 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage let detailInspector, previewInspector; scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); }); const visit = embedded ? null : lastVisit; + let density = visit?.density || 'comfortable'; + let metrics = workspaceImageMetrics(window.innerWidth, density); let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer; - let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest; + let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest; const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; scope.cleanup(() => { - if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, selection, scrollY: window.scrollY } : null; + if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); scope.cleanup(() => main.classList.remove('images-main')); - if (!embedded) main.append(el('p', 'Browse your images, explore the details, keep your creative context.', 'intro')); + if (!embedded) { + const intro = el('p', 'Browse your images, explore the details, keep your creative context.', 'intro'); + (main.querySelector('.workspace-heading-copy') || main).append(intro); + scope.cleanup(() => intro.remove()); + } const filtersBar = el('div', undefined, 'image-filters'); const search = el('input'); search.value = filters.q; @@ -54,12 +61,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const optionsStatus = el('p', '', 'options-status muted'); optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert'); if (!embedded) main.append(filtersBar, optionsStatus); + const controls = el('div', undefined, 'image-view-controls'); const toolbar = el('div', undefined, 'image-toolbar'); + toolbar.append(el('h2', embedded ? 'Linked images' : 'All images')); const count = el('p', 'Loading images…', 'muted'); const layouts = el('div', undefined, 'image-layouts'); layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout'); for (const mode of ['grid', 'list', 'masonry']) { const button = createButton(mode[0].toUpperCase() + mode.slice(1)); + button.prepend(createWorkspaceIcon(mode)); button.setAttribute('aria-pressed', String(mode === layout)); scope.listen(button, 'click', () => { layout = mode; @@ -89,9 +99,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }); const currentGallery = el('a', 'Current gallery', 'workspace-link'); currentGallery.href = resolveUrl('/prompt_manager/gallery'); - toolbar.append(count, layouts); - if (!embedded) toolbar.append(refresh, currentGallery); - main.append(toolbar); + const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], ''); + thumbnails.value = density; + scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); }); + controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density')); + if (!embedded) { + const group = el('a', 'Group by positive prompt', 'workspace-link'); + group.href = '#/library'; group.prepend(createWorkspaceIcon('branch')); + controls.append(group, refresh); + } + main.append(controls); + toolbar.append(count); const body = el('div', undefined, 'image-workspace'); const collection = el('section', undefined, 'image-collection'); collection.setAttribute('aria-label', 'Images'); @@ -103,7 +121,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const retry = createButton('Retry'); const end = el('p', '', 'muted'); continuation.append(message, more, retry, end); - collection.append(grid, continuation); + collection.append(toolbar, grid, continuation); + if (!embedded) collection.append(currentGallery); const details = el('section', undefined, 'image-details panel'); details.setAttribute('aria-label', 'Image details'); details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); @@ -164,7 +183,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const prompts = el('div'); const legacy = el('p', 'Open Current gallery for tagging.', 'muted'); legacy.hidden = embedded; - details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, preview, download, prompts, metadata, legacy); + details.replaceChildren(thumbnail, el('h2', image.filename), preview, download, prompts, metadata, legacy); detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope}); for (const [key, card] of rendered) { card.classList.toggle('selected', key === selection); @@ -205,8 +224,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage open.append(img); const footer = el('div', undefined, 'image-card-footer'); const title = el('strong', image.filename); title.title = image.path; - const sub = el('small', `${formatBytes(image.size)} · ${image.media_type === 'video' ? 'Video' : 'Image'}`); - const preview = createButton('↗', 'ca-icon-button'); + const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : ''; + const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase(); + const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`); + const preview = createButton('', 'ca-icon-button'); + preview.append(createWorkspaceIcon('images')); preview.setAttribute('aria-label', `Preview ${image.filename}`); footer.append(title, sub, preview); card.append(open, footer); return card; @@ -221,11 +243,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage function renderVisible() { if (!state || scope.signal.aborted) return; - const top = Math.max(0, -grid.getBoundingClientRect().top); + const bounds = grid.getBoundingClientRect(); + const scale = bounds.width / Number.parseFloat(getComputedStyle(grid).width) || 1; + const top = Math.max(0, -bounds.top / scale); + const viewportHeight = window.innerHeight / scale; const active = document.activeElement?.closest('article[data-image-key]')?.dataset.imageKey; const focused = active ? itemIndexes.get(active) ?? -1 : -1; const indexes = state.items.length <= 120 ? state.items.map((_, index) => index) : - visibleImages(geometry.positions, top, window.innerHeight, focused, geometry.maxHeight); + visibleImages(geometry.positions, top, viewportHeight, focused, geometry.maxHeight); const keep = new Set(indexes.map(index => imageKey(state.items[index]))); for (const [key, card] of rendered) if (!keep.has(key)) { for (const video of card.querySelectorAll('video')) releaseVideo(video); @@ -244,6 +269,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild); previous = card; } + // Natural footer measurements and absolute reservations share unzoomed CSS units. + const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight)); + if (footerHeight > metrics.footerHeight) { + const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight); + const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y; + metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)}; + updateGeometry(); + for (const index of indexes) positionCard(rendered.get(imageKey(state.items[index])), index); + // A later mounted footer must not move the current visible card. + const correction = anchor === undefined ? 0 : geometry.positions[anchor].y - anchorTop; + if (top > 0 && correction) window.scrollBy(0, correction * scale); + scheduleVisible(); + } } function positionCard(card, index) { const image = state.items[index], position = geometry.positions[index]; @@ -270,7 +308,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const card = event.target.closest('article[data-image-key]'); if (!card) return; const index = Number(card.dataset.index); - const columns = layout === 'list' ? 1 : Math.max(1, Math.floor((grid.clientWidth + 16) / 256)); + const columns = layout === 'list' ? 1 : metrics.columns; const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1, ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key]; if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); } @@ -284,12 +322,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } }); function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); } + function updateGeometry() { + geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics); + grid.style.height = `${geometry.height}px`; + grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`); + } function updateLayout() { if (!state || scope.signal.aborted) return; - grid.dataset.layout = layout; cancelAnimationFrame(frame); frame = 0; - geometry = layoutImages(state.items, grid.clientWidth, layout); - grid.style.height = `${geometry.height}px`; + const width = Number.parseFloat(getComputedStyle(grid).width); + const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null; + galleryWidth = width; + metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth); + if (retained) { + metrics.footerHeight = retained.footerHeight; + metrics.listHeight = retained.listHeight; + } + grid.dataset.layout = layout; + grid.dataset.density = density; + updateGeometry(); renderVisible(); } let resizeFrame = 0, observedWidth; @@ -304,6 +355,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }); resize.observe(grid); scope.listen(window, 'scroll', scheduleVisible); + scope.listen(window, 'resize', () => { + if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); }); + }); scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); }); function renderState(next) { const changed = state?.items !== next.items; diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index af347e6..596cfec 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -4,6 +4,7 @@ import { routes, mountRouter, summarizeStats } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { mountImages } from './images.mjs'; import { mountLibrary } from './library.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -33,13 +34,24 @@ function mountWorkspace() { const theme = mountWorkspaceTheme({ root: document.body, scope }); const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], ''); preference.value = theme.preference; - appearance.replaceChildren(createField('Appearance', preference, 'workspace-appearance')); + const appearanceField = createField('Appearance', preference, 'workspace-appearance'); + appearanceField.classList.add('workspace-appearance'); + appearance.replaceChildren(appearanceField); scope.listen(preference, 'change', () => theme.setPreference(preference.value)); - nav.replaceChildren(...routes.map(route => { - const link = element('a', route.label); - link.href = `#/${route.id}`; - link.dataset.route = route.id; - return link; + 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; })); let viewScope; let firstRender = true; @@ -49,13 +61,17 @@ function mountWorkspace() { viewScope?.dispose(); viewScope = createScope(); const view = viewScope; - for (const link of nav.children) { + for (const link of nav.querySelectorAll('[data-route]')) { if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); } document.title = `${route.label} · Prompt Manager`; const heading = element('h1', route.label); - main.replaceChildren(element('p', 'YOUR CREATIVE LIBRARY', 'eyebrow'), heading); + const headingCopy = element('div', undefined, 'workspace-heading-copy'); + headingCopy.append(heading); + const headingRegion = element('header', undefined, 'workspace-heading'); + headingRegion.append(headingCopy); + main.replaceChildren(headingRegion); if (!firstRender) main.focus(); firstRender = false; document.getElementById('workspace-breadcrumb').textContent = route.label; @@ -68,7 +84,7 @@ function mountWorkspace() { return; } if (route.id !== 'overview') { - main.append(element('p', route.description, 'intro')); + headingCopy.append(element('p', route.description, 'intro')); const panel = element('section', undefined, 'panel handoff'); panel.append(element('h2', 'Your current tools are ready'), element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'), @@ -76,7 +92,7 @@ function mountWorkspace() { main.append(panel); return; } - main.append(element('p', 'A home for your prompts, images and creative history.', 'intro')); + headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro')); const stats = element('section', undefined, 'overview-stats'); stats.setAttribute('aria-label', 'Library statistics'); main.append(stats); diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs new file mode 100644 index 0000000..0723690 --- /dev/null +++ b/web/js/workspace/library-layout.mjs @@ -0,0 +1,42 @@ +export function layoutLibraryRows(heights, columns, {gap = 18, estimate = 468} = {}) { + if (!Number.isSafeInteger(columns) || columns < 1) throw new TypeError('Invalid Library columns'); + if (!Number.isFinite(gap) || gap < 0 || !Number.isFinite(estimate) || estimate <= 0) throw new TypeError('Invalid Library geometry'); + const rows = []; + let top = 0; + for (let first = 0; first < heights.length; first += columns) { + const last = Math.min(first + columns, heights.length) - 1; + let height = 0; + for (let index = first; index <= last; index++) { + const measured = heights[index]; + height = Math.max(height, Number.isFinite(measured) && measured > 0 ? measured : estimate); + } + rows.push({first, last, top, height}); + top += height + gap; + } + return {rows, height: rows.length ? top - gap : 0}; +} + +function lowerBound(rows, after) { + let low = 0, high = rows.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (after(rows[middle])) low = middle + 1; + else high = middle; + } + return low; +} + +export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1) { + if (!rows.length) return []; + top = Number.isFinite(top) ? Math.max(0, top) : 0; + viewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0; + const first = Math.min(rows.length - 1, lowerBound(rows, row => row.top + row.height <= top)); + const end = Math.max(first + 1, lowerBound(rows, row => row.top < top + viewportHeight)); + const visible = new Set(); + for (let index = Math.max(0, first - 2); index < Math.min(rows.length, end + 2); index++) visible.add(index); + if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0) { + const focus = lowerBound(rows, row => row.last < focusedIndex); + if (focus < rows.length && rows[focus].first <= focusedIndex) visible.add(focus); + } + return [...visible].sort((a, b) => a - b); +} diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 4fc6c02..b0c8405 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -1,29 +1,31 @@ -main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding: 28px; } +main.library-main { --library-positive: #73dda9; } .library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; } -.library-main .library-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-block: 20px 16px; } -.library-main .library-toolbar .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; } +.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; } +.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; } .library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; } -.library-main .library-toolbar :where(input, select) { inline-size: 100%; min-inline-size: 0; min-block-size: 36px; } -.library-main .library-caption { display: flex; justify-content: space-between; font-size: 12px; color: var(--ca-muted); margin-block: 16px; } -.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } -.library-main .library-card { box-sizing: border-box; block-size: 560px; display: grid; grid-template-rows: 170px minmax(0, 1fr) auto; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } +.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } +.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; } +.ca-tools .library-main .library-toolbar > button { inline-size: auto; } +.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; } +.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } +.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } +.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } .library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; } .library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; } .library-main .library-card-art > :only-child { grid-column: 1 / -1; } .library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); } .library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; } -.library-main .library-card-title { max-block-size: 72px; overflow: auto; flex-shrink: 0; display: flex; align-items: start; justify-content: space-between; gap: 12px; } -.library-main .library-card-title h2 { margin: 0; font-size: 16px; min-inline-size: 0; overflow-wrap: anywhere; } +.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; } .library-main .library-title-link { color: var(--ca-text); text-decoration: none; } .library-main .library-title-link:hover { color: var(--ca-accent); } -.library-main .library-card-prompts { flex: 1 1 auto; overflow: auto; } -.library-main .library-card-prompts .prompt-block { margin: 0; } -.library-main .library-card-prompts .prompt-text { max-block-size: 100px; } -.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 11px; color: var(--ca-muted); } -.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 4px 8px; border: 1px solid var(--ca-line); border-radius: 5px; font-size: 11px; color: var(--ca-muted); overflow-wrap: anywhere; } +.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; } +.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); } +.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } .library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); } -.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; max-block-size: 50px; overflow: auto; } -.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; } +.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; } +.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; } +.library-main .library-rating { color: var(--ca-muted); } .library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); } .library-main .library-history-link svg { inline-size: 18px; block-size: 18px; } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } @@ -41,6 +43,6 @@ main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding: .library-main .library-shared-gallery { margin-block-start: 24px; } .library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@media (max-width: 700px) { main.library-main { padding: 20px 14px; } .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } } +@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } } -[data-ca-theme="light"] main.library-main { --library-positive: #14784f; } +[data-ca-theme="light"] main.library-main { --library-positive: #176b46; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index eab6e44..fb3cd8a 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -2,7 +2,9 @@ import {createScope,createButton,createField,createSelect} from '../shared/toolk import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; -import {createLibraryStream,createLibraryDetail,libraryPromptRoles,libraryWindow} from './library-state.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs'; +import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; +import {createWorkspaceIcon} from './icons.mjs'; let shelfVisit=null; const variantSelections=new Map(); @@ -11,17 +13,15 @@ function el(tag,text,className) { } function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;} -function branchIcon() { - const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('aria-hidden','true');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8'); - const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('d','M6 8v8m0-4h6a6 6 0 0 0 6-6M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM21 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z');svg.append(path);return svg; -} export function mountLibrary(main,api,parentScope,{positiveId}={}) { const scope=createScope();parentScope.cleanup(()=>scope.dispose()); - main.classList.add('library-main');scope.cleanup(()=>main.classList.remove('library-main')); + main.classList.add('library-main'); const sender=createPromptSender({scope}); - if(positiveId!==undefined) {mountHistory();return;} - mountShelf(); + if(positiveId!==undefined) mountHistory(); + else mountShelf(); + // Save the visit before removing the class that defines the shelf's scroll range. + scope.cleanup(()=>main.classList.remove('library-main')); function mountShelf() { const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; @@ -41,10 +41,17 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); - continuation.append(message,retry,more);section.append(introduction,toolbar,optionsStatus,legacy,caption,grid,continuation);main.append(section); - const cards=new Map();let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1,restoreFrame=0; + (main.querySelector('.workspace-heading-copy') || section).append(introduction); + continuation.append(message,retry,more);section.append(toolbar,optionsStatus,caption,grid,continuation,legacy);main.append(section); + const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); + let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; - function removeCard(card) {card.blocks.dispose();card.node.remove();} + const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; + let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); + let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY; + let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null; + if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height); + function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} function cardFor(item,index) { const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true'); @@ -65,38 +72,106 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`)); const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip')); content.append(heading,prompts,stats,vocabulary); - const footer=el('footer',undefined,'library-card-footer');footer.append(el('span',item.rating?`${item.rating} / 5`:'Unrated','muted')); - const history=link('',`#/library/${item.id}`,'library-history-link');history.append(branchIcon(),el('span','Explore history'));footer.append(history); + const footer=el('footer',undefined,'library-card-footer'); + const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating); + const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history); article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks}; } - function renderWindow() { - if(!state || scope.signal.aborted) return; - const active=document.activeElement?.closest('article[data-prompt-id]'); - const focused=focusOverride>=0?focusOverride:active?Number(active.dataset.index):-1; - const size=columns();const top=Math.max(0,-grid.getBoundingClientRect().top); - const indexes=libraryWindow(state.items.length,size,top,window.innerHeight,focused);const keep=new Set(indexes.map(index=>state.items[index].id)); - for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);} - const children=[];let previousRow=-1; - for(const index of indexes) { - const row=Math.floor(index/size); - if(index%size===0 && row>previousRow+1) { - const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${(row-previousRow-1)*578-18}px`;children.push(gap); - } - let card=cards.get(state.items[index].id); - if(!card) {card=cardFor(state.items[index],index);cards.set(state.items[index].id,card);} - card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total);children.push(card.node);previousRow=row; + function captureAnchor() { + // Mounted rows can already differ from estimates before their observer batch. + for(const row of grid.children) { + if(!row.classList.contains('library-row'))continue; + const bounds=row.getBoundingClientRect(); + if(bounds.bottom>0 && bounds.top0) {const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${remaining*578-18}px`;children.push(gap);} - // Remove obsolete spacers first: moving a focused card past an old spacer loses focus. - for(const child of [...grid.children]) if(!children.includes(child)) child.remove(); - let previous=null; - for(const child of children) {if(previous?previous.nextElementSibling!==child:grid.firstElementChild!==child) grid.insertBefore(child,previous?previous.nextElementSibling:grid.firstChild);previous=child;} + return null; } - const schedule=()=>{if(!frame) frame=requestAnimationFrame(()=>{frame=0;renderWindow();});}; + function reconcileScroll() { + const delta=window.scrollY-observedScrollY;observedScrollY=window.scrollY; + if(!delta)return; + // Keep pending height compensation while honoring movement since it was queued. + if(pendingAnchor)pendingAnchor={...pendingAnchor,offset:pendingAnchor.offset-delta}; + else anchor=captureAnchor(); + } + function spacer(key,height) { + let node=spacers.get(key); + if(!node) {node=el('div',undefined,'library-row-spacer');node.setAttribute('aria-hidden','true');spacers.set(key,node);} + const size=`${height}px`;if(node.style.blockSize!==size)node.style.blockSize=size; + return node; + } + function renderWindow(topOverride) { + if(!state || scope.signal.aborted) return; + const active=document.activeElement, activeCard=active?.closest('article[data-prompt-id]'); + const focused=focusOverride>=0?focusOverride:activeCard?Number(activeCard.dataset.index):-1; + const top=topOverride??Math.max(0,-grid.getBoundingClientRect().top); + const visible=state.items.length<=120?layout.rows.map((_,index)=>index):visibleLibraryRows(layout.rows,top,window.innerHeight,focused); + const keep=new Set(), children=[];let previous=-1; + for(const rowIndex of visible) { + const row=layout.rows[rowIndex]; + if(rowIndex>previous+1) { + const end=previous<0?0:layout.rows[previous].top+layout.rows[previous].height+18; + children.push(spacer(`${previous}:${rowIndex}`,row.top-end-18)); + } + let rowNode=rowNodes.get(rowIndex); + if(!rowNode) {rowNode=el('div',undefined,'library-row');rowNode.setAttribute('role','presentation');rowNodes.set(rowIndex,rowNode);} + if(!rowNode.isConnected)grid.append(rowNode); + for(let index=row.first;index<=row.last;index++) { + const item=state.items[index];keep.add(item.id);let card=cards.get(item.id); + if(!card) {card=cardFor(item,index);cards.set(item.id,card);rowNode.append(card.node);measurementObserver?.observe(card.node);} + else if(card.node.parentElement!==rowNode)rowNode.append(card.node); + card.node.dataset.index=index;card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total); + } + children.push(rowNode);previous=rowIndex; + } + for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);} + if(previous>=0 && previous.5) {heights.set(id,measurement.height);dirty=true;} + } + pendingMeasurements.clear(); + const restore=dirty?(pendingAnchor || anchor):null; + if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;} + const index=restore?indexesById.get(restore.id):undefined; + const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset); + renderWindow(target); + const card=index===undefined?null:cards.get(restore.id); + if(card) { + const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5)window.scrollBy(0,delta); + anchor=restore; + } else { + if(restoreScrollY!==null)window.scrollTo(0,restoreScrollY); + anchor=captureAnchor(); + } + // Read the actual clamped result now; its later scroll event is not new motion. + observedScrollY=window.scrollY; + restoreScrollY=null;pendingAnchor=null;scheduleLoad(); + } + const schedule=()=>{if(!frame)frame=requestAnimationFrame(flush);}; + const measurementObserver=typeof ResizeObserver==='function'?new ResizeObserver(entries=>{ + const width=readInlineSize();let changed=width!==inlineSize || columns()!==columnCount; + for(const entry of entries) { + const id=Number(entry.target.dataset.promptId);if(!cards.has(id) || cards.get(id).node!==entry.target)continue; + const height=entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height; + if(Number.isFinite(height) && height>0 && (width!==inlineSize || Math.abs((pendingMeasurements.get(id)?.height??heights.get(id)??0)-height)>.5)) {pendingMeasurements.set(id,{height,width});changed=true;} + } + if(changed) {pendingAnchor ||= anchor;schedule();} + }):null; + measurementObserver?.observe(grid); function focusCard(index,last=false) { if(index<0 || index>=state.items.length) return; - focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; + if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow(); } scope.listen(grid,'keydown',event=>{ @@ -110,18 +185,26 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } }); function render(next) { + if(items!==next.items) { + pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true; + if(!items.length) { + anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0}; + for(const card of cards.values())removeCard(card); + cards.clear();rowNodes.clear();spacers.clear();grid.replaceChildren(); + } + } state=next; caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':''; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); - renderWindow();scheduleLoad(); + schedule();scheduleLoad(); } const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined}); function maybeLoad() { if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return; const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore(); } - function scheduleLoad() {if(!loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});} + function scheduleLoad() {if(observer && !loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});} if(typeof IntersectionObserver==='function') {observer=new IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting)) maybeLoad();},{rootMargin:'250px'});observer.observe(continuation);} async function loadCategories() { optionsRequest?.abort();const request=optionsRequest=new AbortController(); @@ -130,14 +213,16 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true; } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}} } - const reset=()=>stream.reset(filters); + const reset=()=>{restoreScrollY=null;return stream.reset(filters);}; scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200)); scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); - scope.listen(window,'scroll',schedule);scope.listen(window,'resize',schedule); - scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);cancelAnimationFrame(restoreFrame);for(const card of cards.values()) removeCard(card);cards.clear();}); - loadCategories();if(visit) {render(stream.state);restoreFrame=requestAnimationFrame(()=>{if(!scope.signal.aborted) {window.scrollTo(0,visit.scrollY);renderWindow();scheduleLoad();}});if(visit.state.loading || visit.state.error) stream.loadMore();} else reset(); + scope.listen(window,'scroll',()=>{reconcileScroll();schedule();}); + // Mobile viewport resizing can adjust scroll before delivering its resize event. + scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();}); + scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset(); } function mountHistory() { diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs index a321abb..0fe3a84 100644 --- a/web/js/workspace/prompt-inspector.mjs +++ b/web/js/workspace/prompt-inspector.mjs @@ -1,5 +1,6 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; import { createSendButton } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; export function inspectPath(image) { return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?root=${image.root_index}`; @@ -100,7 +101,9 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role const record = state.prompts?.[role]; const block = el('section', undefined, `prompt-block prompt-${role}`); block.setAttribute('aria-label', title); - block.append(el('h3', title)); + const heading = el('div', undefined, 'prompt-block-heading'); + heading.append(el('h3', title)); + block.append(heading); if (Object.hasOwn(roles, role)) { block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted')); if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); @@ -112,11 +115,15 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role const actions = el('div', undefined, 'prompt-actions'); const send = createSendButton(`Send ${role} prompt`); send.dataset.sendRole = role; - const copy = createButton(`Copy ${role} prompt`); + const copy = createButton('', 'ca-icon-button'); + copy.setAttribute('aria-label', `Copy ${role} prompt`); + copy.title = `Copy ${role} prompt`; + copy.append(createWorkspaceIcon('copy')); copy.dataset.copyRole = role; - actions.append(send, copy); + actions.append(copy, send); + heading.append(actions); const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); - block.append(actions, status); + block.append(status); } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); container.append(block); } diff --git a/web/js/workspace/style.css b/web/js/workspace/style.css index d0a7b23..3297591 100644 --- a/web/js/workspace/style.css +++ b/web/js/workspace/style.css @@ -2,30 +2,42 @@ * { box-sizing: border-box; } body.ca-tools { margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124; - background: var(--workspace-page); font-size: 14px; + background: var(--workspace-page); --workspace-active: #14364d; + font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; } -body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; } +body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; --workspace-active: #e4f1fb; } [hidden] { display: none !important; } a { color: var(--ca-focus); text-decoration: none; } a:hover { text-decoration: underline; } a:focus-visible, main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } -.workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100dvh; } -.sidebar { background: var(--workspace-sidebar); border-right: 1px solid var(--ca-line); padding: 28px 18px; display: flex; flex-direction: column; gap: 24px; } -.brand { display: flex; gap: 12px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 16px; } -.brand small { display: block; color: var(--ca-muted); font-size: 11px; font-weight: 400; margin-top: 2px; } -.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--ca-accent); color: white; } +.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-block-size: 100dvh; } +.sidebar { background: var(--workspace-sidebar); border-inline-end: 1px solid var(--ca-line); padding: 26px 14px 16px; display: flex; flex-direction: column; gap: 5px; position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; overflow-y: auto; } +.brand { display: flex; gap: 11px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 14px; margin: 0 4px 28px; line-height: 1.3; } +.brand small { display: block; color: var(--ca-muted); font-size: 9px; letter-spacing: 1.8px; font-weight: 400; margin-block-start: 6px; } +.brand-mark { flex: 0 0 34px; inline-size: 34px; block-size: 36px; color: var(--ca-focus); font-size: 29px; position: relative; } +.brand-mark > span { font-size: 15px; position: absolute; inset-block-end: -2px; inset-inline-end: -2px; } .eyebrow { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--ca-muted); margin: 0; } -nav { display: grid; gap: 6px; } -nav a { color: var(--ca-muted); padding: 11px 14px; border-radius: 7px; min-height: 44px; } -nav a:hover { background: var(--ca-hover); color: var(--ca-text); text-decoration: none; } -nav a[aria-current="page"] { color: #fff; background: var(--ca-accent); } -.sidebar-footer { margin-top: auto; color: var(--ca-muted); font-size: 11px; display: flex; align-items: center; gap: 8px; } -.connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ca-muted); } -.workspace-body { min-width: 0; display: flex; flex-direction: column; } -.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 32px; border-bottom: 1px solid var(--ca-line); font-size: 12px; } +.sidebar-label { margin: 14px 12px 6px; font-size: 9px; font-weight: 650; letter-spacing: 1.1px; color: var(--ca-muted); } +nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; gap: 5px; } +.workspace-nav-primary, .workspace-nav-tools { display: grid; gap: 3px; } +.workspace-nav-tools { margin-block-start: auto; padding-block-start: 32px; } +nav [data-route] { display: flex; align-items: center; gap: 11px; color: var(--ca-muted); padding: 8px 12px; border-radius: 6px; min-block-size: 38px; font-size: 12px; overflow-wrap: anywhere; } +nav [data-route]:hover { background: var(--ca-field); color: var(--ca-text); text-decoration: none; } +nav [aria-current="page"] { color: var(--ca-focus); background: var(--workspace-active); } +.workspace-icon { display: block; inline-size: 17px; block-size: 17px; flex: 0 0 auto; } +.sidebar-footer { margin: 17px 6px 0; padding-block-start: 17px; border-block-start: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 10px; display: flex; align-items: center; gap: 8px; } +.connection-dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; } +.workspace-body { min-inline-size: 0; display: flex; flex-direction: column; } +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; block-size: 68px; padding-inline: 30px; border-block-end: 1px solid var(--ca-line); font-size: 12px; } +#appearance-control { min-inline-size: 0; max-inline-size: 100%; } +.ca-tools .workspace-appearance { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; min-inline-size: 0; max-inline-size: 100%; font-size: 11px; } +.ca-tools .workspace-appearance select { min-inline-size: 0; max-inline-size: 100%; font-size: 11px; } .muted { color: var(--ca-muted); } -main { flex: 1; padding: 36px; width: 100%; max-width: 1500px; margin: 0 auto; } -h1 { font-size: clamp(25px, 4vw, 34px); line-height: 1.3; letter-spacing: -1px; margin: 10px 0; font-weight: 620; } +main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-inline-size: 0; margin-inline: auto; } +h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; } +.workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-block-end: 22px; } +.workspace-heading-copy { min-inline-size: 0; } +.workspace-heading-copy .intro { font-size: 12px; line-height: 1.5; margin: 6px 0 0; } h2 { font-size: 18px; margin: 0 0 8px; } h3 { font-size: 15px; margin: 0; } p { line-height: 1.6; } @@ -45,19 +57,37 @@ p { line-height: 1.6; } .page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 11px; } .skip { position: fixed; top: 10px; left: 10px; z-index: 10; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; } .skip:focus { clip-path: none; width: auto; height: auto; padding: 12px; background: var(--ca-surface); } -@media (max-width: 780px) { +@media (max-width: 1250px) { + .workspace-shell { grid-template-columns: 185px minmax(0, 1fr); } + .sidebar { padding-inline: 9px; } + .brand { font-size: 12px; } + .topbar { padding-inline: 24px; } + main { padding: 24px; } +} +@media (max-width: 950px) { .workspace-shell { grid-template-columns: minmax(0, 1fr); } - .sidebar { padding: 18px; gap: 16px; border-right: 0; border-bottom: 1px solid var(--ca-line); } - nav { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; } - nav a { padding: 10px 6px; font-size: 12px; text-align: center; } - .sidebar > .eyebrow, .sidebar-footer { display: none; } - .topbar { padding: 14px 18px; flex-wrap: wrap; } - main { padding: 26px 18px; } + .sidebar { position: static; block-size: auto; padding: 12px 18px; border-inline-end: 0; border-block-end: 1px solid var(--ca-line); overflow: visible; } + .brand { margin: 0 0 12px; } + .sidebar-label, .sidebar-footer { display: none; } + .workspace-nav-primary, .workspace-nav-tools { display: flex; flex-wrap: wrap; gap: 4px; } + .workspace-nav-tools { margin: 0; padding-block-start: 5px; border-block-start: 1px solid var(--ca-line); } + nav [data-route] { min-block-size: 32px; padding: 5px 10px; } + .topbar { block-size: auto; min-block-size: 58px; padding: 10px 24px; flex-wrap: wrap; } +} +@media (max-width: 700px) { + main { padding: 20px 14px; } + .topbar { padding-inline: 14px; } + .topbar > span { overflow-wrap: anywhere; } + .workspace-heading { align-items: start; flex-direction: column; } + h1 { font-size: 25px; } + nav [data-route] { font-size: 11px; gap: 6px; } + nav .workspace-icon { inline-size: 14px; block-size: 14px; } + .brand { font-size: 14px; } .stat-grid { gap: 8px; } .stat { padding: 16px; } .stat dd { font-size: 26px; } .panel { padding: 18px; } - .page-footer { margin: 0 18px; } + .page-footer { margin: 0 14px; flex-wrap: wrap; } } @media (forced-colors: active) { nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; } diff --git a/web/workspace.html b/web/workspace.html index 3fe3de1..0057c5a 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -17,8 +17,7 @@