Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a37ac4fde | ||
|
|
98b7365cb2 | ||
|
|
199ff05f8c | ||
|
|
5f8acc7963 | ||
|
|
5548419f0d | ||
|
|
0db03f55ad |
@@ -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
|
||||
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
+22
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.top<innerHeight;});
|
||||
return card?{id:card.dataset.promptId,offset:card.getBoundingClientRect().top}:null;
|
||||
});
|
||||
}
|
||||
async function retainedParent(page, anchor) {
|
||||
const card=page.locator(`article[data-prompt-id="${anchor.id}"]`);
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect.poll(async()=>Math.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<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
|
||||
}
|
||||
});
|
||||
|
||||
test('row measurement preserves anchor after width invalidation and append',async({page,request})=>{
|
||||
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();}
|
||||
});
|
||||
|
||||
@@ -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<boxes.length;index++) for (const other of boxes.slice(index+1)) {
|
||||
const box=boxes[index];
|
||||
expect(box.right<=other.x+1||other.right<=box.x+1||box.bottom<=other.y+1||other.bottom<=box.y+1).toBe(true);
|
||||
}
|
||||
await noOverflow(page);
|
||||
}
|
||||
|
||||
test('Images visual layouts retain real delayed media long filenames and loaded scroll',async({page,request},testInfo)=>{
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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:()=>{}});
|
||||
|
||||
+70
@@ -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');
|
||||
});
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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' } },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+64
-45
@@ -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); }
|
||||
|
||||
+71
-17
@@ -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;
|
||||
|
||||
+26
-10
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
+128
-43
@@ -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.top<window.innerHeight)return {id:Number(row.firstElementChild.dataset.promptId),offset:bounds.top};
|
||||
}
|
||||
const remaining=Math.ceil(state.items.length/size)-previousRow-1;
|
||||
if(remaining>0) {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<layout.rows.length-1) {const end=layout.rows[previous].top+layout.rows[previous].height;children.push(spacer(`${previous}:end`,layout.height-end-18));}
|
||||
for(const child of [...grid.children]) if(!children.includes(child)) child.remove();
|
||||
let sibling=null;
|
||||
for(const child of children) {if(sibling?sibling.nextElementSibling!==child:grid.firstElementChild!==child)grid.insertBefore(child,sibling?sibling.nextElementSibling:grid.firstChild);sibling=child;}
|
||||
for(const [key,node] of rowNodes)if(!node.isConnected)rowNodes.delete(key);
|
||||
for(const [key,node] of spacers)if(!node.isConnected)spacers.delete(key);
|
||||
if(activeCard && active.isConnected && document.activeElement!==active)active.focus({preventScroll:true});
|
||||
}
|
||||
function flush() {
|
||||
frame=0;if(scope.signal.aborted)return;
|
||||
reconcileScroll();
|
||||
const width=readInlineSize(), size=columns();
|
||||
if(width!==inlineSize || size!==columnCount) {pendingAnchor ||= anchor;inlineSize=width;columnCount=size;heights.clear();dirty=true;}
|
||||
for(const [id,measurement] of pendingMeasurements) {
|
||||
if(measurement.width===inlineSize && indexesById.has(id) && Math.abs((heights.get(id)??0)-measurement.height)>.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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+55
-25
@@ -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; }
|
||||
|
||||
+1
-2
@@ -17,8 +17,7 @@
|
||||
<a class="skip" href="#workspace-main">Skip to content</a>
|
||||
<div class="workspace-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P<small>±</small></span><span>Prompt Manager<small>ComfyAssets</small></span></a>
|
||||
<p class="eyebrow">Your workspace</p>
|
||||
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<nav aria-label="Workspace"></nav>
|
||||
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user