style(workspace): match approved image browsing geometry

This commit is contained in:
Vito Sansevero
2026-10-17 23:53:16 -07:00
parent 5548419f0d
commit 5f8acc7963
8 changed files with 338 additions and 68 deletions
+20 -1
View File
@@ -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.');
});
+77 -1
View File
@@ -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);
}
});
+75 -4
View File
@@ -17,17 +17,19 @@ async function groupedNavigation(page) {
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
}
}
async function attachEvidence(page, testInfo, name) {
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: true });
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'].map(selector => {
const node = document.querySelector(selector), box = node.getBoundingClientRect(), style = getComputedStyle(node);
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'].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`);
@@ -169,3 +171,72 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
}
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);
}
});
+42 -1
View File
@@ -75,6 +75,47 @@ 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));
});
+15 -6
View File
@@ -1,14 +1,23 @@
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') {
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;
return {columns, gap:13, 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;
+59 -39
View File
@@ -1,30 +1,41 @@
.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; }
.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) 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="grid"] .image-open { aspect-ratio: 4 / 3; }
.image-card-footer { position: relative; min-inline-size: 0; 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: 2px; 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: 160px 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; }
.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-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; }
@@ -38,32 +49,41 @@
.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; }
@media (max-width: 1250px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; }
}
@media (max-width: 780px) {
@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; }
.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-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; }
}
.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); }
+39 -12
View File
@@ -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;
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;
@@ -270,7 +292,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))); }
@@ -288,7 +310,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!state || scope.signal.aborted) return;
grid.dataset.layout = layout;
cancelAnimationFrame(frame); frame = 0;
geometry = layoutImages(state.items, grid.clientWidth, layout);
metrics = workspaceImageMetrics(window.innerWidth, density);
grid.dataset.density = density;
geometry = layoutImages(state.items, grid.clientWidth, layout, 240, metrics);
grid.style.height = `${geometry.height}px`;
renderVisible();
}
@@ -304,6 +328,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;
+11 -4
View File
@@ -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);
}