Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
075930f439 | ||
|
|
12aa1e51d6 | ||
|
|
f09ceb996e | ||
|
|
64f14a3e59 | ||
|
|
69d201d684 | ||
|
|
cc271838c9 | ||
|
|
720231f7ba | ||
|
|
383b724369 | ||
|
|
020a8df1e0 | ||
|
|
c858329f7d | ||
|
|
3f34ef34ac | ||
|
|
01817d9712 | ||
|
|
9ec23f8615 | ||
|
|
c75223f0ea | ||
|
|
a44cd27c2e | ||
|
|
673433b2ae |
+87
@@ -305,6 +305,93 @@ Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
|
||||
### Concept shell and comparison method (#249)
|
||||
|
||||
**CSS base and drift check.** `web/js/workspace/concept.css` is a byte-identical copy
|
||||
of the approved concept's `docs/concepts/workspace/style.css`. Never edit the copy;
|
||||
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
|
||||
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
|
||||
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
|
||||
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
|
||||
PRs retire, and the production overlay `workspace.css` last.
|
||||
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
|
||||
other page or canvas script loads workspace CSS.
|
||||
|
||||
**Overlay decision-marker lint.** `lintOverlay` in `tools/vendor/sync-concept-css.mjs`
|
||||
fails any `workspace.css` rule whose selector is also a concept selector unless the
|
||||
rule carries a `/* D<n> */` comment, before its selector or inside its block. The
|
||||
D-number must be one of this spec's (#249): a comment that cites any other issue, such
|
||||
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
|
||||
for those. The lint test fails if `workspace.css` is missing. The match is by exact
|
||||
selector, so a narrower selector over a concept rule (for example
|
||||
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
|
||||
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
|
||||
convention.
|
||||
|
||||
The lint covers `workspace.css` only. The page stylesheets that load before it
|
||||
(`images.css`, `library.css`, `inspector.css`) are not linted yet, and `images.css`
|
||||
still has unmarked concept-selector overrides (`.image-grid`, `.prompt-text`,
|
||||
`.prompt-actions`, `.ca-tools .prompt-actions button`). PRs C and D retire those
|
||||
sheets: each one runs `lintOverlay` over every page sheet it keeps, and adds that
|
||||
sheet to the real-file lint test.
|
||||
|
||||
**Comparison method.** Production must equal the concept except for the recorded
|
||||
deviations in the #249 design (spec §8, D-numbers). It is never compared against
|
||||
blessed production screenshots.
|
||||
- The fixture is `tests/browser/workspace_concept_fixture.py`, reset through
|
||||
`POST /__test__/concept/reset`. It holds the concept's six prompts and pairs, and
|
||||
24 PNGs with real links, runs and the concept's inspector facts. The sidebar counts
|
||||
are therefore the concept's own: 24, 6 and 15.
|
||||
- The shared inventory is `tests/browser/concept-inventory.mjs`. One self-contained
|
||||
in-page measurer runs the same concept selectors in the immutable concept
|
||||
(`/__test__/canonical/workspace`) and in production. An absent element becomes
|
||||
`null`, so the diff names its path (for example `chrome.breadcrumbs`) instead of
|
||||
failing the measurement.
|
||||
- Each page's `map<Page>Concept(reference)` applies only the recorded deviations, each
|
||||
commented with its D-number. `diffInventory` must then report nothing.
|
||||
- Sizes, offsets and heights that follow from a removed element are derived from the
|
||||
concept's own measurements, never copied from production.
|
||||
- `mutate(page, {css, remove})` appends a stylesheet and detaches elements, and returns
|
||||
an undo. Each comparison has a mutation check proving it fails. For the shell, a
|
||||
`.sidebar nav a { min-height: 30px }` rule must fail at `chrome.navs.0.rows.0.minHeight`
|
||||
and `.height`, and removing `.breadcrumbs` must fail at `chrome.breadcrumbs`.
|
||||
- Paired concept/production captures are attached as test artifacts. There are no PNG
|
||||
baselines.
|
||||
|
||||
`tests/browser/shell-concept.spec.mjs` compares the shell: the sidebar, topbar, page
|
||||
heading and footer, at 1600x1100 and 390x844, in dark and light, under normal and
|
||||
reduced motion, on Images, Prompt library and Tags. The heading is compared in full on
|
||||
Tags; on Images and Library only its frame and h1 are compared, because their copy and
|
||||
actions belong to their own PRs. The spec also compares the hover state of the first
|
||||
non-current nav row, the Theme select and the ComfyUI link, and the keyboard rings of
|
||||
the first nav link and heading button.
|
||||
|
||||
Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor check
|
||||
npm test --prefix tools/vendor
|
||||
npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Prompt sending and cross-pack compatibility
|
||||
|
||||
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Shared concept-vs-production inventory for the mock-true rebuild (#249, spec §7). One in-page measurer runs the
|
||||
// same concept selectors in the immutable concept (/__test__/canonical/workspace) and in production, which now renders
|
||||
// the concept's classes. The concept is never rewritten: each page's map<Page>Concept(reference) applies only the
|
||||
// recorded spec-§8 deviations, each commented with its D-number, and diffInventory must then report nothing.
|
||||
import {expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
export {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export const CONCEPT_URL='/__test__/canonical/workspace';
|
||||
export const PRODUCTION_URL='/prompt_manager/workspace';
|
||||
export const SIZES=Object.freeze([[1600,1100],[390,844]]);
|
||||
export const THEMES=Object.freeze(['dark','light']);
|
||||
export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||||
|
||||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||||
export async function seedConcept(request,data={}) {
|
||||
const response=await request.post('/__test__/concept/reset',{data});
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
|
||||
export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
export async function focusRingOf(locator) {
|
||||
return locator.evaluate(node=>{const style=getComputedStyle(node);return {focused:document.activeElement===node,
|
||||
visible:node.matches(':focus-visible'),outlineStyle:style.outlineStyle,outlineWidth:style.outlineWidth,
|
||||
outlineColor:style.outlineColor,outlineOffset:style.outlineOffset};});
|
||||
}
|
||||
|
||||
/** Computed colors of an element while the pointer rests on it; the pointer is parked again afterwards. */
|
||||
export async function hoverStateOf(page,locator) {
|
||||
await locator.hover();
|
||||
const state=await locator.evaluate(node=>{const style=getComputedStyle(node);return {hovered:node.matches(':hover'),
|
||||
color:style.color,backgroundColor:style.backgroundColor,borderTopColor:style.borderTopColor,
|
||||
textDecorationLine:style.textDecorationLine,boxShadow:style.boxShadow};});
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as
|
||||
* the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector
|
||||
* is detached. Returns an async undo that restores the stylesheet and the removed elements in place.
|
||||
*/
|
||||
export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
const token=await page.evaluate(({css,remove})=>{
|
||||
const token=`mutation-${Math.random().toString(36).slice(2)}`;
|
||||
const undo=[];
|
||||
if(css) {
|
||||
const style=document.createElement('style');style.dataset.mutation=token;style.textContent=css;
|
||||
document.head.append(style);undo.push(()=>style.remove());
|
||||
}
|
||||
for(const selector of remove) {
|
||||
const nodes=[...document.querySelectorAll(selector)];
|
||||
if(!nodes.length)throw new Error(`mutate: nothing matches ${selector}`);
|
||||
for(const node of nodes) {
|
||||
const parent=node.parentNode,next=node.nextSibling;
|
||||
node.remove();undo.push(()=>parent.insertBefore(node,next));
|
||||
}
|
||||
}
|
||||
(window.__conceptMutations??=new Map()).set(token,undo);
|
||||
return token;
|
||||
},{css,remove});
|
||||
return ()=>page.evaluate(token=>{
|
||||
const undo=window.__conceptMutations?.get(token);
|
||||
if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded');
|
||||
undo.reverse().forEach(step=>step());window.__conceptMutations.delete(token);
|
||||
},token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history,
|
||||
* noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet
|
||||
* (toolbar … history, PRs C–F) are null until that PR adds its measurer here.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. `scope` selects the page's main element. */
|
||||
export function measureInventory(scope) {
|
||||
const px=value=>Math.round(value);
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderRightStyle','borderRightColor',
|
||||
'borderBottomWidth','borderBottomStyle','borderBottomColor','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap','position');
|
||||
// Text of a node without its descendants matching `skip` (counts, nested controls), whitespace collapsed.
|
||||
const textOf=(node,skip)=>{const copy=node.cloneNode(true);if(skip)copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const main=document.querySelector(scope);
|
||||
const sidebar=document.querySelector('aside.sidebar');
|
||||
const workspace=document.querySelector('.workspace');
|
||||
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
const box=rect(svg);
|
||||
return {width:px(box.width),height:px(box.height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color','verticalAlign','flexShrink')};
|
||||
}
|
||||
function navRow(link,index,links) {
|
||||
const box=rect(link),count=link.querySelector('.nav-count'),icon=link.querySelector(':scope > svg.icon');
|
||||
const style=getComputedStyle(link);
|
||||
const content=(icon?rect(icon).width+parseFloat(style.columnGap||0):0)+(()=>{
|
||||
const range=document.createRange();const text=[...link.childNodes].find(item=>item.nodeType===Node.TEXT_NODE&&item.textContent.trim());
|
||||
if(!text)return 0;range.selectNodeContents(text);return range.getBoundingClientRect().width;})();
|
||||
const next=links[index+1];let gapAfter=null;
|
||||
if(next){const other=rect(next);gapAfter=px(other.top)===px(box.top)?{axis:'x',size:px(other.left-box.right)}:{axis:'y',size:px(other.top-box.bottom)};}
|
||||
return {text:textOf(link,'.nav-count'),current:link.getAttribute('aria-current'),
|
||||
...layout(link),...edges(link),...font(link),minHeight:style.minHeight,height:px(box.height),
|
||||
fullWidth:px(box.width)===px(rect(link.parentElement).width),
|
||||
hugs:Math.abs(box.width-content-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight))<=1.5,
|
||||
gapAfter,icon:iconOf(link),
|
||||
count:count?{text:count.textContent,shown:shown(count),...font(count),...css(count,'marginInlineStart')}:null};
|
||||
}
|
||||
// Every subsection is null when its element is missing, so a removed node is reported by its path
|
||||
// (for example `chrome.breadcrumbs`) instead of failing the measurement.
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// The accessible text of a node: its text without aria-hidden descendants.
|
||||
const accessibleText=node=>textOf(node,'[aria-hidden="true"]');
|
||||
function itemOf(node,actions) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:node.className,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const box=relative(node,actions);
|
||||
return {...base,text:textOf(node,'select, .count'),...font(node),...edges(node),...layout(node),
|
||||
left:box.left,top:box.top,width:box.width,height:box.height,
|
||||
count:node.querySelector('.count')?.textContent??null,
|
||||
select:when(node.querySelector('select'),select=>({options:[...select.options].map(option=>[option.value,option.textContent]),
|
||||
label:select.getAttribute('aria-label'),labelled:select.labels?.[0]===node,...font(select),...edges(select),
|
||||
minHeight:getComputedStyle(select).minHeight,width:px(rect(select).width),height:px(rect(select).height)})),
|
||||
hiddenSuffix:[...node.querySelectorAll('[aria-hidden="true"]')].map(child=>child.textContent).join('')||null,
|
||||
whiteSpace:getComputedStyle(node).whiteSpace};
|
||||
}
|
||||
function sidebarOf() {
|
||||
return when(sidebar,()=>({...layout(sidebar),...edges(sidebar),
|
||||
...css(sidebar,'top','overflowY','borderInlineEndWidth'),width:px(rect(sidebar).width),
|
||||
fillsViewport:Math.abs(rect(sidebar).height-innerHeight)<=1}));
|
||||
}
|
||||
function bottomOf(bottom) {
|
||||
// In the column sidebar the bottom group is pinned to the end by `margin-top: auto`, whose used value is only
|
||||
// the free space above it, so the pin (its inset from the sidebar's padding edge) is recorded instead.
|
||||
const sidebarStyle=getComputedStyle(sidebar);
|
||||
const pinned=sidebarStyle.display==='flex';
|
||||
const {marginTop,...rest}=edges(bottom);
|
||||
return {...rest,...css(bottom,'display'),marginTop:pinned?'auto':marginTop,
|
||||
bottomInset:pinned?px(rect(sidebar).bottom-rect(bottom).bottom-parseFloat(sidebarStyle.paddingBottom)):null};
|
||||
}
|
||||
function topActionsOf(actions,topbar) {
|
||||
const style=getComputedStyle(topbar);
|
||||
return {...layout(actions),...edges(actions),width:px(rect(actions).width),height:px(rect(actions).height),
|
||||
fullWidth:Math.abs(rect(actions).width-(rect(topbar).width-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)))<=1,
|
||||
rightInset:px(rect(topbar).right-rect(actions).right),
|
||||
items:[...actions.children].map(node=>itemOf(node,actions))};
|
||||
}
|
||||
function chromeOf() {
|
||||
if(!sidebar&&!workspace)return null;
|
||||
const shell=document.querySelector('.app-shell');
|
||||
const brand=sidebar?.querySelector('.brand');
|
||||
const topbar=workspace?.querySelector('.topbar');
|
||||
const footer=workspace?.querySelector('.app-footer');
|
||||
return {
|
||||
title:document.title,
|
||||
skip:when(document.querySelector('a.skip'),skip=>({text:skip.textContent.trim(),...css(skip,'position','top','left')})),
|
||||
body:{...font(document.body),backgroundColor:getComputedStyle(document.body).backgroundColor,
|
||||
tokens:Object.fromEntries(['--ca-text','--ca-muted','--ca-focus','--ca-accent','--ca-field','--ca-surface','--ca-line',
|
||||
'--line','--page','--sidebar','--soft-blue','--green'].map(name=>[name,getComputedStyle(document.body).getPropertyValue(name).trim()]))},
|
||||
shell:when(shell,()=>{const columns=getComputedStyle(shell).gridTemplateColumns.split(' ');
|
||||
return {display:getComputedStyle(shell).display,columns:columns.length,sidebarColumn:columns.length>1?columns[0]:null,
|
||||
minHeight:getComputedStyle(shell).minHeight};}),
|
||||
sidebar:sidebarOf(),
|
||||
brand:when(brand,()=>({text:textOf(brand),name:accessibleText(brand),href:brand.getAttribute('href')?.replace(/^#\/?/,''),
|
||||
...relative(brand,sidebar),...font(brand),...layout(brand),...edges(brand),
|
||||
mark:when(brand.querySelector('.brand-mark'),mark=>({text:textOf(mark),hidden:mark.getAttribute('aria-hidden'),...font(mark),
|
||||
width:px(rect(mark).width),height:px(rect(mark).height)})),
|
||||
small:when(brand.querySelector('small'),small=>({text:small.textContent,...font(small),...css(small,'display','marginTop')}))})),
|
||||
labels:sidebar?[...sidebar.querySelectorAll('.sidebar-label')].map(node=>({text:textOf(node),shown:shown(node),...font(node),...layout(node),...edges(node)})):null,
|
||||
navs:sidebar?[...sidebar.querySelectorAll('nav')].map(nav=>{const links=[...nav.querySelectorAll(':scope > a')];return {
|
||||
id:nav.id,label:nav.getAttribute('aria-label'),shown:shown(nav),...layout(nav),...edges(nav),
|
||||
width:px(rect(nav).width),rows:links.map(navRow)};}):null,
|
||||
bottom:when(sidebar?.querySelector('.sidebar-bottom'),bottomOf),
|
||||
connection:when(sidebar?.querySelector('.connection'),connection=>({shown:shown(connection),text:textOf(connection,'small'),
|
||||
small:connection.querySelector('small')?.textContent??null,...layout(connection),...edges(connection),...font(connection),
|
||||
smallFont:when(connection.querySelector('small'),font),
|
||||
dot:when(connection.querySelector('i'),dot=>css(dot,'width','height','borderTopLeftRadius','backgroundColor','marginTop'))})),
|
||||
topbar:when(topbar,()=>({...layout(topbar),...edges(topbar),...css(topbar,'height','minHeight'),...relative(topbar,workspace),
|
||||
fullWidth:px(rect(topbar).width)===px(rect(workspace).width)})),
|
||||
breadcrumbs:when(topbar?.querySelector('.breadcrumbs'),crumbs=>({shown:shown(crumbs),text:textOf(crumbs),...layout(crumbs),...font(crumbs),
|
||||
parts:[...crumbs.querySelectorAll('span')].map(span=>({text:span.textContent,...font(span)})),
|
||||
...(shown(crumbs)?{left:relative(crumbs,topbar).left,middle:px(rect(crumbs).top+rect(crumbs).height/2-rect(topbar).top)}:{})})),
|
||||
topActions:when(topbar?.querySelector('.top-actions'),actions=>topActionsOf(actions,topbar)),
|
||||
main:when(main,()=>({tag:main.tagName.toLowerCase(),tabIndex:main.tabIndex,
|
||||
...css(main,'paddingTop','paddingRight','paddingBottom','paddingLeft','maxWidth','marginLeft','marginRight')})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...css(footer,'height'),width:px(rect(footer).width),
|
||||
fullWidth:px(rect(footer).width)===px(rect(workspace).width),
|
||||
spans:[...footer.querySelectorAll(':scope > span')].map(span=>({text:span.textContent.replace(/\s+/g,' ').trim(),...font(span),height:px(rect(span).height)})),
|
||||
buttons:[...footer.querySelectorAll('button')].map(button=>({text:button.textContent.trim(),...font(button),...edges(button),
|
||||
minHeight:getComputedStyle(button).minHeight,height:px(rect(button).height)}))})),
|
||||
};
|
||||
}
|
||||
function headingOf() {
|
||||
const heading=main?.querySelector(':scope > .heading');
|
||||
if(!heading)return null;
|
||||
const copy=heading.firstElementChild,h1=copy?.querySelector('h1'),description=copy?.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
return {...layout(heading),...edges(heading),height:px(rect(heading).height),
|
||||
copy:when(copy,()=>({tag:copy.tagName.toLowerCase(),...layout(copy)})),
|
||||
h1:when(h1,()=>({id:h1.id,tabIndex:h1.tabIndex,text:h1.textContent,...font(h1),...edges(h1)})),
|
||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null,
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
@@ -295,7 +295,8 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
|
||||
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
|
||||
let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;});
|
||||
await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1});
|
||||
// A grid page, not the sidebar's `limit=1` Images count.
|
||||
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1});
|
||||
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
|
||||
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
|
||||
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
|
||||
@@ -506,7 +507,7 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
}
|
||||
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
|
||||
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await capture(`inline-${label}`);
|
||||
@@ -576,7 +577,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
|
||||
await settleViewport(1600);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
|
||||
expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
|
||||
|
||||
@@ -18,7 +18,7 @@ test.beforeEach(async({page,request})=>{
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;
|
||||
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
|
||||
@@ -111,7 +111,7 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await chosen.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await page.evaluate(()=>scrollTo(0,0));
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light');
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
@@ -122,6 +122,38 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// D6: ComfyUI ↗ is a link to the canvas; on Images with a selected file whose two roles resolved, a plain click opens
|
||||
// the acknowledged pair send instead (concept app.js 1750-1757).
|
||||
test('ComfyUI ↗ opens the pair send for a selected resolved image, else follows the link',async({page,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto('/prompt_manager/workspace#/overview');
|
||||
const link=page.locator('header.topbar').getByRole('link',{name:'ComfyUI',exact:true});
|
||||
await expect(link).toHaveAttribute('href','/');
|
||||
// Record whether the link's own handler let the navigation through, then stop it so the page stays.
|
||||
await page.evaluate(()=>{window.followed=[];document.addEventListener('click',event=>{if(!event.target.closest?.('.canvas-link'))return;window.followed.push(!event.defaultPrevented);event.preventDefault();});});
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
await page.locator('nav [data-route="images"]').click();await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Cat portrait','blurry, watermark'])await expect(sender).toContainText(text);
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([]);
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(dialog(page)).toHaveCount(0);await expect(link).toBeFocused();
|
||||
// Another page has no image selection, so the link is a plain link again.
|
||||
await page.locator('nav [data-route="library"]').click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false,true]);
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,10 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
seed_workspace_concept,
|
||||
)
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
|
||||
from tests.browser.workspace_history_fixture import (
|
||||
@@ -379,6 +383,30 @@ def create_app(root: Path) -> web.Application:
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/concept/reset")
|
||||
async def concept_reset(request: web.Request) -> web.Response:
|
||||
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
|
||||
nonlocal history_fixture
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - {"extra_images"}
|
||||
or isinstance(data.get("extra_images", 0), bool)
|
||||
or not isinstance(data.get("extra_images", 0), int)
|
||||
or not 0 <= data.get("extra_images", 0) <= CONCEPT_MAX_EXTRA_IMAGES
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
api, root, extra_images=data.get("extra_images", 0)
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
|
||||
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
|
||||
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// Routes whose shell is compared. The concept and production share route names for these three pages.
|
||||
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
|
||||
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
|
||||
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
|
||||
const FULL_HEADING=new Set(['tags']);
|
||||
|
||||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||||
|
||||
const removedItem=item=>String(item.className).includes('concept-badge')||(item.tag==='button'&&item.text.startsWith('Activity'));
|
||||
|
||||
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
|
||||
function packedLefts(items,width,justify,gap) {
|
||||
const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1);
|
||||
let start=0,spacing=gap;
|
||||
if(justify==='flex-end'||justify==='end')start=width-total;
|
||||
else if(justify==='center')start=(width-total)/2;
|
||||
else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1);
|
||||
let x=start;
|
||||
return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;});
|
||||
}
|
||||
|
||||
/** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */
|
||||
function remainingTopActions(actions) {
|
||||
const kept=actions.items.filter(item=>!removedItem(item));
|
||||
const shown=kept.filter(item=>item.shown);
|
||||
const height=Math.max(...shown.map(item=>item.height));
|
||||
const gap=parseFloat(actions.columnGap);
|
||||
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
|
||||
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
|
||||
// At <=700px the concept spreads three items with `space-between`, so its Theme select sits in the middle; with
|
||||
// Activity removed (D5) the same rule puts Theme at the row's start. Offsets come from the concept's own sizes.
|
||||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||||
return {...actions,height,width,
|
||||
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
|
||||
}
|
||||
|
||||
/** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */
|
||||
function remainingFooter(footer,spans) {
|
||||
const content=footer.flexDirection==='column'
|
||||
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
|
||||
:Math.max(...spans.map(span=>span.height));
|
||||
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
|
||||
}
|
||||
|
||||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
// D5: no Activity row until an activity center exists. D28: no Design review (prototype page).
|
||||
system,
|
||||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||||
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
|
||||
]};
|
||||
});
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
small:'Your library, on your server',
|
||||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||||
// D29: no "Interactive concept" badge. D5: no Activity button. The row's size and the remaining items'
|
||||
// centered offsets follow from the concept's own item sizes.
|
||||
topActions:remainingTopActions(chrome.topActions),
|
||||
footer:(()=>{
|
||||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||||
// (a prototype review page); the footer then holds only its span.
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
function headingFrame(heading) {
|
||||
if(!heading)return heading;
|
||||
const {height,description,actions,...frame}=heading;
|
||||
const {text:copy,...descriptionTokens}=description??{};
|
||||
const {buttons,stacked,...actionTokens}=actions??{};
|
||||
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
|
||||
}
|
||||
|
||||
function compare(route,reference,actual) {
|
||||
const expected=mapShellConcept(reference);
|
||||
const heading=FULL_HEADING.has(route);
|
||||
return [
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||||
];
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertShellMatches(route,reference,actual) {
|
||||
const diff=compare(route,reference,actual);
|
||||
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
|
||||
async function ringsOf(page,mainSelector) {
|
||||
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
|
||||
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
|
||||
const button=page.locator(`${mainSelector} .heading-actions button`).first();
|
||||
await expect(button).toBeFocused();
|
||||
const headingButton=await focusRingOf(button);
|
||||
const link=page.locator('#primary-nav a').first();await link.focus();
|
||||
const navLink=await focusRingOf(link);
|
||||
await link.blur();
|
||||
return {navLink,headingButton};
|
||||
}
|
||||
|
||||
/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */
|
||||
async function hoversOf(page) {
|
||||
return {
|
||||
navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()),
|
||||
theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})),
|
||||
canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */
|
||||
async function captureFooter(page,testInfo,name) {
|
||||
await page.locator('.app-footer').scrollIntoViewIfNeeded();
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await captureCanonicalState(page,testInfo,name);
|
||||
}
|
||||
|
||||
test.beforeEach(async({request})=>{await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});const result={};
|
||||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
||||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
||||
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
||||
const referenceHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
||||
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
||||
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
||||
const actualHovers=await hoversOf(page);
|
||||
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
||||
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
||||
referenceHovers,actualHovers};
|
||||
}
|
||||
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
|
||||
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
||||
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
||||
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
||||
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
||||
expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`)
|
||||
.toEqual(['concept-badge','button','theme-label','canvas-link']);
|
||||
expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb);
|
||||
expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`);
|
||||
expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`);
|
||||
expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb);
|
||||
// The concept's own nav-row hover (style.css:183-186) is what production is held to.
|
||||
expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color});
|
||||
expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers);
|
||||
expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true);
|
||||
if(referenceRings) {
|
||||
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
|
||||
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
|
||||
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
|
||||
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const reference=mapShellConcept(await inventoryOf(page,'#main'));
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]);
|
||||
await page.locator('.breadcrumbs').evaluate(node=>node.remove());
|
||||
await page.locator('.connection small').evaluate(node=>node.remove());
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect(actual.chrome.breadcrumbs).toBeNull();
|
||||
expect(actual.chrome.connection.smallFont).toBeNull();
|
||||
const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path);
|
||||
expect(paths).toContain('breadcrumbs');
|
||||
expect(paths).toContain('connection.smallFont');
|
||||
expect(paths).toContain('connection.small');
|
||||
});
|
||||
|
||||
test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProduction(page,'images','dark');
|
||||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||||
const facts=page.locator('#workspace-main .generation-facts');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||||
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
|
||||
// and each must make the comparison fail with the path of what changed.
|
||||
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
const clean=await measure();
|
||||
assertShellMatches('tags',reference,clean);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
|
||||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
|
||||
await restoreCss();
|
||||
assertShellMatches('tags',reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.breadcrumbs']});
|
||||
const domMutated=await measure();
|
||||
expect(domMutated.chrome.breadcrumbs).toBeNull();
|
||||
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
|
||||
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main, production) {
|
||||
export function pageInventoryOf(main) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value);
|
||||
const visible=node=>node.getClientRects().length>0;
|
||||
@@ -18,8 +18,8 @@ export function pageInventoryOf(main, production) {
|
||||
|
||||
// Rendered line count of a cell's text, so a wrapped short name is reported even when the row height is unchanged.
|
||||
const lines=node=>{const range=document.createRange();range.selectNodeContents(node);return new Set([...range.getClientRects()].map(rect=>px(rect.top))).size;};
|
||||
const heading=main.querySelector(production?':scope > .workspace-heading':':scope > .heading');
|
||||
const copy=production?heading.querySelector('.workspace-heading-copy'):heading.firstElementChild;
|
||||
const heading=main.querySelector(':scope > .heading');
|
||||
const copy=heading.firstElementChild;
|
||||
const h1=copy.querySelector('h1'),subtitle=copy.querySelector('p'),actions=heading.querySelector('.heading-actions');
|
||||
const grid=main.querySelector(':scope > .two-col'),gridBox=grid.getBoundingClientRect();
|
||||
const surfaces=[...grid.querySelectorAll(':scope > section.surface')];
|
||||
|
||||
@@ -19,7 +19,7 @@ const categoryOperation=(page,name)=>main(page).locator('.operation').filter({ha
|
||||
async function seed(request,data={}) {const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();}
|
||||
async function open(page,theme,count) {
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
if(count!==undefined)await expect(rows(page)).toHaveCount(count);
|
||||
}
|
||||
async function openDialog(page,trigger) {await trigger.click();await expect(dialog(page)).toBeVisible();return dialog(page);}
|
||||
|
||||
@@ -353,7 +353,7 @@ test('route reentry restores older timeline range and both scroll axes after 61
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
|
||||
const workspace = '/prompt_manager/workspace#/images';
|
||||
const cards = page => page.locator('article[data-image-key]');
|
||||
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
|
||||
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
|
||||
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
@@ -54,7 +56,7 @@ test('workspace defaults to the native Images collection without a hash route',
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('image selection is announced on the selected image button', async ({ page }) => {
|
||||
@@ -83,7 +85,7 @@ test('closing details preview restores its trigger focus', async ({ page }) => {
|
||||
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
|
||||
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
let reads = 0;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
const current = reads++;
|
||||
await route.fulfill({ json: {
|
||||
success: true, images: [real.images[current]], total: 2,
|
||||
@@ -435,7 +437,7 @@ test('collection viewer describes video transforms and releases native playback
|
||||
});
|
||||
|
||||
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
|
||||
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
|
||||
await page.route(imagePages, route => route.fulfill({
|
||||
status: 503, json: { success: false, error: 'Images unavailable' },
|
||||
}), { times: 1 });
|
||||
await page.goto(workspace);
|
||||
@@ -446,7 +448,7 @@ test('failed image requests offer retry while stale results cannot replace a new
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
let arrived;
|
||||
const received = new Promise(resolve => { arrived = resolve; });
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
const response = await route.fetch();
|
||||
arrived();
|
||||
await pending;
|
||||
@@ -454,7 +456,7 @@ test('failed image requests offer retry while stale results cannot replace a new
|
||||
});
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
|
||||
await received;
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
|
||||
.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
@@ -492,7 +494,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
|
||||
const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
|
||||
let oldPage;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
|
||||
const response = await route.fetch();oldPage = { route, data: await response.json() };
|
||||
} else await route.continue();
|
||||
@@ -557,7 +559,7 @@ test('source-change recovery ignores superseded discovery and route disposal', a
|
||||
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
|
||||
@@ -588,7 +590,7 @@ test('a delayed search cannot replace results for a newer filename query', async
|
||||
const received = new Promise(resolve => { arrived = resolve; });
|
||||
const completed = new Promise(resolve => { complete = resolve; });
|
||||
let delayed = false;
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
await page.route(imagePages, async route => {
|
||||
if (delayed) return route.continue();
|
||||
delayed = true;
|
||||
const response = await route.fetch();
|
||||
@@ -613,7 +615,7 @@ test('selected layout stays blue with readable text on hover in either theme', a
|
||||
await page.goto(workspace);
|
||||
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await layout.click();
|
||||
await layout.hover();
|
||||
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
@@ -672,7 +674,7 @@ test('leaving during Refresh cancels it without replacing the next workspace', a
|
||||
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await requested;
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
|
||||
.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
release();
|
||||
@@ -686,7 +688,7 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
|
||||
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(page.locator('#workspace-main > .heading p.muted')).toHaveText('Browse your images, explore the details, keep your creative context.');
|
||||
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();
|
||||
|
||||
@@ -22,7 +22,7 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
|
||||
async function mountEditor(page,theme) {
|
||||
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
if(theme)await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.evaluate(async()=>{
|
||||
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
|
||||
window.editorEvents={committed:[],reconciled:[]};
|
||||
@@ -433,7 +433,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
|
||||
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
|
||||
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
||||
const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions');
|
||||
const headingActions=page=>page.locator('#workspace-main > .heading > .heading-actions');
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
@@ -866,3 +866,25 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
});
|
||||
|
||||
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
const [hash,openMode]=conceptModes[mode];
|
||||
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);await expect(page.locator('#modal')).toBeVisible();
|
||||
if(mode==='revision')await page.locator('#modal-close').focus();
|
||||
references[mode]=mapConcept(await page.locator('#modal').evaluate(inventoryOf));
|
||||
}
|
||||
await mountEditor(page,theme);
|
||||
expect(await page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')))).toEqual(CONCEPT_BASE);
|
||||
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
const dialog=await openEditor(page,options[mode]);await ready(dialog);
|
||||
expect(diffInventory(references[mode],await dialog.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ test('returning from history restores library context and mobile content stays i
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -140,9 +140,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
|
||||
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
|
||||
const position=await page.evaluate(()=>scrollY);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click());
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click());
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
|
||||
@@ -188,7 +188,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
|
||||
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'});
|
||||
@@ -203,7 +203,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
@@ -279,10 +279,10 @@ test('Library natural rows retain long prompt content without overflow',async({p
|
||||
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);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.');
|
||||
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',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');
|
||||
|
||||
@@ -8,14 +8,14 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const primary = ['images', 'library', 'tags', 'inspect'];
|
||||
const tools = ['overview', 'system'];
|
||||
const tools = ['system', 'overview'];
|
||||
|
||||
async function noOverflow(page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
async function groupedNavigation(page) {
|
||||
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
|
||||
const region = page.locator(`.workspace-nav-${group}`);
|
||||
for (const [group, routes] of [['primary', primary], ['system', tools]]) {
|
||||
const region = page.locator(`#${group}-nav`);
|
||||
await expect(region).toHaveCount(1);
|
||||
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
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 geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.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);
|
||||
@@ -92,7 +92,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
|
||||
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
@@ -104,7 +104,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.workspace-heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
@@ -197,26 +197,26 @@ test('shell matches approved stable geometry in both themes', async ({ page, req
|
||||
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 expect(page.locator('#workspace-main > .heading > div > h1#page-title')).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 page.getByRole('combobox', { name: 'Theme', 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('#workspace-main > .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 > .heading p.muted')).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);
|
||||
expect(await page.locator('.app-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
}
|
||||
await noOverflow(page);
|
||||
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
|
||||
@@ -236,8 +236,8 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
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();
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).focus();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', 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);
|
||||
@@ -257,17 +257,17 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
}
|
||||
});
|
||||
|
||||
test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => {
|
||||
test('Theme 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 preference = page.getByRole('combobox', { name: 'Theme', 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 => {
|
||||
return { intendedWrapper: field.classList.contains('theme-label') && field.parentElement.classList.contains('top-actions'),
|
||||
boxes: [field, select].map(node => {
|
||||
const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width };
|
||||
}), width: document.documentElement.clientWidth };
|
||||
});
|
||||
@@ -277,7 +277,7 @@ test('Appearance label and control fit the narrow shell at 200 percent zoom', as
|
||||
expect(box.right).toBeLessThanOrEqual(bounds.width + 1);
|
||||
}
|
||||
expect(bounds.intendedWrapper).toBe(true);
|
||||
for (const theme of ['follow', 'light', 'dark']) {
|
||||
for (const theme of ['system', 'light', 'dark']) {
|
||||
await preference.selectOption(theme); await expect(preference).toHaveValue(theme);
|
||||
}
|
||||
await preference.focus(); await expect(preference).toBeFocused();
|
||||
@@ -514,15 +514,15 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
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 page.getByRole('combobox',{name:'Theme',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 expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await saveCoverage(page,browserName);await context.close();}
|
||||
}
|
||||
@@ -533,11 +533,11 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const icons = await page.evaluate(async () => {
|
||||
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
|
||||
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch', 'star', 'tagImage', 'folder', 'history', 'review'];
|
||||
const otherDocument = document.implementation.createHTMLDocument();
|
||||
const rendered = names.map(name => {
|
||||
const icon = createWorkspaceIcon(name, otherDocument);
|
||||
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
|
||||
return { name, svg: icon instanceof SVGSVGElement, className: icon.getAttribute('class'), owner: icon.ownerDocument === otherDocument,
|
||||
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
|
||||
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
|
||||
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
|
||||
@@ -549,10 +549,12 @@ test('approved icons are decorative safe SVGs and reject unknown vocabulary', as
|
||||
});
|
||||
expect(icons.unknown).toEqual([true, true, true, true, true]);
|
||||
for (const icon of icons.rendered) {
|
||||
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon).toMatchObject({ svg: true, className: 'icon', owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
|
||||
expect(icon.path.length).toBeGreaterThan(10);
|
||||
}
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
|
||||
// D28 ruling: Overview's own outline home glyph, not the concept's `activity` pulse ('M3 12h4l3-8 4 16 3-8h4').
|
||||
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6');
|
||||
expect(new Set(icons.rendered.map(icon => icon.path)).size).toBe(icons.rendered.length);
|
||||
});
|
||||
|
||||
|
||||
@@ -582,7 +584,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
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.getByRole('combobox',{name:'Theme',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']) {
|
||||
@@ -618,7 +620,7 @@ test('embedded history gallery keeps the history heading and disposes its layout
|
||||
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 expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
|
||||
@@ -43,8 +43,8 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
test('tags route renders the concept composition without a legacy handoff',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator('.workspace-heading-copy > p')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator('.workspace-heading .heading-actions button');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
@@ -89,7 +89,7 @@ test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true}).click();
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{category:'Landscapes',count:3});
|
||||
@@ -127,7 +127,8 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
|
||||
test('read failure shows inline error with Retry and no zero counts',async({page})=>{
|
||||
let failures=0;
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',route=>{
|
||||
// Tag list pages only: the sidebar's Tags count is its own `limit=1` read.
|
||||
await page.route(url=>url.pathname==='/prompt_manager/vocabulary/tags' && url.searchParams.get('limit')!=='1',route=>{
|
||||
if(failures++) return route.continue();
|
||||
return route.fulfill({status:500,json:{success:false,error:'Database is unavailable'}});
|
||||
});
|
||||
@@ -149,7 +150,7 @@ test('breadcrumb and document title are Tags',async({page})=>{
|
||||
await page.goto(route);
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await expect(page).toHaveTitle('Tags · Prompt Manager');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Tags & categories',exact:true})).toHaveAttribute('aria-current','page');
|
||||
await expect(page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/})).toHaveAttribute('aria-current','page');
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Tags');
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
@@ -161,7 +162,8 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.addInitScript(()=>{
|
||||
window.__vocabularySignals=[];window.__vocabularyObservers=[];
|
||||
const fetch=window.fetch;
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
// The page's own reads; the sidebar's `limit=1` Tags count belongs to the shell.
|
||||
window.fetch=(input,init={})=>{if(String(input).includes('/vocabulary/') && !/[?&]limit=1(?:&|$)/.test(String(input)) && init.signal) window.__vocabularySignals.push(init.signal);return fetch(input,init);};
|
||||
const Observer=window.IntersectionObserver;
|
||||
window.IntersectionObserver=class extends Observer {
|
||||
constructor(...args) {super(...args);this.record={targets:[],disconnected:false};window.__vocabularyObservers.push(this.record);}
|
||||
@@ -175,7 +177,7 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).click();
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
@@ -193,10 +195,10 @@ test('390px layout stacks without horizontal page scroll; table scrolls locally'
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const layout=await main(page).evaluate(node=>{
|
||||
const grid=node.querySelector('.two-col'),[left,right]=grid.querySelectorAll(':scope > .surface'),wrap=node.querySelector('.table-wrap');
|
||||
const heading=node.querySelector('.workspace-heading'),operation=node.querySelector('.operation');
|
||||
const heading=node.querySelector(':scope > .heading'),operation=node.querySelector('.operation');
|
||||
return {tracks:getComputedStyle(grid).gridTemplateColumns.split(' ').length,stacked:right.getBoundingClientRect().top>=left.getBoundingClientRect().bottom,
|
||||
overflow:getComputedStyle(wrap).overflowX,heading:getComputedStyle(heading).flexDirection,operation:getComputedStyle(operation).flexDirection,
|
||||
actions:node.querySelector('.workspace-heading .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
actions:node.querySelector(':scope > .heading > .heading-actions').getBoundingClientRect().width===heading.getBoundingClientRect().width};
|
||||
});
|
||||
expect(layout).toEqual({tracks:1,stacked:true,overflow:'auto',heading:'column',operation:'column',actions:true});
|
||||
});
|
||||
@@ -482,7 +484,8 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
});
|
||||
|
||||
const nav=(page,name)=>page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name,exact:true});
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
@@ -666,10 +669,10 @@ async function openConceptTags(page,theme,motion) {
|
||||
async function openProductionTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
}
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf,production);
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||||
async function chromeOf(page,production) {
|
||||
const scope=page.locator(production?'#workspace-main':'#main');
|
||||
@@ -791,3 +794,29 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
expect(dialogPaths).toContain('fields.0.control.color');
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
|
||||
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const donors=await conceptDonors(page,theme);
|
||||
// Park the pointer: donor clicks leave it over a heading or row button, and a hover background is not compared.
|
||||
await openConceptTags(page,theme,'no-preference');await page.mouse.move(0,0);const reference=await pageOf(page,false);
|
||||
const dialogs={};
|
||||
for(const mode of Object.keys(conceptDialogs)) {
|
||||
await openConceptTags(page,theme,'no-preference');await conceptDialogs[mode](page);
|
||||
await expect(page.locator('#modal')).toBeVisible();dialogs[mode]=await page.locator('#modal').evaluate(inventoryOf);
|
||||
}
|
||||
await openProductionTags(page,theme,'no-preference');await page.mouse.move(0,0);
|
||||
expect(await loadedSheets(page)).toEqual(CONCEPT_BASE);
|
||||
const {diff,extra}=comparePage(reference,await pageOf(page,true));
|
||||
expect(diff).toEqual([]);expect(extra).toEqual(fixtureOnly());
|
||||
for(const mode of Object.keys(productionDialogs)) {
|
||||
const box=await productionDialogs[mode](page);
|
||||
expect(diffInventory(mapVocabularyDialog(mode,dialogs[mode],donors),await box.evaluate(inventoryOf)),`${mode} dialog`).toEqual([]);
|
||||
await page.keyboard.press('Escape');await expect(box).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/i
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const statistic = (page, label) => page.locator('#workspace-main dt')
|
||||
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
|
||||
// Concept sidebar: WORKSPACE links carry a real `.nav-count` once loaded, so their accessible name may end in it.
|
||||
const escapeName = label => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const navName = label => new RegExp(`^${escapeName(label)}(?:\\s*[\\d,]+)?$`);
|
||||
const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: navName(label) });
|
||||
const sidebarLinks = page => page.locator('aside.sidebar nav a');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
@@ -13,12 +18,12 @@ const routes = [
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
|
||||
async function countAppearanceWrites(page) {
|
||||
async function countThemeWrites(page) {
|
||||
await page.evaluate(() => {
|
||||
window.__appearanceChanges = 0;
|
||||
window.__themeChanges = 0;
|
||||
const original = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (...args) {
|
||||
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
|
||||
if (args[0] === 'pm_workspace_theme') window.__themeChanges++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
@@ -66,8 +71,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
@@ -81,11 +85,11 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
const images = navigation.getByRole('link', { name: 'Images', exact: true });
|
||||
const images = navigation.getByRole('link', { name: navName('Images') });
|
||||
await images.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
|
||||
await navigation.getByRole('link', { name: navName('Prompt library') }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goBack();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
@@ -93,7 +97,7 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goto(`${workspace}#/not-a-page`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
|
||||
await expect(navigation.getByRole('link', { name: navName('Images') }))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
@@ -101,44 +105,144 @@ test('skip to content focuses the current view without changing its route', asyn
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('link', { name: 'Skip to workspace', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
// The concept focuses its page title on every hashchange navigation (docs/concepts/workspace/app.js 1168), so a screen
|
||||
// reader announces "<page>, heading level 1"; the first render leaves focus alone.
|
||||
test('a sidebar route change focuses the page title, as the concept does', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/library`);
|
||||
const title = page.locator('#page-title');
|
||||
await expect(title).toHaveText('Prompt library');
|
||||
await expect(title).not.toBeFocused();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
await expect(page.locator('#workspace-main')).not.toBeFocused();
|
||||
await sidebarLink(page, 'Tags & categories').click();
|
||||
await expect(title).toHaveText('Tags & categories');
|
||||
await expect(title).toBeFocused();
|
||||
await page.goBack();
|
||||
await expect(title).toHaveText('Images');
|
||||
await expect(title).toBeFocused();
|
||||
});
|
||||
|
||||
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const prompts = statistic(page, 'Prompts');
|
||||
await expect(prompts).toHaveText('2');
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
|
||||
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await sidebarLink(page, 'Overview').click();
|
||||
await expect(prompts).toHaveText('3');
|
||||
await expect(navigation).toHaveCount(1);
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
});
|
||||
|
||||
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
test('theme System follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
await appearance.selectOption({ label: 'Dark' });
|
||||
const themeSelect = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(themeSelect).toHaveValue('system');
|
||||
await themeSelect.selectOption({ label: 'Dark' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await page.reload();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await expect(appearance).toHaveValue('dark');
|
||||
await appearance.selectOption({ label: 'Light' });
|
||||
await expect(themeSelect).toHaveValue('dark');
|
||||
await themeSelect.selectOption({ label: 'Light' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await appearance.selectOption({ label: 'Follow ComfyUI' });
|
||||
await themeSelect.selectOption({ label: 'System' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
|
||||
// Task A4 (D4, D5): the concept topbar (index.html 45-63, style.css 225-277) without the prototype badge or Activity.
|
||||
test('topbar matches the concept actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const topbar = page.locator('.workspace > header.topbar');
|
||||
await expect(topbar).toHaveCount(1);
|
||||
const shape = await topbar.evaluate(node => ({
|
||||
children: [...node.children].map(child => `${child.localName}.${child.className}`),
|
||||
crumbs: [...node.querySelector('.breadcrumbs').childNodes].filter(item => item.nodeType === 1 || item.textContent.trim())
|
||||
.map(item => item.nodeType === 1 ? `${item.localName}${item.id ? `#${item.id}` : ''}:${item.textContent}` : item.textContent.trim()),
|
||||
actions: [...node.querySelector('.top-actions').children].map(child => `${child.localName}.${child.className}`),
|
||||
label: node.querySelector('.theme-label').firstChild.textContent.trim(),
|
||||
labelFor: node.querySelector('.theme-label').htmlFor,
|
||||
options: [...node.querySelectorAll('#theme option')].map(option => [option.value, option.textContent]),
|
||||
arrow: [...node.querySelectorAll('.canvas-link span')].map(span => [span.textContent, span.getAttribute('aria-hidden')]),
|
||||
tokens: {
|
||||
height: getComputedStyle(node).height, padding: getComputedStyle(node).padding,
|
||||
crumbs: getComputedStyle(node.querySelector('.breadcrumbs')).fontSize,
|
||||
current: getComputedStyle(node.querySelector('#workspace-breadcrumb')).color === getComputedStyle(document.body).color,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
link: getComputedStyle(node.querySelector('.canvas-link')).fontSize,
|
||||
gap: getComputedStyle(node.querySelector('.top-actions')).columnGap,
|
||||
},
|
||||
}));
|
||||
expect(shape).toEqual({
|
||||
children: ['div.breadcrumbs', 'div.top-actions'],
|
||||
crumbs: ['ComfyAssets', 'span:/', 'span#workspace-breadcrumb:Images'],
|
||||
actions: ['label.theme-label', 'a.canvas-link'],
|
||||
label: 'Theme', labelFor: 'theme',
|
||||
options: [['dark', 'Dark'], ['light', 'Light'], ['system', 'System']],
|
||||
arrow: [['↗', 'true']],
|
||||
tokens: { height: '68px', padding: '0px 30px', crumbs: '12px', current: true, label: '11px', select: '11px', link: '12px', gap: '12px' },
|
||||
});
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toHaveValue('system');
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveText('ComfyUI ↗');
|
||||
// D6: the ComfyUI canvas root under the page's mount (resolveUrl), never a hard-coded origin.
|
||||
await expect(link).toHaveAttribute('href', '/');
|
||||
expect(await link.evaluate(node => node.href === `${location.origin}/`)).toBe(true);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toHaveCount(0);
|
||||
await expect(page.locator('.concept-badge, #activity-open')).toHaveCount(0);
|
||||
await page.locator('nav [data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Prompt library');
|
||||
await expect(topbar.locator('.top-actions > *')).toHaveCount(2);
|
||||
});
|
||||
|
||||
// Concept style.css 1375-1391: at <=700px the breadcrumbs hide, the actions spread across the bar and the Theme
|
||||
// label text collapses while the select keeps its accessible name.
|
||||
test('mobile topbar hides breadcrumbs and spreads actions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
const theme = page.getByRole('combobox', { name: 'Theme', exact: true });
|
||||
await expect(theme).toBeVisible();
|
||||
await expect(page.locator('.breadcrumbs')).toBeHidden();
|
||||
const layout = await page.locator('.topbar').evaluate(node => {
|
||||
const bar = node.getBoundingClientRect(), actions = node.querySelector('.top-actions');
|
||||
const box = element => element.getBoundingClientRect();
|
||||
return {
|
||||
padding: getComputedStyle(node).paddingInline,
|
||||
actions: { width: box(actions).width, justify: getComputedStyle(actions).justifyContent, gap: getComputedStyle(actions).columnGap },
|
||||
expected: bar.width - 28,
|
||||
label: getComputedStyle(node.querySelector('.theme-label')).fontSize,
|
||||
select: getComputedStyle(node.querySelector('#theme')).fontSize,
|
||||
labelLeft: box(node.querySelector('.theme-label')).left - bar.left,
|
||||
linkRight: bar.right - box(node.querySelector('.canvas-link')).right,
|
||||
};
|
||||
});
|
||||
expect(layout).toMatchObject({ padding: '14px', actions: { justify: 'space-between', gap: '8px' }, label: '0px', select: '11px' });
|
||||
expect(Math.abs(layout.actions.width - layout.expected)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.labelLeft - 14)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(layout.linkRight - 14)).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toBeVisible();
|
||||
await theme.selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to the system theme and tracks system changes', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -170,15 +274,14 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
}
|
||||
});
|
||||
|
||||
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
for (const [, label] of routes) {
|
||||
await navigation.getByRole('link', { name: label, exact: true }).click();
|
||||
await sidebarLink(page, label).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(label);
|
||||
}
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -196,20 +299,19 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
release();
|
||||
await finished;
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(page.locator('#workspace-main')).not.toContainText('999');
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', 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);
|
||||
await countThemeWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
@@ -227,15 +329,384 @@ test('cached-page restoration remounts one working shell and refreshes its data'
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
}
|
||||
expect(reads).toBe(3);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
|
||||
await countAppearanceWrites(page);
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await countThemeWrites(page);
|
||||
await sidebarLink(page, 'Images').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await page.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
});
|
||||
|
||||
// Task A2: the Overview and handoff panels use the concept's `.surface` (style.css 709-722) and `.small` from the
|
||||
// concept base stylesheet, not the retired shell sheet (18px h2, 11px panel radius).
|
||||
const surfaceStyle = node => {
|
||||
const style = getComputedStyle(node);
|
||||
const heading = getComputedStyle(node.querySelector(':scope > h2'));
|
||||
const intro = node.querySelector(':scope > p');
|
||||
return {
|
||||
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
|
||||
radius: style.borderTopLeftRadius, border: `${style.borderTopWidth} ${style.borderTopStyle}`,
|
||||
background: style.backgroundColor,
|
||||
h2: { fontSize: heading.fontSize, fontWeight: heading.fontWeight, marginTop: heading.marginTop, marginBottom: heading.marginBottom },
|
||||
intro: intro && { fontSize: getComputedStyle(intro).fontSize, marginTop: getComputedStyle(intro).marginTop },
|
||||
};
|
||||
};
|
||||
const SURFACE = {
|
||||
padding: ['22px', '22px', '22px', '22px'], radius: '7px', border: '1px solid',
|
||||
h2: { fontSize: '17px', fontWeight: '600', marginTop: '0px', marginBottom: '0px' },
|
||||
intro: { fontSize: '12px', marginTop: '7px' },
|
||||
};
|
||||
|
||||
for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
const tools = main.locator('section.surface').filter({ has: page.getByRole('heading', { level: 2, name: 'Continue creating', exact: true }) });
|
||||
await expect(tools).toHaveCount(1);
|
||||
const surface = await tools.evaluate(surfaceStyle);
|
||||
expect(surface).toMatchObject(SURFACE);
|
||||
expect(surface.background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
const card = tools.locator('.tool-card').first();
|
||||
await expect(card.locator('p.small')).toHaveCount(1);
|
||||
expect(await card.locator('p.small').evaluate(node => getComputedStyle(node).fontSize)).toBe('12px');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible();
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
// Task A3: the concept sidebar (index.html h16-41, app.js nav()) rendered from real data (D27, D28).
|
||||
const NAV_TOTALS = [
|
||||
['images', 'Images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['library', 'Prompt library', '/prompt_manager/library?limit=1'],
|
||||
['tags', 'Tags & categories', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
];
|
||||
async function serverTotals(request) {
|
||||
const totals = {};
|
||||
for (const [id, , url] of NAV_TOTALS) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
totals[id] = (await response.json()).total;
|
||||
expect(Number.isSafeInteger(totals[id]) && totals[id] >= 0).toBe(true);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
const navCount = (page, route) => page.locator(`#primary-nav a[data-route="${route}"] .nav-count`);
|
||||
const childShape = locator => locator.evaluate(node => [...node.children]
|
||||
.map(child => `${child.localName}${child.id ? `#${child.id}` : ''}${[...child.classList].map(name => `.${name}`).join('')}`));
|
||||
|
||||
test('sidebar matches the concept groups and shows real counts', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
expect(totals.library).toBe(2);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('body > .app-shell > aside.sidebar')).toHaveCount(1);
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
const links = await primary.evaluate(node => [...node.children].map(link => ({ tag: link.localName,
|
||||
href: link.getAttribute('href'), icon: link.querySelector('svg')?.getAttribute('class'),
|
||||
nodes: [...link.childNodes].map(child => child.nodeType === Node.TEXT_NODE ? `text:${child.textContent}`
|
||||
: `${child.localName}${[...child.classList].map(name => `.${name}`).join('')}`) })));
|
||||
expect(links).toEqual([
|
||||
{ tag: 'a', href: '#/images', nodes: ['svg.icon', 'text:Images', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/library', nodes: ['svg.icon', 'text:Prompt library', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/tags', nodes: ['svg.icon', 'text:Tags & categories', 'span.nav-count'], icon: 'icon' },
|
||||
{ tag: 'a', href: '#/inspect', nodes: ['svg.icon', 'text:Inspect a file'], icon: 'icon' },
|
||||
]);
|
||||
await expect(primary.locator('a[href="#/images"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(primary.locator('a[aria-current]')).toHaveCount(1);
|
||||
await expect(page.locator('#primary-nav a[data-route="inspect"] .nav-count')).toHaveCount(0);
|
||||
// Concept tokens: 38px rows, 12px muted labels, 10px muted counts pushed to the end.
|
||||
const style = await primary.locator('a[href="#/library"]').evaluate(link => {
|
||||
const own = getComputedStyle(link), count = link.querySelector('.nav-count');
|
||||
return { minHeight: own.minHeight, fontSize: own.fontSize, gap: own.columnGap, count: getComputedStyle(count).fontSize,
|
||||
end: Math.round(link.getBoundingClientRect().right - count.getBoundingClientRect().right) };
|
||||
});
|
||||
expect(style).toEqual({ minHeight: '38px', fontSize: '12px', gap: '11px', count: '10px', end: 12 });
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('nav counts stay hidden until loaded and when their request fails', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.route('**/prompt_manager/vocabulary/tags?limit=1', route => route.fulfill({ json: { success: true, total: -4 } }));
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// Nothing renders for a read that is outstanding: no "0" placeholder. The settled Images count does not wait for it.
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'tags')).toHaveCount(0);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// A3 review: the Images listing scans output folders, so its slow count must not hold back Library and Tags.
|
||||
test('a slow Images count does not delay the Library and Tags badges', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') === '1', async route => {
|
||||
await pending;
|
||||
await route.continue().catch(() => {});
|
||||
});
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await expect(navCount(page, 'images')).toHaveCount(0);
|
||||
release();
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
});
|
||||
|
||||
test('sidebar bottom holds System and Overview with a neutral connection', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system`);
|
||||
const bottom = page.locator('aside.sidebar > .sidebar-bottom');
|
||||
expect(await childShape(bottom)).toEqual(['nav#system-nav', 'div.connection']);
|
||||
const system = page.locator('nav#system-nav');
|
||||
await expect(system).toHaveAttribute('aria-label', 'Tools and system');
|
||||
expect(await system.locator(':scope > a').evaluateAll(nodes => nodes.map(node => [node.getAttribute('href'), node.textContent]))).toEqual([
|
||||
['#/system', 'System'], ['#/overview', 'Overview'],
|
||||
]);
|
||||
await expect(system.locator('.nav-count')).toHaveCount(0);
|
||||
await expect(system.locator('a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(system.locator('svg.icon')).toHaveCount(2);
|
||||
const connection = bottom.locator('.connection');
|
||||
expect(await childShape(connection)).toEqual(['i', 'div']);
|
||||
await expect(connection.locator(':scope > div')).toHaveText('ComfyUI workspaceYour library, on your server');
|
||||
await expect(connection.locator('small')).toHaveText('Your library, on your server');
|
||||
// D28: no fake connection state. The dot is the muted text colour, not the concept's demo green.
|
||||
const dot = await connection.evaluate(node => ({ dot: getComputedStyle(node.querySelector('i')).backgroundColor,
|
||||
muted: getComputedStyle(node.querySelector('small')).color }));
|
||||
expect(dot.dot).toBe(dot.muted);
|
||||
await page.locator('nav#system-nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(system.locator('a[href="#/overview"]')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar a[aria-current]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('counts refresh after a confirmed editor save', async ({ page, request }) => {
|
||||
const before = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(before.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(before.tags));
|
||||
const reads = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.searchParams.get('limit') === '1') reads.push(url.pathname);
|
||||
});
|
||||
await page.getByRole('button', { name: '+ New prompt', exact: true }).click();
|
||||
const dialog = page.locator('dialog.library-editor');
|
||||
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
|
||||
await dialog.getByLabel('Title', { exact: true }).fill('Harbor at dawn');
|
||||
await dialog.getByLabel('Positive prompt', { exact: true }).fill('A quiet harbor at dawn for the sidebar count');
|
||||
await dialog.getByLabel('Tags, separated by commas', { exact: true }).fill('sidebar_count_tag');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
const after = await serverTotals(request);
|
||||
expect(after.library).toBe(before.library + 1);
|
||||
expect(after.tags).toBeGreaterThan(before.tags);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(after.library));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
|
||||
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
|
||||
// A Library write cannot change the Images count, so the Images listing is not read again.
|
||||
expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
|
||||
const totals = await serverTotals(request);
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
const held = [];
|
||||
let reads = 0;
|
||||
await page.route('**/prompt_manager/library?limit=1', route => {
|
||||
if (reads++ === 0) { held.push(route); return undefined; }
|
||||
return route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
const invalidate = () => page.evaluate(async () => {
|
||||
const { invalidateLibraryAfterWrite } = await import('/prompt_manager/js/workspace/library.mjs');
|
||||
invalidateLibraryAfterWrite({ kind: 'test', receipt: {} });
|
||||
});
|
||||
await invalidate();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await invalidate();
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await expect(navCount(page, 'images')).toHaveText(String(totals.images));
|
||||
await expect(navCount(page, 'tags')).toHaveText(String(totals.tags));
|
||||
await held[0].fulfill({ json: { success: true, total: 999 } }).catch(() => {});
|
||||
await page.waitForTimeout(100);
|
||||
await expect(navCount(page, 'library')).toHaveCount(0);
|
||||
await page.unroute('**/prompt_manager/library?limit=1');
|
||||
// Leaving the page with a read in flight aborts it; the restored shell reads the counts again.
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/library?limit=1', async route => { await pending; await route.continue().catch(() => {}); }, { times: 1 });
|
||||
await invalidate();
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
});
|
||||
release();
|
||||
await expect(navCount(page, 'library')).toHaveText(String(totals.library));
|
||||
await expect(page.locator('#primary-nav a')).toHaveCount(4);
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
});
|
||||
|
||||
// Task A5: the concept heading() (app.js 303-305) with its typography (style.css 73-103, 283-300; <=700 1408-1416) on
|
||||
// every native route, and the production footer copy in the concept `.app-footer` (D29).
|
||||
const headingShape = node => {
|
||||
const style = element => getComputedStyle(element);
|
||||
const shape = element => `${element.localName}${element.id ? `#${element.id}` : ''}${[...element.classList].map(name => `.${name}`).join('')}`;
|
||||
const [copy, actions] = node.children;
|
||||
const [h1, description] = copy.children;
|
||||
const main = node.parentElement.getBoundingClientRect();
|
||||
return {
|
||||
children: [...node.children].map(shape), copy: [...copy.children].map(shape), tabIndex: h1.tabIndex,
|
||||
direction: style(node).flexDirection, marginBottom: style(node).marginBottom,
|
||||
h1: { fontSize: style(h1).fontSize, lineHeight: Math.round(parseFloat(style(h1).lineHeight) / parseFloat(style(h1).fontSize) * 100) / 100, fontWeight: style(h1).fontWeight, letterSpacing: style(h1).letterSpacing, marginTop: style(h1).marginTop },
|
||||
description: { text: description.textContent.length > 0, fontSize: style(description).fontSize, marginTop: style(description).marginTop, muted: style(description).color === style(document.querySelector('.connection small')).color },
|
||||
actions: { gap: style(actions).columnGap, left: Math.round(actions.getBoundingClientRect().left), fullWidth: Math.round(actions.getBoundingClientRect().width) === Math.round(node.getBoundingClientRect().width) },
|
||||
mainLeft: Math.round(main.left + parseFloat(getComputedStyle(node.parentElement).paddingLeft)),
|
||||
pageTitles: document.querySelectorAll('#page-title').length,
|
||||
retired: document.querySelectorAll('.workspace-heading, .workspace-heading-copy, #workspace-main .intro').length,
|
||||
};
|
||||
};
|
||||
|
||||
test('every route renders the concept heading', async ({ page, request }) => {
|
||||
const library = await (await request.get('/prompt_manager/library?limit=1')).json();
|
||||
const positiveId = library.items[0].id;
|
||||
const pages = [['overview', 'Overview'], ['images', 'Images'], ['library', 'Prompt library'], ['tags', 'Tags & categories'], [`library/${positiveId}`, null]];
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const [hash, title] of pages) {
|
||||
await page.goto(`${workspace}#/${hash}`);
|
||||
const heading = page.locator('#workspace-main > .heading');
|
||||
await expect(heading).toHaveCount(1);
|
||||
if (title) await expect(heading.locator('h1')).toHaveText(title);
|
||||
else await expect(heading.getByRole('button', { name: '← Prompt library', exact: true })).toBeVisible();
|
||||
await expect(heading.locator('p.muted')).not.toHaveText('');
|
||||
const shape = await heading.evaluate(headingShape);
|
||||
expect(shape, hash).toMatchObject({
|
||||
children: ['div', 'div.heading-actions'], copy: ['h1#page-title', 'p.muted'], tabIndex: -1, marginBottom: '22px',
|
||||
h1: width === 1600 ? { fontSize: '28px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' }
|
||||
: { fontSize: '25px', lineHeight: 1.3, fontWeight: '620', letterSpacing: '-0.7px', marginTop: '0px' },
|
||||
description: { text: true, fontSize: '12px', marginTop: '6px', muted: true },
|
||||
actions: { gap: '8px' }, pageTitles: 1, retired: 0,
|
||||
});
|
||||
if (width === 1600) {
|
||||
expect(shape.direction, hash).toBe('row');
|
||||
} else {
|
||||
// The concept stacks the heading and gives the actions the full row, starting at main's 14px gutter.
|
||||
expect(shape.direction, hash).toBe('column');
|
||||
expect(shape.actions, hash).toMatchObject({ left: 14, fullWidth: true });
|
||||
expect(shape.mainLeft, hash).toBe(14);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the footer is the concept app footer with production copy', async ({ page }) => {
|
||||
for (const [width, padding, direction] of [[1600, '22px 30px', 'row'], [390, '20px 14px', 'column']]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const footer = page.locator('.workspace > footer.app-footer');
|
||||
await expect(footer).toHaveCount(1);
|
||||
await expect(page.locator('.page-footer')).toHaveCount(0);
|
||||
expect(await footer.evaluate(node => ({
|
||||
children: [...node.children].map(child => child.localName), text: node.textContent,
|
||||
padding: getComputedStyle(node).padding, fontSize: getComputedStyle(node).fontSize, direction: getComputedStyle(node).flexDirection,
|
||||
muted: getComputedStyle(node).color === getComputedStyle(document.querySelector('.connection small')).color,
|
||||
afterMain: node.previousElementSibling.id,
|
||||
}))).toEqual({ children: ['span'], text: 'ComfyAssets · Local tools. Your library.', padding, fontSize: '10px', direction, muted: true, afterMain: 'workspace-main' });
|
||||
}
|
||||
});
|
||||
|
||||
// #164: the maintainer serves ComfyUI behind a proxy prefix. Everything, the ComfyUI link included, stays on that mount.
|
||||
const PROXY_MOUNT = '/ComfyBackendDirect';
|
||||
async function serveUnderProxyMount(page) {
|
||||
const escaped = [];
|
||||
page.on('request', item => {
|
||||
const { pathname } = new URL(item.url());
|
||||
if (pathname.startsWith('/prompt_manager/')) escaped.push(pathname);
|
||||
});
|
||||
await page.route(url => url.pathname.startsWith(`${PROXY_MOUNT}/`), async route => {
|
||||
const url = new URL(route.request().url());
|
||||
url.pathname = url.pathname.slice(PROXY_MOUNT.length);
|
||||
const response = await route.fetch({ url: url.href });
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
return escaped;
|
||||
}
|
||||
|
||||
test('the ComfyUI link points to the root of a non-standard proxy mount', async ({ page }) => {
|
||||
const escaped = await serveUnderProxyMount(page);
|
||||
for (const route of ['overview', 'library', 'tags']) {
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/${route}`);
|
||||
await expect(page.locator('#workspace-main > .heading h1')).toBeVisible();
|
||||
const link = page.getByRole('link', { name: 'ComfyUI', exact: true });
|
||||
await expect(link).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
expect(await link.evaluate(node => node.href)).toBe(new URL(`${PROXY_MOUNT}/`, page.url()).href);
|
||||
}
|
||||
await expect(navCount(page, 'library')).toHaveCount(1);
|
||||
expect(escaped).toEqual([]);
|
||||
});
|
||||
|
||||
test('a resolver failure for the ComfyUI link never stops the shell from mounting', async ({ page }) => {
|
||||
await serveUnderProxyMount(page);
|
||||
// Wrap the shared client as it loads so only the topbar's canvas-root lookup throws (the sender reads the same root).
|
||||
// Every engine's stack names the calling module's URL; each injected failure is counted so a renamed module or a
|
||||
// changed call site fails this test on the count instead of silently testing nothing.
|
||||
await page.addInitScript(() => {
|
||||
let client;
|
||||
window.__resolverFailures = { injected: 0 };
|
||||
Object.defineProperty(window, 'ApiClient', { configurable: true, get: () => client, set(value) {
|
||||
client = { ...value, resolveUrl: (url, ...rest) => {
|
||||
if (url === '/prompt_manager/' && /\/workspace\/topbar\.mjs\b/.test(String(new Error().stack))) {
|
||||
window.__resolverFailures.injected += 1;
|
||||
throw new Error('resolver unavailable');
|
||||
}
|
||||
return value.resolveUrl(url, ...rest);
|
||||
} };
|
||||
} });
|
||||
});
|
||||
const warnings = [];
|
||||
page.on('console', message => { if (message.type() === 'warning') warnings.push(message.text()); });
|
||||
await page.goto(`${PROXY_MOUNT}${workspace}#/overview`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(sidebarLinks(page)).toHaveCount(routes.length);
|
||||
expect(await page.evaluate(() => window.__resolverFailures.injected), 'the topbar lookup was the one that failed').toBe(1);
|
||||
expect(warnings.filter(text => text.includes('the ComfyUI link falls back to the page mount'))).toHaveLength(1);
|
||||
await expect(page.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', `${PROXY_MOUNT}/`);
|
||||
await sidebarLink(page, 'Prompt library').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Concept-shaped real data for every concept comparison (D30 of #249).
|
||||
|
||||
The approved concept's six prompts (docs/concepts/workspace/app.js:71-178) and its
|
||||
24 sample images (app.js:180-239) become real rows and real PNG files in this
|
||||
server's disposable database and output folder, so production renders the
|
||||
concept's shapes from its own stores: the same titles, texts, categories, tags,
|
||||
ratings and negative names, the same image dimensions, filenames and seeds, and
|
||||
the same sidebar counts (24 images, 6 prompts, 15 tags). Nothing is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
from utils.pair_execution import EXECUTION_KEY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptPrompt:
|
||||
id: str
|
||||
title: str
|
||||
category: str
|
||||
tags: tuple[str, ...]
|
||||
positive: str
|
||||
negatives: tuple[tuple[str, str], ...]
|
||||
color: tuple[str, str, str]
|
||||
rating: int
|
||||
|
||||
|
||||
# app.js:71-178, in concept order. Negatives are (name, text) pairs.
|
||||
CONCEPT_PROMPTS = (
|
||||
ConceptPrompt(
|
||||
"alpine",
|
||||
"Alpine observatory",
|
||||
"Landscapes",
|
||||
("cinematic", "blue_hour", "architecture"),
|
||||
"A remote alpine observatory at blue hour, warm light through glass, dramatic "
|
||||
"mountain peaks, cinematic composition, atmospheric mist, fine detail",
|
||||
(
|
||||
("Quality baseline", "blurry, low quality, low resolution"),
|
||||
(
|
||||
"Clean architecture",
|
||||
"blurry, low quality, low resolution, distorted architecture, "
|
||||
"watermark, text",
|
||||
),
|
||||
("No negative", ""),
|
||||
),
|
||||
("#14233c", "#9aaac3", "#d8d9dd"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"dunes",
|
||||
"Quiet geometry",
|
||||
"Architecture",
|
||||
("minimal", "warm_light", "editorial"),
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry",
|
||||
(
|
||||
("Natural materials", "plastic, harsh shadows, noise, watermark"),
|
||||
(
|
||||
"Clean lines",
|
||||
"plastic, harsh shadows, noise, watermark, distorted geometry",
|
||||
),
|
||||
),
|
||||
("#6c4948", "#ddad91", "#efe0c5"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"forest",
|
||||
"Somewhere in the pines",
|
||||
"Landscapes",
|
||||
("forest", "mist", "peaceful"),
|
||||
"A quiet cedar cabin above a still forest lake, towering evergreens, soft "
|
||||
"morning mist, muted jade and moss, a small amber window, cinematic still",
|
||||
(("Soft atmosphere", "oversaturated, blurry, low quality, text"),),
|
||||
("#163b3d", "#6f9691", "#c0cfc1"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"city",
|
||||
"After the rain",
|
||||
"Environments",
|
||||
("neon", "night", "cinematic"),
|
||||
"Rain-soaked city street at night, blue neon and rose reflections, deep "
|
||||
"perspective, cinematic science fiction, solitary architecture, detailed "
|
||||
"atmosphere",
|
||||
(
|
||||
("Clean atmosphere", "blurry, low resolution, watermark, oversaturated"),
|
||||
(
|
||||
"Less signage",
|
||||
"blurry, low resolution, watermark, oversaturated, text, signs",
|
||||
),
|
||||
),
|
||||
("#1a2143", "#6869a1", "#cf8fbc"),
|
||||
4,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"lunar",
|
||||
"The long way home",
|
||||
"Illustration",
|
||||
("space", "minimal", "concept_art"),
|
||||
"An isolated lunar research outpost, a luminous planet above the horizon, "
|
||||
"sweeping pale terrain, quiet science fiction, finely detailed concept art",
|
||||
(("Quality baseline", "blurry, low quality, watermark"),),
|
||||
("#19283b", "#7398ac", "#e2d3c4"),
|
||||
5,
|
||||
),
|
||||
ConceptPrompt(
|
||||
"coast",
|
||||
"Between sea and sky",
|
||||
"Landscapes",
|
||||
("coast", "soft_light", "peaceful"),
|
||||
"A solitary lighthouse on a rugged coastal cliff, sea mist, glints of morning "
|
||||
"light on turquoise water, atmospheric perspective, fine art landscape",
|
||||
(("Natural light", "oversaturated, flat lighting, watermark, text"),),
|
||||
("#255565", "#8cbcc2", "#e8d9bf"),
|
||||
4,
|
||||
),
|
||||
)
|
||||
# app.js:220-227: the image at flat index n has the n % 6 dimensions; with six
|
||||
# prompts per round that is the prompt's own index.
|
||||
CONCEPT_DIMENSIONS = (
|
||||
(1536, 1152),
|
||||
(1024, 1536),
|
||||
(1280, 1280),
|
||||
(1600, 900),
|
||||
(1152, 1536),
|
||||
(1536, 1024),
|
||||
)
|
||||
CONCEPT_IMAGE_ROUNDS = 4
|
||||
CONCEPT_RUN_COUNT = 4
|
||||
# The concept's Model fact (app.js:408).
|
||||
CONCEPT_MODEL = "illustrative-model.safetensors"
|
||||
MAX_EXTRA_IMAGES = 120
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
|
||||
|
||||
def concept_seed(prompt_index: int, round_index: int) -> int:
|
||||
"""The concept's sample seed (app.js:185)."""
|
||||
return 42001 + prompt_index * 100 + round_index
|
||||
|
||||
|
||||
def concept_filename(prompt: ConceptPrompt, round_index: int) -> str:
|
||||
"""The concept's sample filename (app.js:187)."""
|
||||
return f"{prompt.id}_{round_index + 1:05}.png"
|
||||
|
||||
|
||||
def _graph(positive: str, negative: str, seed: int) -> dict:
|
||||
"""A ComfyUI API prompt with the concept's facts (app.js:406-408).
|
||||
|
||||
The recipe is a Prompt Manager pair node, so with its embedded execution record
|
||||
the inspector reports both roles as execution evidence, which the panel badges
|
||||
"Execution recorded" (D21). One sampler keeps the seed unambiguous.
|
||||
"""
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": positive, "negative_text": negative, "clip": ["2", 1]},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": CONCEPT_MODEL},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["2", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["1", 1],
|
||||
"seed": seed,
|
||||
"steps": 28,
|
||||
"cfg": 6.5,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _execution_record(positive: str, negative: str) -> dict:
|
||||
"""The pair node's embedded execution evidence (utils/pair_execution.py)."""
|
||||
record = {
|
||||
"text": positive,
|
||||
"negative_text": negative,
|
||||
"positive_text": positive,
|
||||
"negative_effective_text": negative,
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
}
|
||||
return {"version": 1, "nodes": {"1": {"records": [record]}}}
|
||||
|
||||
|
||||
def _snapshot(text: str) -> PromptSnapshot:
|
||||
"""A recorded prompt snapshot whose raw and effective texts are ``text``."""
|
||||
|
||||
def known(value: str) -> TextEvidence:
|
||||
return TextEvidence(value, "recorded", "runtime_return", None)
|
||||
|
||||
return PromptSnapshot(known(text), known(text), known(""), known(""))
|
||||
|
||||
|
||||
def write_concept_png(path: Path, prompt_index: int, round_index: int) -> dict:
|
||||
"""Write the concept's sample image ``round_index`` of prompt ``prompt_index``.
|
||||
|
||||
Returns its facts: variant, negative, seed and dimensions.
|
||||
"""
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
variant = round_index % len(prompt.negatives)
|
||||
negative = prompt.negatives[variant][1]
|
||||
seed = concept_seed(prompt_index, round_index)
|
||||
width, height = size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)]
|
||||
sky, ground, light = prompt.color
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(_graph(prompt.positive, negative, seed)))
|
||||
info.add_text(
|
||||
EXECUTION_KEY, json.dumps(_execution_record(prompt.positive, negative))
|
||||
)
|
||||
with Image.new("RGB", size, sky) as image:
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((0, height * 2 // 3, width, height), fill=ground)
|
||||
radius = min(width, height) // 9
|
||||
left, top = width * 2 // 3, height // 6
|
||||
draw.ellipse((left, top, left + radius, top + radius), fill=light)
|
||||
image.save(path, pnginfo=info)
|
||||
return {
|
||||
"variant": variant,
|
||||
"negative": negative,
|
||||
"seed": seed,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
|
||||
|
||||
def _clear(api: PromptManagerAPI, root: Path) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
|
||||
|
||||
def _record_run(
|
||||
api: PromptManagerAPI,
|
||||
*,
|
||||
epoch: str,
|
||||
item: int,
|
||||
context: RecipeContext,
|
||||
output: OutputReference,
|
||||
observed_at: int,
|
||||
) -> str:
|
||||
"""One finished execution with its recipe context and its one output."""
|
||||
job = str(uuid5(UUID(SESSION), f"concept-job-{item}"))
|
||||
start = make_start(SESSION, ((item, job, {}, {}), item), epoch, observed_at)
|
||||
api.db.write_execution_start(start)
|
||||
finish = make_finish(
|
||||
SESSION,
|
||||
item,
|
||||
epoch,
|
||||
SimpleNamespace(messages=[("execution_success", {"prompt_id": job})]),
|
||||
observed_at + 10,
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id, epoch, ContextBundle((context,), "complete", "captured")
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((output,), "complete", "reported")
|
||||
)
|
||||
return start.run_id
|
||||
|
||||
|
||||
def seed_workspace_concept(
|
||||
api: PromptManagerAPI, root: Path, *, extra_images: int = 0
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||
"""
|
||||
if isinstance(extra_images, bool) or not isinstance(extra_images, int):
|
||||
raise TypeError("extra_images must be an integer")
|
||||
if not 0 <= extra_images <= MAX_EXTRA_IMAGES:
|
||||
raise ValueError("extra_images is out of range")
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in CONCEPT_PROMPTS:
|
||||
first_name, first_text = prompt.negatives[0]
|
||||
receipt = db.create_library_recipe(
|
||||
prompt.positive,
|
||||
first_text,
|
||||
expected_epoch=epoch,
|
||||
parent_metadata={
|
||||
"title": prompt.title,
|
||||
"category": prompt.category,
|
||||
"tags": list(prompt.tags),
|
||||
"rating": prompt.rating,
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_ids = [receipt["pair"]["id"]]
|
||||
db.update_prompt_pair_metadata(pair_ids[0], title=first_name)
|
||||
for name, text in prompt.negatives[1:]:
|
||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||
# Real queue-hook counting (D23): four runs, the last one now.
|
||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||
prompts.append(
|
||||
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
)
|
||||
|
||||
folder = root / "output" / "concepts"
|
||||
folder.mkdir()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
images, runs = [], []
|
||||
for round_index in range(rounds):
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
if index >= total:
|
||||
break
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
with db.model.get_connection() as conn:
|
||||
counts = conn.execute(
|
||||
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
|
||||
"WHERE id IN (SELECT prompt_id FROM prompt_library_state)"
|
||||
).fetchone()
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
|
||||
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
"prompts": prompts,
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
"tags": tags,
|
||||
}
|
||||
@@ -28,7 +28,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// The pair send lives in the history heading, as in the approved concept.
|
||||
await workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
@@ -60,7 +60,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
// Created through the native editor entry action, with an explicitly empty negative.
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled();
|
||||
await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive);
|
||||
await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0);
|
||||
@@ -75,7 +75,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))};
|
||||
};
|
||||
const before=await evidence();expect(before.pairMetadata).toHaveLength(1);
|
||||
const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
const send=workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
|
||||
for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
|
||||
@@ -17,14 +17,14 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await workspace.getByRole('combobox', { name: 'Theme' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
const imageResponse = workspace.waitForResponse(response => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
|
||||
});
|
||||
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const images = await imageResponse;
|
||||
expect(images.ok()).toBe(true);
|
||||
|
||||
+17
File diff suppressed because one or more lines are too long
@@ -244,6 +244,14 @@ def _workflow_only():
|
||||
}
|
||||
|
||||
|
||||
def _nested(depth):
|
||||
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
|
||||
value = ["leaf", 7]
|
||||
for _ in range(depth):
|
||||
value = [value, None]
|
||||
return value
|
||||
|
||||
|
||||
def _chain(length):
|
||||
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
|
||||
previous = "1"
|
||||
@@ -440,5 +448,14 @@ CASES = {
|
||||
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
|
||||
}
|
||||
),
|
||||
"deeply-nested-values": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
"6": encoder("nested neighbours"),
|
||||
"3": ksampler(("6", 0), ("6", 0)),
|
||||
"8": {"class_type": _nested(900), "inputs": {"text": "x"}},
|
||||
},
|
||||
{"nodes": [{"id": _nested(900), "type": _nested(900)}], "links": []},
|
||||
),
|
||||
"nothing": case(),
|
||||
}
|
||||
|
||||
@@ -555,3 +555,16 @@ test('a setting the file does not record is never filled from the legacy parse',
|
||||
assert.equal(record.generation.cfg.status, 'not-recorded');
|
||||
assert.equal(record.generation.cfg.value, null);
|
||||
});
|
||||
|
||||
test('a workflow the legacy parser throws on keeps the server result instead of failing the record', t => {
|
||||
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
|
||||
t.after(() => { delete globalThis.ComfyMetadata; });
|
||||
const entry = image(), data = envelope(entry);
|
||||
// The browser parser throws on a null workflow node (it reads properties of it).
|
||||
data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [null], links: [] } };
|
||||
data.metadata.raw.workflow = '{"nodes":[null],"links":[]}';
|
||||
data.metadata.record_formats.workflow = 'reserialized-json';
|
||||
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
|
||||
assert.equal(record.prompts.negative.status, 'ambiguous');
|
||||
});
|
||||
|
||||
@@ -76,3 +76,74 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
|
||||
});
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
}};
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
const api = {get: async (url, options) => {
|
||||
calls.push([url, options.signal]);
|
||||
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
|
||||
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => {
|
||||
calls.push(new URL(url, 'http://host').pathname);
|
||||
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
|
||||
}};
|
||||
const settled = [];
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
||||
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ function fixture({ dark = false, preference, fetchImpl, storageDenied = false, p
|
||||
const response = value => ({ ok: true, json: async () => value });
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
test('system uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -41,13 +41,14 @@ test('supported light palettes accept string and wrapped values', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit overrides persist and follow restores host choice', async () => {
|
||||
test('explicit overrides persist and system restores host choice', async () => {
|
||||
const f = fixture({ preference: 'light' }); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
|
||||
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
|
||||
f.theme.setPreference('system'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'system');
|
||||
assert.throws(() => f.theme.setPreference('follow'), /preference/i);
|
||||
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
|
||||
f.scope.dispose();
|
||||
});
|
||||
@@ -132,7 +133,7 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
let url;
|
||||
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
|
||||
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
|
||||
assert.equal(theme.preference, 'follow');
|
||||
assert.equal(theme.preference, 'system');
|
||||
await theme.ready;
|
||||
assert.equal(url, '/api/settings/Comfy.ColorPalette');
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
@@ -140,3 +141,18 @@ test('blocked localStorage getter and missing proxy marker stay usable', async (
|
||||
assert.equal(theme.preference, 'light');
|
||||
scope.dispose();
|
||||
});
|
||||
|
||||
test('stored follow migrates to system and system follows the host palette', async () => {
|
||||
const f = fixture({ preference: 'follow', fetchImpl: async () => response('light') });
|
||||
assert.equal(f.theme.preference, 'system');
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
|
||||
assert.equal(f.root.dataset.caTheme, 'light', 'a known host palette wins over the OS');
|
||||
f.scope.dispose();
|
||||
const unknown = fixture({ preference: 'follow', dark: true, fetchImpl: async () => response('custom') });
|
||||
await unknown.theme.ready;
|
||||
assert.equal(unknown.theme.preference, 'system');
|
||||
assert.equal(unknown.root.dataset.caTheme, 'dark', 'an unknown host palette falls back to the OS');
|
||||
unknown.scope.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { canvasRoot, canvasHref, bindCanvasLink } from '../../web/js/workspace/topbar.mjs';
|
||||
|
||||
// The shared mount-aware resolver (#164), as api-client.js implements it.
|
||||
const resolverAt = pathname => url => {
|
||||
if (!url.startsWith('/prompt_manager/')) return url;
|
||||
const match = pathname.match(/^(.*)\/prompt_manager(?:\/|$)/);
|
||||
return match && match[1] ? match[1] + url : url;
|
||||
};
|
||||
|
||||
function link() {
|
||||
const node = new EventTarget();
|
||||
node.href = '';
|
||||
return node;
|
||||
}
|
||||
function click(node, init = {}) {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.assign(event, { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...init });
|
||||
node.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('the canvas root is the ComfyUI root under the page mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/prompt_manager/workspace')), '/');
|
||||
assert.equal(canvasRoot(resolverAt('/proxy/comfy/prompt_manager/workspace')), '/proxy/comfy/');
|
||||
assert.throws(() => canvasRoot(() => 'https://elsewhere.example/'), /canvas/i);
|
||||
});
|
||||
|
||||
test('the canvas root follows a non-standard proxy mount', () => {
|
||||
assert.equal(canvasRoot(resolverAt('/ComfyBackendDirect/prompt_manager/workspace')), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(resolverAt('/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/prompt_manager/workspace'),
|
||||
'/ComfyBackendDirect/');
|
||||
});
|
||||
|
||||
// A4 review: a resolver failure must never stop the shell from mounting, and the fallback stays on the page's mount.
|
||||
test('a resolver failure falls back to the page mount and never to another origin', () => {
|
||||
const warn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const broken = () => { throw new Error('resolver unavailable'); };
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/ComfyBackendDirect/prompt_manager/workspace/'), '/ComfyBackendDirect/');
|
||||
assert.equal(canvasHref(broken, '/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(() => 'https://elsewhere.example/prompt_manager/', '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(canvasHref(broken, '//elsewhere.example/prompt_manager/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, '/workspace'), '/');
|
||||
assert.equal(canvasHref(broken, undefined), '/');
|
||||
assert.equal(canvasHref(undefined, '/a/prompt_manager/workspace'), '/a/');
|
||||
assert.equal(warnings.length, 8);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
});
|
||||
|
||||
test('ComfyUI link follows its href unless a resolved pair is selected', () => {
|
||||
const node = link();
|
||||
const opened = [];
|
||||
let pair = null;
|
||||
const sender = { open: (roles, trigger) => opened.push([roles, trigger]) };
|
||||
const unbind = bindCanvasLink(node, { getSelectedPair: () => pair, sender, href: '/proxy/' });
|
||||
assert.equal(node.href, '/proxy/');
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
const event = click(node);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(opened, [[{ positive: 'a castle', negative: '' }, node]]);
|
||||
// A modified or secondary click keeps the browser's link behavior.
|
||||
for (const init of [{ metaKey: true }, { ctrlKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
|
||||
assert.equal(click(node, init).defaultPrevented, false);
|
||||
}
|
||||
// One role alone is not a pair.
|
||||
pair = { positive: 'a castle' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
unbind();
|
||||
pair = { positive: 'a castle', negative: '' };
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
assert.equal(opened.length, 1);
|
||||
});
|
||||
|
||||
test('ComfyUI link without a pair source is a plain link', () => {
|
||||
const node = link();
|
||||
const unbind = bindCanvasLink(node, { href: '/' });
|
||||
assert.equal(click(node).defaultPrevented, false);
|
||||
unbind();
|
||||
});
|
||||
@@ -176,6 +176,12 @@ class StaysBounded(unittest.TestCase):
|
||||
self.assertEqual(result["negativePrompt"], "t")
|
||||
|
||||
|
||||
class DeeplyNestedValues(unittest.TestCase):
|
||||
def test_deeply_nested_values_do_not_exhaust_the_python_stack(self):
|
||||
result = run("deeply-nested-values")
|
||||
self.assertEqual(result["positivePrompt"], "nested neighbours")
|
||||
|
||||
|
||||
class Placeholders(unittest.TestCase):
|
||||
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
|
||||
result = run("blank-text-and-zero-seed")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The concept fixture files carry the concept inspector facts (app.js:400-414)."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
CONCEPT_MODEL,
|
||||
CONCEPT_PROMPTS,
|
||||
write_concept_png,
|
||||
)
|
||||
from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file
|
||||
|
||||
|
||||
class TestWorkspaceConceptFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
|
||||
def write(self, prompt_index, round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path = Path(self.tmp.name) / f"{prompt.id}_{round_index + 1:05}.png"
|
||||
image = write_concept_png(path, prompt_index, round_index)
|
||||
return path, image
|
||||
|
||||
def test_each_image_carries_execution_evidence_and_the_concept_facts(self):
|
||||
for prompt_index, round_index in ((0, 0), (0, 2), (3, 1), (5, 3)):
|
||||
with self.subTest(prompt=prompt_index, round=round_index):
|
||||
prompt = CONCEPT_PROMPTS[prompt_index]
|
||||
path, image = self.write(prompt_index, round_index)
|
||||
negative = prompt.negatives[round_index % len(prompt.negatives)][1]
|
||||
inspection = read_inspection_file(path)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata)
|
||||
# D21: "execution" is the source the panel badges "Execution recorded".
|
||||
self.assertEqual(
|
||||
evidence["prompts"]["positive"],
|
||||
{
|
||||
"status": "resolved",
|
||||
"text": prompt.positive,
|
||||
"source": "execution",
|
||||
},
|
||||
)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["text"], negative)
|
||||
self.assertEqual(evidence["prompts"]["negative"]["source"], "execution")
|
||||
graph = json.loads(inspection.metadata["prompt"])
|
||||
sampler = next(
|
||||
node["inputs"]
|
||||
for node in graph.values()
|
||||
if node["class_type"] == "KSampler"
|
||||
)
|
||||
self.assertEqual(
|
||||
sampler["seed"], 42001 + prompt_index * 100 + round_index
|
||||
)
|
||||
self.assertEqual(sampler["seed"], image["seed"])
|
||||
self.assertEqual(
|
||||
(sampler["sampler_name"], sampler["scheduler"]),
|
||||
("dpmpp_2m", "karras"),
|
||||
)
|
||||
self.assertEqual((sampler["steps"], sampler["cfg"]), (28, 6.5))
|
||||
loader = graph[sampler["model"][0]]
|
||||
self.assertEqual(loader["class_type"], "CheckpointLoaderSimple")
|
||||
self.assertEqual(loader["inputs"]["ckpt_name"], CONCEPT_MODEL)
|
||||
self.assertEqual(CONCEPT_MODEL, "illustrative-model.safetensors")
|
||||
with Image.open(path) as source:
|
||||
self.assertEqual(source.size, (image["width"], image["height"]))
|
||||
|
||||
def test_concept_dimensions_follow_the_prompt_index(self):
|
||||
expected = [(1536, 1152), (1024, 1536), (1280, 1280)]
|
||||
for prompt_index, size in enumerate(expected):
|
||||
_, image = self.write(prompt_index, 1)
|
||||
self.assertEqual((image["width"], image["height"]), size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { lintOverlay } from '../../tools/vendor/sync-concept-css.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const script = join(root, 'tools/vendor/sync-concept-css.mjs');
|
||||
const concept = join(root, 'docs/concepts/workspace/style.css');
|
||||
const copy = join(root, 'web/js/workspace/concept.css');
|
||||
const sha = path => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
test('concept copy is byte-identical', () => {
|
||||
assert.ok(existsSync(copy), 'web/js/workspace/concept.css is missing; run sync-concept-css.mjs --write');
|
||||
assert.equal(sha(copy), sha(concept));
|
||||
const result = spawnSync(process.execPath, [script, '--check'], { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('check fails on a one-byte drift', () => {
|
||||
const temp = mkdtempSync(join(tmpdir(), 'concept-css-'));
|
||||
try {
|
||||
mkdirSync(join(temp, 'docs/concepts/workspace'), { recursive: true });
|
||||
mkdirSync(join(temp, 'web/js/workspace'), { recursive: true });
|
||||
copyFileSync(concept, join(temp, 'docs/concepts/workspace/style.css'));
|
||||
writeFileSync(join(temp, 'web/js/workspace/concept.css'), Buffer.concat([readFileSync(concept), Buffer.from(' ')]));
|
||||
const run = () => spawnSync(process.execPath, [script, '--check', '--root', temp], { encoding: 'utf8' });
|
||||
const drifted = run();
|
||||
assert.equal(drifted.status, 1);
|
||||
assert.match(drifted.stderr, /drift/i);
|
||||
assert.equal(spawnSync(process.execPath, [script, '--write', '--root', temp]).status, 0);
|
||||
assert.equal(run().status, 0);
|
||||
} finally { rmSync(temp, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('overlay lint requires a decision marker on concept selectors', () => {
|
||||
const conceptCss = '.card, .row { color: red; }\n@media (max-width: 700px) { .card .title { margin: 0; } }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.error-row { color: red; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '.x, .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '@media (max-width: 700px) { .card .title { margin: 1px; } }').violations.length, 1);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 */\n.card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '.card { /* D3 */ color: blue; }').violations, []);
|
||||
assert.equal(lintOverlay(conceptCss, '/* tweak */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D1 */ .card { color: blue; }\n.row { gap: 1px; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay lint accepts only this spec\'s (#249) decision numbers', () => {
|
||||
const conceptCss = '.card { color: red; }\n';
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* D12 of #249 */ .card { color: blue; }').violations, []);
|
||||
assert.deepEqual(lintOverlay(conceptCss, '/* #249 D3: reason */ .card { color: blue; }').violations, []);
|
||||
// Another issue's decision numbers are not #249 rulings, so they never mark a concept override.
|
||||
assert.equal(lintOverlay(conceptCss, '/* D5 (#245) */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* D2 of #240 and #249 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* #245 decision 5 */ .card { color: blue; }').violations.length, 1);
|
||||
assert.equal(lintOverlay(conceptCss, '/* AD5 */ .card { color: blue; }').violations.length, 1);
|
||||
});
|
||||
|
||||
test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
const overlay = join(root, 'web/js/workspace/workspace.css');
|
||||
assert.ok(existsSync(overlay), 'web/js/workspace/workspace.css is missing');
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(overlay, 'utf8'));
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('only workspace.html loads workspace CSS', () => {
|
||||
const offenders = walk(join(root, 'web'))
|
||||
.filter(path => /\.(html|js|mjs)$/.test(path) && !path.includes('/lib/') && !path.includes('/shared/'))
|
||||
.filter(path => /\b(concept|workspace)\.css\b/.test(readFileSync(path, 'utf8')))
|
||||
.map(path => path.slice(root.length + 1));
|
||||
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
|
||||
// retire, then the production overlay last.
|
||||
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
|
||||
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
|
||||
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(hrefs, [
|
||||
'js/shared/toolkit/controls.css',
|
||||
'lib/viewerjs/viewer.min.css',
|
||||
'js/workspace/concept.css',
|
||||
'js/workspace/images.css',
|
||||
'js/workspace/library.css',
|
||||
'js/workspace/inspector.css',
|
||||
'js/workspace/workspace.css',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
|
||||
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
|
||||
}
|
||||
});
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
// Mount-aware URLs (#164): Prompt Manager runs behind reverse proxies with a path prefix, so every src, href or
|
||||
// new URL() the workspace builds from a "/prompt_manager/..." literal must go through resolveUrl (or the api client).
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const SINK = /(\.(?:src|href)\s*=|setAttribute\(\s*['"](?:src|href)['"]\s*,|new URL\()/g;
|
||||
const RAW = /['"`]\/prompt_manager\//;
|
||||
|
||||
export function rawUrlSinks(source) {
|
||||
const found = [];
|
||||
source.split('\n').forEach((line, index) => {
|
||||
for (const match of line.matchAll(SINK)) {
|
||||
const rest = line.slice(match.index + match[0].length);
|
||||
const literal = rest.search(RAW);
|
||||
if (literal !== -1 && !/\bresolve(?:Url)?\s*\(/.test(rest.slice(0, literal))) found.push(`${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
test('the scanner flags raw Prompt Manager URLs in src, href and new URL()', () => {
|
||||
assert.equal(rawUrlSinks("img.src = '/prompt_manager/images/serve/a.png';").length, 1);
|
||||
assert.equal(rawUrlSinks('link.href = `/prompt_manager/admin`;').length, 1);
|
||||
assert.equal(rawUrlSinks("node.setAttribute('href', '/prompt_manager/gallery');").length, 1);
|
||||
assert.equal(rawUrlSinks("const url = new URL('/prompt_manager/x', location.href);").length, 1);
|
||||
assert.deepEqual(rawUrlSinks("img.src = resolveUrl('/prompt_manager/images/serve/a.png');"), []);
|
||||
assert.deepEqual(rawUrlSinks('const u = new URL(resolve(`/prompt_manager/history/${id}`), location.href);'), []);
|
||||
assert.deepEqual(rawUrlSinks("link.href = resolveUrl(route.href);"), []);
|
||||
});
|
||||
|
||||
test('workspace modules build Prompt Manager src, href and URLs only through resolveUrl', () => {
|
||||
const dir = join(root, 'web/js/workspace');
|
||||
const offenders = readdirSync(dir).filter(name => name.endsWith('.mjs'))
|
||||
.flatMap(name => rawUrlSinks(readFileSync(join(dir, name), 'utf8')).map(hit => `${name}:${hit}`));
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
+13
-1
@@ -161,6 +161,12 @@
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/history-media.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
@@ -205,7 +211,7 @@
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -293,6 +299,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/topbar.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 83,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/viewer-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
|
||||
Vendored
+2
-1
@@ -5,7 +5,8 @@
|
||||
"description": "Maintainer-only tools for reproducible browser dependencies",
|
||||
"scripts": {
|
||||
"sync": "node vendor-viewerjs.mjs",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
|
||||
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check && node sync-concept-css.mjs --check",
|
||||
"sync:concept": "node sync-concept-css.mjs --write",
|
||||
"sync:toolkit": "node vendor-toolkit.mjs",
|
||||
"update": "node update-viewerjs.mjs",
|
||||
"test": "node --test ../../tests/vendor/*.test.mjs",
|
||||
|
||||
Vendored
+130
@@ -0,0 +1,130 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
|
||||
const CONCEPT = 'docs/concepts/workspace/style.css';
|
||||
const COPY = 'web/js/workspace/concept.css';
|
||||
const MARKER = /\/\*[^*]*\bD\d+\b[^*]*\*\//;
|
||||
const SPEC_ISSUE = '#249';
|
||||
|
||||
// A decision marker names one of this spec's (#249) D-numbers. A comment that cites any other issue is that issue's
|
||||
// ruling (for example #245's own D5), so it never marks a concept override here.
|
||||
function isDecisionMarker(comment) {
|
||||
return MARKER.test(comment) && (comment.match(/#\d+\b/g) ?? []).every(issue => issue === SPEC_ISSUE);
|
||||
}
|
||||
|
||||
// Minimal tokenizer: walks top-level rules and the bodies of @media/@supports,
|
||||
// returning each rule's normalized selectors and the comments attached to it
|
||||
// (those before its selector and those inside its block).
|
||||
function parseRules(css) {
|
||||
const rules = [];
|
||||
let index = 0;
|
||||
const length = css.length;
|
||||
function readBlock(start) {
|
||||
let depth = 0;
|
||||
let cursor = start;
|
||||
for (; cursor < length; cursor += 1) {
|
||||
if (css.startsWith('/*', cursor)) { cursor = commentEnd(cursor) - 1; continue; }
|
||||
const char = css[cursor];
|
||||
if (char === '"' || char === "'") { cursor = stringEnd(cursor) - 1; continue; }
|
||||
if (char === '{') depth += 1;
|
||||
else if (char === '}') { depth -= 1; if (depth === 0) return cursor; }
|
||||
}
|
||||
return length - 1;
|
||||
}
|
||||
function commentEnd(at) { const end = css.indexOf('*/', at + 2); return end === -1 ? length : end + 2; }
|
||||
function stringEnd(at) {
|
||||
for (let cursor = at + 1; cursor < length; cursor += 1) {
|
||||
if (css[cursor] === '\\') cursor += 1;
|
||||
else if (css[cursor] === css[at]) return cursor + 1;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
function walk(from, to) {
|
||||
let cursor = from;
|
||||
let comments = [];
|
||||
let prelude = '';
|
||||
while (cursor < to) {
|
||||
if (css.startsWith('/*', cursor)) {
|
||||
const end = commentEnd(cursor);
|
||||
comments.push(css.slice(cursor, end));
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
const char = css[cursor];
|
||||
if (char === ';') { prelude = ''; comments = []; cursor += 1; continue; }
|
||||
if (char === '{') {
|
||||
const close = readBlock(cursor);
|
||||
const head = prelude.trim();
|
||||
const body = css.slice(cursor + 1, close);
|
||||
if (/^@(media|supports|layer)\b/i.test(head)) walk(cursor + 1, close);
|
||||
else if (!head.startsWith('@')) {
|
||||
rules.push({ selectors: splitSelectors(head), comments: [...comments, ...(body.match(/\/\*[\s\S]*?\*\//g) ?? [])] });
|
||||
}
|
||||
prelude = ''; comments = []; cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
prelude += char;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
walk(index, length);
|
||||
return rules;
|
||||
}
|
||||
|
||||
function splitSelectors(head) {
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let current = '';
|
||||
for (const char of head) {
|
||||
if (char === '(' || char === '[') depth += 1;
|
||||
else if (char === ')' || char === ']') depth -= 1;
|
||||
if (char === ',' && depth === 0) { out.push(current); current = ''; } else current += char;
|
||||
}
|
||||
out.push(current);
|
||||
return out.map(selector => selector.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function lintOverlay(conceptCss, overlayCss) {
|
||||
const owned = new Set(parseRules(conceptCss).flatMap(rule => rule.selectors));
|
||||
const violations = [];
|
||||
for (const rule of parseRules(overlayCss)) {
|
||||
const shared = rule.selectors.filter(selector => owned.has(selector));
|
||||
if (shared.length && !rule.comments.some(isDecisionMarker)) {
|
||||
violations.push(`${shared.join(', ')}: overlay rule on a concept selector needs a /* D<n> */ (#249) decision marker`);
|
||||
}
|
||||
}
|
||||
return { violations };
|
||||
}
|
||||
|
||||
export function syncConceptCss({ root = repositoryRoot, check = false } = {}) {
|
||||
const source = readFileSync(join(root, CONCEPT));
|
||||
const target = join(root, COPY);
|
||||
if (check) {
|
||||
let current = null;
|
||||
try { current = readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
if (!current) throw new Error(`Concept stylesheet drift: ${COPY} is missing; run sync-concept-css.mjs --write`);
|
||||
if (!current.equals(source)) {
|
||||
let offset = 0;
|
||||
while (offset < source.length && offset < current.length && source[offset] === current[offset]) offset += 1;
|
||||
throw new Error(`Concept stylesheet drift: ${COPY} (${current.length} bytes) differs from ${CONCEPT} (${source.length} bytes) at byte ${offset}; run sync-concept-css.mjs --write`);
|
||||
}
|
||||
} else {
|
||||
mkdirSync(join(root, 'web/js/workspace'), { recursive: true });
|
||||
writeFileSync(target, source);
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const rootAt = args.indexOf('--root');
|
||||
const root = rootAt === -1 ? repositoryRoot : resolve(args[rootAt + 1]);
|
||||
const mode = args.filter((arg, at) => arg.startsWith('--') && arg !== '--root' && !(rootAt !== -1 && at === rootAt + 1));
|
||||
if (mode.length !== 1 || !['--write', '--check'].includes(mode[0])) throw new Error('Usage: sync-concept-css.mjs --write | --check [--root <dir>]');
|
||||
const bytes = syncConceptCss({ root, check: mode[0] === '--check' });
|
||||
console.log(`Concept stylesheet ${mode[0] === '--check' ? 'verified' : 'written'} (${bytes} bytes)`);
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -217,10 +217,33 @@ def _js_string(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return ",".join("" if _nullish(item) else _js_string(item) for item in value)
|
||||
return _array_string(value)
|
||||
return "[object Object]"
|
||||
|
||||
|
||||
def _array_string(value):
|
||||
"""``String(array)``: ``join(",")`` applied to nested arrays, on an explicit stack.
|
||||
|
||||
null and undefined elements join as "". A crafted file can nest arrays far
|
||||
deeper than the Python stack allows, so this never recurses.
|
||||
"""
|
||||
out = []
|
||||
stack = [(value, 0)]
|
||||
while stack:
|
||||
items, index = stack.pop()
|
||||
if index == len(items):
|
||||
continue
|
||||
if index:
|
||||
out.append(",")
|
||||
stack.append((items, index + 1))
|
||||
item = items[index]
|
||||
if isinstance(item, list):
|
||||
stack.append((item, 0))
|
||||
elif not _nullish(item):
|
||||
out.append(_js_string(item))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _key(value):
|
||||
return value if isinstance(value, str) else _js_string(value)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
// The concept's page heading (docs/concepts/workspace/app.js heading(), 303-305), shared by every workspace route.
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function partsOf(heading) {
|
||||
return {
|
||||
heading,
|
||||
h1: heading.querySelector(':scope > div > h1'),
|
||||
description: heading.querySelector(':scope > div > p.muted'),
|
||||
actions: heading.querySelector(':scope > .heading-actions'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `main`'s content with `.heading` > div (`h1#page-title[tabindex=-1]`, `p.muted`) + `.heading-actions`.
|
||||
* The h1 is the focus target after a save, and the shell focuses it on every route change after the first, as the
|
||||
* concept focuses its page title on hashchange (app.js 1168). The skip link focuses `main` instead.
|
||||
*/
|
||||
export function renderHeading(main, { title = '', description = '', actions = [] } = {}) {
|
||||
const heading = element('div', 'heading');
|
||||
const copy = element('div');
|
||||
const h1 = element('h1', undefined, title);
|
||||
h1.id = 'page-title';
|
||||
h1.tabIndex = -1;
|
||||
copy.append(h1, element('p', 'muted', description));
|
||||
const actionRow = element('div', 'heading-actions');
|
||||
actionRow.append(...actions);
|
||||
heading.append(copy, actionRow);
|
||||
main.replaceChildren(heading);
|
||||
return partsOf(heading);
|
||||
}
|
||||
|
||||
/** The heading the shell rendered into `main`, or null when a view is mounted without one. */
|
||||
export function pageHeading(main) {
|
||||
const heading = main.querySelector(':scope > .heading');
|
||||
return heading ? partsOf(heading) : null;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ const paths = Object.freeze({
|
||||
"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",
|
||||
// D28 ruling: an outline home glyph of its own; the concept's `activity` pulse stays with the Activity row D5 omits.
|
||||
"overview": "M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6",
|
||||
"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",
|
||||
@@ -13,7 +14,13 @@ const paths = Object.freeze({
|
||||
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
|
||||
"send": "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13",
|
||||
"plus": "M12 4v16M4 12h16",
|
||||
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
|
||||
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0",
|
||||
// The concept's remaining vocabulary (docs/concepts/workspace/app.js `paths`), for the pages rebuilt next.
|
||||
"star": "m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z",
|
||||
"tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5",
|
||||
"folder": "M3 5h7l2 3h9v12H3z",
|
||||
"history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2",
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5"
|
||||
});
|
||||
|
||||
/** Create an approved decorative outline icon without injecting markup. */
|
||||
@@ -24,7 +31,7 @@ export function createWorkspaceIcon(name, doc = document) {
|
||||
const namespace = 'http://www.w3.org/2000/svg';
|
||||
const icon = doc.createElementNS(namespace, 'svg');
|
||||
for (const [attribute, value] of Object.entries({
|
||||
class: 'workspace-icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
|
||||
class: 'icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
|
||||
focusable: 'false', fill: 'none', stroke: 'currentColor',
|
||||
'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
||||
})) icon.setAttribute(attribute, value);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.image-toolbar p { margin: 0; font-size: 11px; }
|
||||
.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
|
||||
.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.image-layouts .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-layouts .icon { inline-size: 14px; block-size: 14px; }
|
||||
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
|
||||
.images-main { container: images-page / inline-size; }
|
||||
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
|
||||
@@ -29,7 +29,7 @@
|
||||
.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
|
||||
.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.image-card-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-card-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
|
||||
.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
|
||||
@@ -113,7 +113,7 @@
|
||||
.prompt-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ca-tools .prompt-actions button { display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.prompt-actions .prompt-send-button span { display: none; }
|
||||
.prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-actions .icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.image-preview-details .prompt-inspector { margin-block-start: 0; }
|
||||
|
||||
@@ -4,9 +4,11 @@ import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layo
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -48,11 +50,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
scope.cleanup(() => main.classList.remove('images-main'));
|
||||
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 description = embedded ? null : pageHeading(main)?.description;
|
||||
if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.';
|
||||
const filtersBar = el('div', undefined, 'image-filters');
|
||||
const search = el('input');
|
||||
search.value = filters.q;
|
||||
@@ -423,5 +422,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
if (visit.state.loading || visit.state.error) stream.loadMore();
|
||||
} else reset();
|
||||
return {dispose};
|
||||
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
|
||||
function selectedPair() {
|
||||
const {record, prompts, loading, error, refreshRequired} = inspection.state;
|
||||
if (scope.signal.aborted || selection === null || loading || error || refreshRequired || record?.identity?.key !== selection) return null;
|
||||
const roles = promptRoles(prompts);
|
||||
return Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative') ? roles : null;
|
||||
}
|
||||
return {dispose, selectedPair, sender};
|
||||
}
|
||||
|
||||
+62
-40
@@ -1,11 +1,14 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff } from './state.mjs';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction } from './library.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -26,61 +29,80 @@ function mountWorkspace() {
|
||||
const scope = createScope();
|
||||
const api = createApiClient();
|
||||
const main = document.getElementById('workspace-main');
|
||||
const nav = document.querySelector('nav[aria-label="Workspace"]');
|
||||
const appearance = document.getElementById('appearance-control');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
main.focus();
|
||||
});
|
||||
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
|
||||
const theme = mountWorkspaceTheme({ root: document.body, scope });
|
||||
const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], '');
|
||||
const preference = document.getElementById('theme');
|
||||
preference.value = theme.preference;
|
||||
const appearanceField = createField('Appearance', preference, 'workspace-appearance');
|
||||
appearanceField.classList.add('workspace-appearance');
|
||||
appearance.replaceChildren(appearanceField);
|
||||
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
||||
const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' };
|
||||
const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]];
|
||||
nav.replaceChildren(...groups.map(([name, label, ids]) => {
|
||||
const group = element('div', undefined, `workspace-nav-${name}`);
|
||||
group.append(element('p', label, 'sidebar-label'));
|
||||
for (const id of ids) {
|
||||
const route = routes.find(item => item.id === id);
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon(icons[id]), element('span', route.label));
|
||||
link.href = `#/${route.id}`;
|
||||
link.dataset.route = route.id;
|
||||
group.append(link);
|
||||
}
|
||||
return group;
|
||||
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (D5: no Activity).
|
||||
let images = null;
|
||||
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
|
||||
href: canvasHref(resolveUrl, window.location.pathname),
|
||||
getSelectedPair: () => images?.selectedPair?.() ?? null,
|
||||
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
|
||||
}));
|
||||
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
|
||||
const navLinks = ids => ids.map(([id, icon]) => {
|
||||
const route = routes.find(item => item.id === id);
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
|
||||
link.href = `#/${route.id}`;
|
||||
link.dataset.route = route.id;
|
||||
return link;
|
||||
});
|
||||
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
function refreshCounts(ids) {
|
||||
for (const id of ids) {
|
||||
countRequests.get(id)?.abort();
|
||||
const request = new AbortController();
|
||||
countRequests.set(id, request);
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}, [id]);
|
||||
}
|
||||
}
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
scope.cleanup(() => viewScope?.dispose());
|
||||
|
||||
function render(route) {
|
||||
viewScope?.dispose();
|
||||
images = null;
|
||||
viewScope = createScope();
|
||||
const view = viewScope;
|
||||
for (const link of nav.querySelectorAll('[data-route]')) {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
const heading = element('h1', route.label);
|
||||
// Focus target after a save, as the concept's page title.
|
||||
heading.tabIndex = -1;
|
||||
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();
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
const { description, h1 } = renderHeading(main, { title: route.label });
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
mountImages(main, api, view);
|
||||
images = mountImages(main, api, view);
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
@@ -92,24 +114,24 @@ function mountWorkspace() {
|
||||
return;
|
||||
}
|
||||
if (isHandoff(route)) {
|
||||
headingCopy.append(element('p', route.description, 'intro'));
|
||||
const panel = element('section', undefined, 'panel handoff');
|
||||
description.textContent = route.description;
|
||||
const panel = element('section', undefined, 'surface 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.'),
|
||||
legacyLink(route));
|
||||
main.append(panel);
|
||||
return;
|
||||
}
|
||||
headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
|
||||
description.textContent = 'A home for your prompts, images and creative history.';
|
||||
const stats = element('section', undefined, 'overview-stats');
|
||||
stats.setAttribute('aria-label', 'Library statistics');
|
||||
main.append(stats);
|
||||
const tools = element('section', undefined, 'panel');
|
||||
const tools = element('section', undefined, 'surface');
|
||||
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
|
||||
const cards = element('div', undefined, 'tool-grid');
|
||||
for (const target of routes.filter(item => item.href)) {
|
||||
const card = element('article', undefined, 'tool-card');
|
||||
card.append(element('h3', target.label), element('p', target.description), legacyLink(target));
|
||||
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
|
||||
cards.append(card);
|
||||
}
|
||||
tools.append(cards);
|
||||
@@ -124,7 +146,7 @@ function mountWorkspace() {
|
||||
const values = summarizeStats(result.stats);
|
||||
const list = element('dl', undefined, 'stat-grid');
|
||||
for (const [label, count] of values) {
|
||||
const stat = element('div', undefined, 'panel stat');
|
||||
const stat = element('div', undefined, 'stat');
|
||||
stat.append(element('dt', label), element('dd', count.toLocaleString()));
|
||||
list.append(stat);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,9 @@ function withWorkflowParams(record, parser = comfyMetadata()) {
|
||||
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
|
||||
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
|
||||
if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return;
|
||||
const params = parser.extractGenerationParams({ prompt: graph, workflow });
|
||||
let params;
|
||||
// The browser parser throws on some malformed graphs; then only the server result shows.
|
||||
try { params = parser.extractGenerationParams({ prompt: graph, workflow }); } catch { return; }
|
||||
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
|
||||
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
|
||||
const prompts = { ...record.prompts };
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-inspector-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
|
||||
@media (pointer: coarse) {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* Native recipe editor and vocabulary dialogs, and the shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
|
||||
.library-editor, .vocabulary-dialog, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
|
||||
[data-ca-theme="light"] :where(.library-editor, .vocabulary-dialog, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; }
|
||||
.library-editor::backdrop, .vocabulary-dialog::backdrop { background: #080b12b8; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(form, p) { margin: 0; line-height: inherit; }
|
||||
:is(.library-editor, .vocabulary-dialog) p + p { margin-top: 10px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 32px; font-size: 12px; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .dialog-header > button { font-size: 20px; min-width: 32px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .field textarea { min-height: 100px; resize: vertical; }
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
:is(.library-editor, .vocabulary-dialog) .small { font-size: 12px; color: var(--ca-muted); }
|
||||
:is(.library-editor, .vocabulary-dialog) .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
|
||||
.ca-tools :is(.library-editor, .vocabulary-dialog) .danger { color: var(--editor-red); border-color: var(--editor-red); }
|
||||
:is(.library-editor, .vocabulary-dialog) :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--ca-surface); border: 1px solid var(--ca-accent); padding: 12px 18px; border-radius: 6px; box-shadow: 0 8px 35px #0004; z-index: 10; max-width: min(650px, 90vw); font-size: 12px; }
|
||||
.toast:empty { display: none; }
|
||||
@media (max-width: 700px) {
|
||||
:is(.library-editor, .vocabulary-dialog) .two-col { grid-template-columns: 1fr; }
|
||||
.toast { bottom: 12px; }
|
||||
}
|
||||
@media (pointer: coarse) { .ca-tools :is(.library-editor, .vocabulary-dialog) button { min-height: 40px; } }
|
||||
@@ -123,16 +123,12 @@ main.library-main { --library-negative: #ff8c98; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Heading and panel actions follow the concept (style.css 283-300, 540-549, 1408-1414). */
|
||||
.workspace-heading .heading-actions, .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.workspace-heading .heading-actions { min-inline-size: 0; max-inline-size: 100%; }
|
||||
.ca-tools .workspace-heading button.send { color: var(--ca-focus); border-color: var(--ca-line); display: inline-flex; align-items: center; gap: 6px; }
|
||||
.workspace-heading h1:focus { outline: none; }
|
||||
.workspace-heading h1:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
|
||||
These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */
|
||||
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
|
||||
@media (max-width: 700px) { .workspace-heading .heading-actions { inline-size: 100%; } }
|
||||
@media (pointer: coarse) { .ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
|
||||
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -31,15 +32,29 @@ function title(text) {const first=text.split('\n')[0].trim();return first.length
|
||||
/** The full stored title; the first-line fallback only when it is blank. */
|
||||
function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);}
|
||||
function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}
|
||||
/** The shell heading's action row (concept `.heading-actions`); a view mounted without one gets its own row. */
|
||||
function headingActions(main,fallback) {
|
||||
const actions=el('div',undefined,'heading-actions');(main.querySelector('.workspace-heading') || fallback).append(actions);return actions;
|
||||
const actions=pageHeading(main)?.actions;if(actions) return actions;
|
||||
const row=el('div',undefined,'heading-actions');fallback.append(row);return row;
|
||||
}
|
||||
/** The shell heading's description (concept `p.muted`); a view mounted without one shows it at the top of `fallback`. */
|
||||
function headingDescription(main,fallback,text) {
|
||||
const description=pageHeading(main)?.description;
|
||||
if(description) description.textContent=text;else fallback.prepend(el('p',text,'muted'));
|
||||
}
|
||||
|
||||
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
|
||||
const writeListeners=new Set();
|
||||
export function invalidateLibraryAfterWrite(commit) {
|
||||
if(!commit || typeof commit.kind!=='string' || !commit.receipt || typeof commit.receipt!=='object') throw new TypeError('Invalid editor commit');
|
||||
writeGeneration++;
|
||||
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
|
||||
for(const listener of [...writeListeners]) listener(commit);
|
||||
}
|
||||
/** Observe confirmed Library and vocabulary writes (the sidebar re-reads its counts); returns the unsubscribe. */
|
||||
export function onLibraryWrite(listener) {
|
||||
if(typeof listener!=='function') throw new TypeError('Invalid Library write listener');
|
||||
writeListeners.add(listener);return ()=>{writeListeners.delete(listener);};
|
||||
}
|
||||
/**
|
||||
* Open the Library shelf with exactly these filters (Tags & categories Browse): the
|
||||
@@ -122,7 +137,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro');
|
||||
const toolbar=el('search',undefined,'library-toolbar');
|
||||
const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500;
|
||||
const category=createSelect([['','All categories']], '');category.value=filters.category;
|
||||
@@ -162,7 +176,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
} finally {exporting=false;}
|
||||
}
|
||||
scope.listen(exportButton,'click',exportLibrary);
|
||||
(main.querySelector('.workspace-heading-copy') || section).append(introduction);
|
||||
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
|
||||
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,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;
|
||||
@@ -356,6 +370,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
|
||||
const body=el('div');history.append(body);
|
||||
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
@@ -398,7 +413,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
}
|
||||
detail=state.detail;const content=contentScope;
|
||||
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
@@ -499,7 +513,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(subtitle,summary,variants,recorded,gallery);
|
||||
body.append(summary,variants,recorded,gallery);
|
||||
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
|
||||
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
|
||||
function rolesFor(pairId) {
|
||||
|
||||
@@ -42,6 +42,41 @@ export function summarizeStats(stats) {
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['library', '/prompt_manager/library?limit=1'],
|
||||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
]);
|
||||
|
||||
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
|
||||
export function summarizeNavCounts(pages = {}) {
|
||||
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
|
||||
const total = pages?.[id]?.total;
|
||||
return [id, Number.isSafeInteger(total) && total >= 0 ? total : null];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
|
||||
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
*/
|
||||
/** The WORKSPACE routes that carry a count. */
|
||||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||||
|
||||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||||
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||||
|
||||
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
|
||||
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
|
||||
const pages = {};
|
||||
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
|
||||
try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; }
|
||||
onCount(id, summarizeNavCounts(pages).get(id));
|
||||
}));
|
||||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||||
}
|
||||
|
||||
export function mountRouter(win, onRoute) {
|
||||
let current;
|
||||
const update = () => {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body.ca-tools {
|
||||
margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124;
|
||||
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; --workspace-active: #e4f1fb; }
|
||||
[hidden] { display: none !important; }
|
||||
a { color: var(--ca-focus); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.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; }
|
||||
.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: 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; }
|
||||
.intro { color: var(--ca-muted); margin: 0 0 30px; }
|
||||
.panel { border: 1px solid var(--ca-line); border-radius: 11px; padding: 24px; background: var(--ca-surface); }
|
||||
.panel > p { color: var(--ca-muted); margin: 0 0 24px; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
|
||||
.stat dt { color: var(--ca-muted); font-size: 12px; }
|
||||
.stat dd { margin: 8px 0 0; font-size: 32px; font-weight: 600; }
|
||||
.overview-stats > [role="status"], .overview-stats > [role="alert"], .empty-state { padding: 18px 0; color: var(--ca-muted); }
|
||||
.overview-stats > [role="alert"] { color: var(--ca-error-text); }
|
||||
.overview-stats > button { margin-bottom: 24px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; }
|
||||
.tool-card { border: 1px solid var(--ca-line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tool-card p { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; flex: 1; }
|
||||
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.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: 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 { 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 14px; flex-wrap: wrap; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
const STORAGE_KEY = 'pm_workspace_theme';
|
||||
const PREFERENCES = new Set(['follow', 'light', 'dark']);
|
||||
// D4 (#249): the concept's Theme select. "system" follows the ComfyUI palette, then the OS.
|
||||
const PREFERENCES = new Set(['dark', 'light', 'system']);
|
||||
// The pre-#249 "Follow ComfyUI" value, read as System so a saved choice survives the rename.
|
||||
const LEGACY = new Map([['follow', 'system']]);
|
||||
const LIGHT_PALETTES = new Set(['light', 'solarized', 'github', 'arc']);
|
||||
const DARK_PALETTES = new Set(['dark', 'obsidian', 'nord', 'dracula', 'gruvbox']);
|
||||
|
||||
@@ -9,19 +12,20 @@ export function mountWorkspaceTheme({ root = document.body, scope, fetchImpl = g
|
||||
const previousTheme = root.dataset.caTheme;
|
||||
const previousScheme = documentElement.style.colorScheme;
|
||||
const system = win.matchMedia('(prefers-color-scheme: dark)');
|
||||
let preference = 'follow';
|
||||
let preference = 'system';
|
||||
let hostTheme = null;
|
||||
let request = null;
|
||||
let sequence = 0;
|
||||
try {
|
||||
storage ??= win.localStorage;
|
||||
const saved = storage?.getItem(STORAGE_KEY);
|
||||
const stored = storage?.getItem(STORAGE_KEY);
|
||||
const saved = LEGACY.get(stored) ?? stored;
|
||||
if (PREFERENCES.has(saved)) preference = saved;
|
||||
} catch { /* Storage can be disabled while theme controls remain usable. */ }
|
||||
|
||||
function update() {
|
||||
if (scope.signal.aborted) return;
|
||||
const theme = preference === 'follow' ? hostTheme || (system.matches ? 'dark' : 'light') : preference;
|
||||
const theme = preference === 'system' ? hostTheme || (system.matches ? 'dark' : 'light') : preference;
|
||||
root.dataset.caTheme = theme;
|
||||
documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// The concept topbar's "ComfyUI ↗" link (D6 of #249).
|
||||
|
||||
const PROMPT_MANAGER = /prompt_manager\/$/;
|
||||
|
||||
/** The ComfyUI canvas root under the page's mount: the shared resolver's Prompt Manager root minus its own segment. */
|
||||
export function canvasRoot(resolve) {
|
||||
const root = resolve('/prompt_manager/');
|
||||
if (typeof root !== 'string' || !root.startsWith('/') || root.startsWith('//') || !PROMPT_MANAGER.test(root)) {
|
||||
throw new TypeError('Cannot resolve the ComfyUI canvas root');
|
||||
}
|
||||
return root.replace(PROMPT_MANAGER, '');
|
||||
}
|
||||
|
||||
const PAGE_MOUNT = /^(.*\/)prompt_manager(?:\/|$)/;
|
||||
|
||||
/**
|
||||
* The canvas link's href. A resolver failure must never stop the shell from mounting, so it falls back to the
|
||||
* ComfyUI root read from the page's own path (the #164 proxy mount), and to the origin root when even that is not a
|
||||
* same-origin root-relative path.
|
||||
*/
|
||||
export function canvasHref(resolve, pathname) {
|
||||
try {
|
||||
return canvasRoot(resolve);
|
||||
} catch (error) {
|
||||
console.warn('Prompt Manager: the ComfyUI link falls back to the page mount.', error);
|
||||
const mount = typeof pathname === 'string' ? pathname.match(PAGE_MOUNT)?.[1] : undefined;
|
||||
return mount && !mount.startsWith('//') ? mount : '/';
|
||||
}
|
||||
}
|
||||
|
||||
const plainClick = event => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
||||
const isPair = roles => roles !== null && typeof roles === 'object' &&
|
||||
typeof roles.positive === 'string' && typeof roles.negative === 'string';
|
||||
|
||||
/**
|
||||
* Point the link at the canvas. A plain click while `getSelectedPair()` returns both resolved roles opens the
|
||||
* acknowledged pair send instead, as the concept does; otherwise the browser follows the link. Returns an unbind.
|
||||
*/
|
||||
export function bindCanvasLink(link, { getSelectedPair = () => null, sender, href }) {
|
||||
link.href = href;
|
||||
const onClick = event => {
|
||||
if (event.defaultPrevented || !plainClick(event) || !sender) return;
|
||||
const roles = getSelectedPair();
|
||||
if (!isPair(roles)) return;
|
||||
event.preventDefault();
|
||||
sender.open({ positive: roles.positive, negative: roles.negative }, link);
|
||||
};
|
||||
link.addEventListener('click', onClick);
|
||||
return () => link.removeEventListener('click', onClick);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Native Tags & categories: geometry and tokens follow the approved concept (style.css 79-99, 283-304, 709-731, 840-895, 1195-1202, 1408-1526). */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-inline-size: 0; }
|
||||
.vocabulary-main .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.vocabulary-main .surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; }
|
||||
.vocabulary-main .surface + .surface { margin-top: 18px; }
|
||||
.vocabulary-main .surface :where(h2, h3, p) { margin: 0; line-height: 1.5; }
|
||||
.vocabulary-main .surface h2 { font-size: 17px; font-weight: 600; }
|
||||
.vocabulary-main .surface > p { font-size: 12px; color: var(--ca-muted); margin-top: 7px; }
|
||||
.vocabulary-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 32px; font-size: 12px; }
|
||||
.vocabulary-main .operation { display: flex; justify-content: space-between; gap: 18px; align-items: center; padding: 15px 0; border-top: 1px solid var(--ca-line); }
|
||||
.vocabulary-main .operation h3 { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.vocabulary-main .operation p { font-size: 11px; color: var(--ca-muted); margin-top: 5px; }
|
||||
.vocabulary-main .operation .row-actions { flex-shrink: 0; }
|
||||
.vocabulary-main .table-wrap { overflow-x: auto; }
|
||||
.vocabulary-main table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
.vocabulary-main caption { text-align: start; font-size: 13px; font-weight: 600; margin: 0 0 15px; }
|
||||
.vocabulary-main :where(th, td) { text-align: start; border-bottom: 1px solid var(--ca-line); padding: 13px 10px; }
|
||||
.vocabulary-main th { color: var(--ca-muted); font-size: 10px; font-weight: 500; }
|
||||
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width and no row action is clipped;
|
||||
on two-column screens the minimum keeps short names (the concept's longest sample, "architecture", is about 5em) on
|
||||
one line, as the concept shows. */
|
||||
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-inline-size: calc(6em + 20px); }
|
||||
.vocabulary-main td .row-actions { justify-content: flex-end; }
|
||||
.vocabulary-main .empty { padding: 50px; text-align: center; border: 1px dashed var(--ca-line); border-radius: 6px; color: var(--ca-muted); }
|
||||
/* Never a scroll anchor: appended rows must push the end of the list away, not chain further pages. */
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
/* Focus rings come from the toolkit (2px --ca-focus, offset 2px), which is what the concept renders: its own 3px rule loses to controls.css. */
|
||||
@media (max-width: 700px) {
|
||||
/* Concept .heading stacks with align-items: flex-start (style.css 1408-1414); the shared shell computes `start`. */
|
||||
.vocabulary-main .workspace-heading { align-items: flex-start; }
|
||||
.vocabulary-main .two-col { grid-template-columns: 1fr; }
|
||||
.vocabulary-main .operation { align-items: flex-start; flex-direction: column; }
|
||||
.vocabulary-main .operation .row-actions { flex-wrap: wrap; }
|
||||
.vocabulary-main .table-wrap { margin-inline: -10px; }
|
||||
/* One column: the fit must not depend on font metrics. The Tag column has no fixed minimum, and the Actions column
|
||||
asks for no more than its stacked width (the concept's 390px rows stack Browse over Rename / merge), so all space
|
||||
left after Prompts, Images and the stacked actions goes to the tag name. Short names stay on one line wherever the
|
||||
concept's own table fits; with a wider fallback font the name wraps instead of pushing an action out of view. */
|
||||
.vocabulary-main td:first-child { min-inline-size: 0; }
|
||||
.vocabulary-main td .row-actions { inline-size: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .vocabulary-main .row-actions button, .ca-tools .vocabulary-main .vocabulary-continuation button { min-height: 40px; }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { browseFilters, createVocabularyCommand, createVocabularyStream } from '
|
||||
import { countOf } from './plural.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
@@ -155,12 +156,10 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after}) {
|
||||
export function mountVocabulary(main, api, scope) {
|
||||
main.classList.add('vocabulary-main');
|
||||
scope.cleanup(() => main.classList.remove('vocabulary-main'));
|
||||
const heading = main.querySelector('.workspace-heading');
|
||||
heading.querySelector('.workspace-heading-copy').append(el('p', 'Organize once. Reuse everywhere.', 'intro'));
|
||||
const actions = el('div', undefined, 'heading-actions');
|
||||
const { description, actions } = pageHeading(main);
|
||||
description.textContent = 'Organize once. Reuse everywhere.';
|
||||
const create = createButton('+ Create tag');
|
||||
actions.append(create);
|
||||
heading.append(actions);
|
||||
|
||||
const grid = el('div', undefined, 'two-col');
|
||||
const tagSurface = el('section', undefined, 'surface');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* Production overlay on the verbatim concept base (concept.css, D1 of #249). Only production-only states and recorded
|
||||
deviations live here; a rule on a selector the concept also styles carries its decision marker. */
|
||||
|
||||
/* Production-only shell states. */
|
||||
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* The page title takes focus after a save or route change (concept app.js 1168); a keyboard user sees where it went. */
|
||||
#page-title:focus { outline: none; }
|
||||
#page-title:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* D28: the sidebar shows no connection state, so its dot is neutral instead of the concept's demo green. */
|
||||
.connection i { background: var(--ca-muted); }
|
||||
/* D31: bounded rendering reads the grid's real width, so at extreme zoom the one-column shell must not grow to the
|
||||
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
|
||||
@media (max-width: 950px) {
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
}
|
||||
|
||||
/* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
|
||||
.stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; }
|
||||
.stat dt { color: var(--ca-muted); font-size: 12px; }
|
||||
.stat dd { margin: 8px 0 0; font-size: 32px; font-weight: 600; }
|
||||
.overview-stats > [role="status"], .overview-stats > [role="alert"], .empty-state { padding: 18px 0; color: var(--ca-muted); }
|
||||
.overview-stats > [role="alert"] { color: var(--ca-error-text); }
|
||||
.overview-stats > button { margin-bottom: 24px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; margin-top: 22px; }
|
||||
.tool-card { border: 1px solid var(--line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tool-card p { flex: 1; margin: 0 0 12px; }
|
||||
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.handoff .workspace-link { margin-top: 12px; }
|
||||
@media (max-width: 700px) {
|
||||
.stat-grid { gap: 8px; }
|
||||
.stat { padding: 16px; }
|
||||
.stat dd { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
|
||||
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
|
||||
.vocabulary-main .operation h3 { overflow-wrap: anywhere; }
|
||||
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width; on two-column screens the
|
||||
minimum keeps the concept's longest sample ("architecture", about 5em) on one line. */
|
||||
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-width: calc(6em + 20px); }
|
||||
/* Tags & categories (#245 decision 5): bounded lists continue with Load more; never a scroll anchor, so appended rows push the end away. */
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
@media (max-width: 700px) {
|
||||
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
|
||||
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */
|
||||
.vocabulary-main td:first-child { min-width: 0; }
|
||||
.vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
|
||||
/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text
|
||||
that keep their own design until their page PR rebuilds them in concept markup. */
|
||||
.panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */
|
||||
.ca-tools dialog.image-preview { width: min(1500px, 96%); max-height: 94%; padding: 18px; overflow: hidden; } /* PR C: viewer becomes `#viewer-dialog` */
|
||||
.ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */
|
||||
.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */
|
||||
.ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */
|
||||
pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */
|
||||
.viewer-container .viewer-hide { display: none; } /* PR C: the concept's own `.viewer-toolbar` would reveal Viewer.js's hidden toolbar */
|
||||
.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ca-tools dialog.image-preview { overflow: auto; } /* PR C */
|
||||
.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */
|
||||
}
|
||||
+22
-11
@@ -6,30 +6,41 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Prompt Manager · Workspace</title>
|
||||
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
|
||||
<link rel="stylesheet" href="js/workspace/style.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
<link rel="stylesheet" href="js/workspace/concept.css">
|
||||
<link rel="stylesheet" href="js/workspace/images.css">
|
||||
<link rel="stylesheet" href="js/workspace/library.css">
|
||||
<link rel="stylesheet" href="js/workspace/inspector.css">
|
||||
<link rel="stylesheet" href="js/workspace/library-editor.css">
|
||||
<link rel="stylesheet" href="js/workspace/vocabulary.css">
|
||||
<link rel="stylesheet" href="js/workspace/workspace.css">
|
||||
<script src="lib/viewerjs/viewer.min.js" defer></script>
|
||||
<script src="js/comfy-metadata.js" defer></script>
|
||||
<script src="js/gallery-settings.js" defer></script>
|
||||
<script type="module" src="js/workspace/index.mjs"></script>
|
||||
</head>
|
||||
<body class="ca-tools">
|
||||
<a class="skip" href="#workspace-main">Skip to content</a>
|
||||
<div class="workspace-shell">
|
||||
<a class="skip" href="#workspace-main">Skip to workspace</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<nav aria-label="Workspace"></nav>
|
||||
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
|
||||
<a class="brand" href="#/images"><span class="brand-mark">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace-body">
|
||||
<header class="topbar"><span>ComfyAssets <span class="muted">/ <span id="workspace-breadcrumb">Images</span></span></span><div id="appearance-control"></div></header>
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumbs">ComfyAssets <span>/</span> <span id="workspace-breadcrumb">Images</span></div>
|
||||
<div class="top-actions"><label class="theme-label" for="theme">Theme
|
||||
<select id="theme" aria-label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system" selected>System</option>
|
||||
</select></label><a class="canvas-link" href="../">ComfyUI <span aria-hidden="true">↗</span></a></div>
|
||||
</header>
|
||||
<main id="workspace-main" tabindex="-1"></main>
|
||||
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
|
||||
<footer class="app-footer"><span>ComfyAssets · Local tools. Your library.</span></footer>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
Reference in New Issue
Block a user