diff --git a/TESTING.md b/TESTING.md
index 7d83e54..1e6c4b1 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -227,7 +227,12 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
response limits, source changes, nested sending, matched dark/light and mobile
-geometry, and the loaded-collection viewer. The viewer never fetches another page
+geometry, and the loaded-collection viewer. `viewer matches the concept shell and
+toolbar order` measures the concept's `#viewer-dialog` shell, header copy, toolbar
+order and frame geometry in the concept and in production at 1600 and 390 pixels;
+its #249 D34 mapping keeps production's Download link, the appended position and
+reason lines, coarse 40px targets and the toolbar inside `.viewer-left` (Viewer.js's
+own `.viewer-toolbar` rule lifts the concept's across the dialog). The viewer never fetches another page
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
readiness, pauses at native video without advancing on `ended`, and resumes on a
manually selected image only while prior intent remains. Pause/Stop, inspector
@@ -359,12 +364,14 @@ selector, so a narrower selector over a concept rule (for example
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.
+The lint also runs over every page stylesheet that loads before the overlay and is
+still kept (`images.css`, `library.css`, `inspector.css`; test `kept page stylesheets
+mark their concept-selector overrides`). After PR C, `images.css` holds only the side
+panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel
+and deletes with the file. Its three remaining concept-selector overrides
+(`.prompt-text`, `.prompt-actions`, `.ca-tools .prompt-actions button`) are marked
+`#249 D22 interim` until then, and `images.css keeps no retired Images page rules`
+keeps the replaced card, grid, filter, toolbar, preview and facts rules out.
**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
@@ -423,6 +430,125 @@ npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
+`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading,
+toolbar, filter row, caption, grid, the first six cards, the continuation and the panel
+box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844
+in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns,
+275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its
+own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production
+40); the Load more row is compared before the press in both documents. Behaviors: hover
+zoom, the 2nd card opens the viewer titled "
· ", Escape returns focus
+to it (production only: the concept re-renders its grid and drops focus), Info on the
+3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
+its toast. `mapImagesConcept` applies D3, D8 and D16 (follow-up PRs: no checkbox, no
+Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
+sorted alphabetically). Removed toolbar and filter-row items flow again from the
+concept's own boxes (`reflow`), checked against the concept page with those items
+removed at runtime. Masonry tops and heights are compared unrounded, within one layout
+unit per card above in the column (D3). `one footer height for the collection (D3)`
+seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
+that every row reserves the tallest footer.
+
+### Images page controls and recorded deviations (#249 PR C)
+
+The Images page renders the concept's DOM with real data. Its controls, top to bottom:
+
+- **Heading**: "Images" / "Your generations, their prompts, and the next idea.",
+ `Import` (opens "Import & inspect") and the shell's `New prompt`.
+- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
+ prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
+ Library title, and tag names), `#category-filter` "All categories" plus the
+ Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
+ first and Name Z–A), and `Filters` (opens "Refine this view").
+- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
+ "Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
+ or " · "), and "Group by positive prompt →" (goes to the Library).
+- **Caption**: "N of M images · K positive prompts" (`#result-count`) and, under
+ Recently added only, the newest result's day.
+- **Grid** (`#image-grid`): concept media cards. The image opens the viewer
+ (click, Enter, Space); Info selects the card into the side panel; the star toggles
+ the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
+ DOM small, and returning to Images restores filters, layout, size, selection and
+ scroll.
+- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
+ `Load more images` and `Retry` do the same by hand, with a toast per batch.
+- **Viewer** (`dialog#viewer-dialog`): " · ", the concept toolbar
+ (a "Media controls" group), the shared file inspector beside the stage, and Escape
+ returns focus to the opening image.
+
+How the earlier workspace Images filters moved (old → new). The legacy `/gallery`,
+`/admin` and `/metadata` pages are unchanged.
+
+| Before PR C | Now |
+| --- | --- |
+| "Search filenames" (filename or folder) | toolbar search, which also matches linked prompt text, title and tags (D12) |
+| Source | "Refine this view" → Source (D14); shown in the scope chip |
+| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
+| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
+| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
+| (none) | toolbar category and "Refine this view" → Tags, through the linked positive (D13, D14) |
+| Refresh | Import → "Scan an output folder" → Rescan (D15) |
+| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
+| Card image → side panel; preview icon → viewer | image → viewer; Info → side panel (D19) |
+| "All images" heading and count | the concept caption and count line (D17, D35) |
+
+Recorded deviations on this page (spec §8):
+- **D3**: bounded rendering stays. Every grid row reserves the tallest measured card
+ footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
+ card above in the column), and a List row's info takes its natural height, centred
+ beside the 160×95 art.
+- **D5**, **D29**: no Activity button and no prototype copy (shell).
+- **D7**: the star is a real persisted favorite with "Added to favorites." /
+ "Removed from favorites." and a rollback toast on failure.
+- **D8 (follow-up)**: no select checkbox or bulk bar in PR C; a follow-up PR adds them
+ with real actions and removes the interim `.image-overlays > .star-button:only-child`
+ rule that keeps the star top-right.
+- **D9**: no Tag button until the image-annotations slice.
+- **D10**: the card title is the linked positive's display title, else the filename;
+ the `h3` keeps the relative path in `title`.
+- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
+- **D14**: Source, Folder, Media and Tags live in "Refine this view" (no "Prompt
+ evidence" field, no scope note); the primary action reads "Apply filters"; extra
+ sorts come after the concept's three; long folder names wrap inside the scope chip.
+ Folder lists only the chosen Source's folders (each workspace root lists its own),
+ and an unchanged Apply or Clear reloads nothing.
+- **D15**: Import offers "Inspect a local image" (`#/inspect`) and "Scan an output
+ folder" (Rescan, `/gallery/rescan`); no "Import portable recipes". The concept has
+ no rescan copy: toasts say "Rescanning output folders…", then "Rescan finished. N
+ files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
+ finish. Try again."; while it runs, a reopened Import shows an aria-disabled
+ "Rescanning…".
+- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
+- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
+ outside the current year), hidden under every other sort.
+- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
+- **D32**: the layout control may wrap, so the filter and continuation rows never
+ overflow at 390px and 200% zoom.
+- **D33**: after Load more or Retry, focus moves to the first new image without
+ scrolling, and only while focus is still on the pressed control or `body`.
+- **D34**: the viewer keeps its toolbar under the stage in `.viewer-left`, a flex
+ column above 700px, `max-height: min(94dvh, 94%)`, the "Download original" link,
+ the appended position and reason lines, full-stage video and failure overlay,
+ 40×40 coarse targets, production's control names and "Media controls" group, and
+ `overscroll-behavior: contain`.
+- **D35**: counts pluralize ("1 positive prompt", "1 image", "1 more image loaded").
+- **D36**: the category list is the Library's, sorted alphabetically (case-insensitive)
+ by the page, whatever order the server sends.
+- **D37**: production loads 40 images per page (the concept 12), so the continuation
+ and toast use 40; after Escape production returns focus to the opening image.
+
+Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
+click it (the viewer reads " · "; Escape closes it); press a card's
+Info (the side panel changes); search a word from a prompt (matching images remain);
+open Filters (Source, Folder and Media are there); Import → Rescan picks up a newly
+saved file.
+
+```sh
+npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
+ workspace-images.spec.mjs full-inspector.spec.mjs \
+ --project chromium --project touch --project firefox --project webkit
+```
+
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -440,9 +566,9 @@ and skip the parse.
`unavailable` when the database read failed (logged, no error text). Treat
anything but `linked` as the filename fallback. In pair scope (the image has
exactly one distinct pair), `positive_id` is the pair's positive, and the title
- comes from the pair title, then the positive's Library title, then the text's
- first line. In positive scope it comes from the Library title, then the first
- line. A first line longer than 80 code points is cut to 80 plus `…`, like
+ comes from that positive's Library title, then its text's first line, never
+ from the pair's own title, which names the negative variant (#249 D10/D20). In
+ positive scope it comes from the Library title, then the first line. A first line longer than 80 code points is cut to 80 plus `…`, like
`library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category`
and `rating` come from the linked positive (D13), not from the pair. The title
gate is shared with the inspector (`_verified_title`): a `stored` title always
@@ -484,9 +610,11 @@ and skip the parse.
Filters (`mode=workspace`; anything invalid is 400 "Invalid gallery filters"):
- `q` (at most 500 characters). An entry passes when the case-folded relative path
- contains it, or when its linked positive's text, Library title, a tag name, or
- its pair title contains it (SQLite `LIKE` with escaping, ASCII case folding
- only). Search does not apply the card title gate, and does not search pair tags.
+ contains it, or when its linked positive's text, Library title or a tag name
+ contains it (SQLite `LIKE` with escaping, ASCII case folding only). A pair's own
+ title names its negative variant, so it is not searched (#249 D12): a variant
+ name alone matches nothing. Search does not apply the card title gate, and does
+ not search pair tags.
- `category` (exact and case-sensitive, at most 200 characters, stripped; blank
means none) and `tags` (a comma list, blanks dropped, order-free, AND, at most 30
tags of at most 200 characters) admit linked images only, through the linked
@@ -527,7 +655,11 @@ Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` wit
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
non-media name, or a key over the stored bound. PUT needs a listed file (404
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
-body as `application/json`.
+body as `application/json` (`api.del(url, {body})` in the shared API client).
+The Images card star (#249 C5) toggles optimistically, allows one request per file
+at a time, and on any failure restores the previous state and announces it in the
+shared toast. The browser fixtures clear `image_favorites` on reset, and the
+concept fixture stars the concept's favorites (each other prompt's first image).
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
is the root's real path (`os.path.realpath`), case-normalized with `/`
@@ -835,9 +967,9 @@ page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
-distinctions are documented in the comparison helper. Broader Images card
-activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
-and shared role-block spacing/padding/glyph polish remain outside #209. The
+distinctions are documented in the comparison helper. The Images card select
+checkbox (#249 D8 follow-up) and tag button (#249 D9) and shared role-block
+spacing/padding/glyph polish remain outside #209. The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
a whole-application or full-gallery/Viewer pixel-parity claim.
diff --git a/database/operations.py b/database/operations.py
index 3fb9f18..6abdb05 100644
--- a/database/operations.py
+++ b/database/operations.py
@@ -184,10 +184,12 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
# parameters per path stays under SQLite's historical 999-variable limit.
LINK_SUMMARY_BATCH = 200
# A pair link counts only when an image has exactly one distinct pair, as in
-# get_image_prompt_info; its positive, title and metadata then describe the card.
+# get_image_prompt_info; its positive (text, Library title, category, rating)
+# then describes the card. The pair's own title names its negative variant, so
+# it is not read (D10).
_LINK_SUMMARY_SQL = """SELECT gi.image_path, gi.file_path, gi.prompt_id,
p.text, p.category, p.rating, ls.title,
- pp.positive_prompt_id, pp.title, pt.text, pt.category, pt.rating, pls.title
+ pp.positive_prompt_id, pt.text, pt.category, pt.rating, pls.title
FROM generated_images gi
JOIN prompts p ON gi.prompt_id = p.id
LEFT JOIN prompt_library_state ls ON ls.prompt_id = p.id
@@ -213,15 +215,15 @@ JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
SELECT 1 FROM generated_images other
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
-# The positive's text, its Library title or one of its tag names, or the pair
-# title, contains :pattern (LIKE, so ASCII case is ignored as in Library search).
-_LINK_FILTER_HIT = """(p.id IN (
+# The positive's text, its Library title or one of its tag names contains
+# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
+# title names its negative variant and is never searched (#249 D12).
+_LINK_FILTER_HIT = """p.id IN (
SELECT hp.id FROM prompts hp
LEFT JOIN prompt_library_state hls ON hls.prompt_id = hp.id
WHERE hp.text LIKE :pattern ESCAPE '\\' OR hls.title LIKE :pattern ESCAPE '\\'
OR hp.id IN (SELECT ht.prompt_id FROM prompt_tags ht JOIN tags hn
- ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))
- OR pp.title LIKE :pattern ESCAPE '\\')"""
+ ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))"""
_LINK_FILTER_TAG = (
"p.id IN (SELECT ft.prompt_id FROM prompt_tags ft JOIN tags fn "
"ON fn.id = ft.tag_id WHERE fn.name = :tag{index})"
@@ -277,8 +279,9 @@ def _empty_link_summary(status: str) -> Dict[str, Any]:
def _link_summary(row: tuple) -> Dict[str, Any]:
if row[7] is not None:
- positive, text, category, rating = row[7], row[9], row[10], row[11]
- titles = (row[8], row[12])
+ positive, text, category, rating = row[7], row[8], row[9], row[10]
+ # The linked positive's Library title (D10/D20).
+ titles = (row[11],)
else:
positive, text, category, rating = row[2], row[3], row[4], row[5]
titles = (row[6],)
@@ -2338,7 +2341,8 @@ class PromptDatabase(
Rows are limited to images whose linked positive has *category* and
every tag in *tags* (exact names). ``hit`` says whether *q* occurs in
- the positive's text, Library title or a tag name, or in the pair title.
+ the positive's text, Library title or a tag name; a pair's own title
+ names its negative variant and is not searched.
Without category or tags only hits are returned, unless *every_link*
asks for every linked image (the rating sort and the positive count).
The positive id is the card's linked positive. One SELECT whose rows
diff --git a/py/api/images.py b/py/api/images.py
index e46e49f..75a51d1 100644
--- a/py/api/images.py
+++ b/py/api/images.py
@@ -1422,45 +1422,56 @@ class ImageRoutesMixin:
{"success": False, "error": "Failed to rescan the gallery"}, status=500
)
+ async def _root_subfolders(self, output_path, include_ancestors):
+ """The distinct folders holding listed files under one output root."""
+ subfolders = set()
+ for f, _mtime in await self._get_gallery_files(output_path):
+ rel_dir = str(f.relative_to(output_path).parent)
+ if rel_dir and rel_dir != ".":
+ subfolders.add(rel_dir)
+ if include_ancestors:
+ ancestors = set()
+ for folder in subfolders:
+ parts = folder.replace("\\", "/").split("/")
+ for i in range(1, len(parts)):
+ ancestors.add("/".join(parts[:i]))
+ subfolders.update(ancestors)
+ return subfolders
+
async def get_gallery_subfolders(self, request):
- """Get distinct subfolders from gallery output directories."""
+ """Get distinct subfolders from gallery output directories.
+
+ In workspace mode every root also lists its own folders, so the
+ Refine dialog can offer only the chosen Source's folders.
+ """
try:
output_dirs = list(self._get_all_output_dirs())
workspace = request.query.get("mode") == "workspace"
- roots = None
+ include_ancestors = (
+ request.query.get("include_ancestors", "").lower() == "true"
+ )
+ selected = None
if workspace:
try:
selected = _workspace_root(request.query, output_dirs)
except ValueError as exc:
return bad_request(str(exc))
- roots = [
- {"index": index, "label": _public_path(root)}
- for index, root in enumerate(output_dirs)
- ]
- if selected is not None:
- output_dirs = [output_dirs[selected]]
- subfolders = set()
- for output_path in output_dirs:
- files = await self._get_gallery_files(output_path)
- for f, _mtime in files:
- rel_dir = str(f.relative_to(output_path).parent)
- if rel_dir and rel_dir != ".":
- subfolders.add(rel_dir)
-
- include_ancestors = (
- request.query.get("include_ancestors", "").lower() == "true"
- )
- if include_ancestors:
- ancestors = set()
- for folder in subfolders:
- parts = folder.replace("\\", "/").split("/")
- for i in range(1, len(parts)):
- ancestors.add("/".join(parts[:i]))
- subfolders.update(ancestors)
-
+ walked = [
+ await self._root_subfolders(output_path, include_ancestors)
+ for output_path in output_dirs
+ ]
+ chosen = walked if selected is None else [walked[selected]]
+ subfolders = set().union(*chosen)
payload = {"success": True, "subfolders": sorted(subfolders)}
if workspace:
- payload["roots"] = roots
+ payload["roots"] = [
+ {
+ "index": index,
+ "label": _public_path(root),
+ "subfolders": sorted(walked[index]),
+ }
+ for index, root in enumerate(output_dirs)
+ ]
return web.json_response(payload)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index cc20579..7e0b560 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -11,6 +11,10 @@ export const SIZES=Object.freeze([[1600,1100],[390,844]]);
export const THEMES=Object.freeze(['dark','light']);
export const MOTIONS=Object.freeze(['no-preference','reduce']);
+/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
+export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
+ .replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
+
/** 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});
@@ -88,14 +92,21 @@ export async function mutate(page,{css=null,remove=[]}={}) {
}
/**
- * 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.
+ * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, continuation, panel, library, history,
+ * noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) measure
+ * the concept's Images markup; `cards` holds the first six. Library and history stay null until PRs E and F.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
}
+/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
+export function stripExact(value) {
+ if(Array.isArray(value))return value.map(stripExact);
+ if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
+ return value;
+}
+
/** Runs in the page; self-contained. `scope` selects the page's main element. */
export function measureInventory(scope) {
const px=value=>Math.round(value);
@@ -234,6 +245,97 @@ export function measureInventory(scope) {
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,
+ // ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
+ const classes=node=>[...node.classList].sort().join(' ');
+ const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
+ /** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
+ function controlOf(node,origin) {
+ const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
+ if(!base.shown)return base;
+ const style=getComputedStyle(node),input=node.querySelector('input');
+ // A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
+ const buttons=[...node.querySelectorAll(':scope > button')];
+ return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
+ role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
+ primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
+ ...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
+ icon:iconOf(node),
+ options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
+ input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
+ ...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
+ // Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
+ exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
+ }
+ function rowOf(row) {
+ return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
+ }
+ const gallery=main?.querySelector(':scope > .gallery-layout');
+ const body=gallery?.querySelector(':scope > .gallery-body');
+ const grid=body?.querySelector(':scope > .image-grid');
+ function captionOf(caption) {
+ return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
+ // A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
+ spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
+ top:relative(span,caption).top,height:relative(span,caption).height}))};
+ }
+ function gridOf() {
+ const style=getComputedStyle(grid);
+ return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
+ ...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
+ }
+ function cardOf(card) {
+ const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
+ const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
+ const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
+ const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
+ return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
+ exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
+ ...edges(card),...css(card,'boxShadow','overflow','display'),
+ art:when(art,()=>({...relative(art,card),...css(art,'position')})),
+ open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
+ img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
+ ...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
+ // An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
+ // to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
+ overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
+ ...css(overlays,'display','pointerEvents'),...relative(overlays,card),
+ items:[...overlays.children].map(node=>{const item=controlOf(node,card);
+ return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
+ rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
+ info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
+ // The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
+ // The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
+ // of 16.5px and 13.5px add up exactly.
+ copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
+ h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
+ ...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
+ // `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
+ // be shown to fit it on one line.
+ p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
+ const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
+ p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
+ actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
+ buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
+ }
+ function continuationOf(continuation) {
+ return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
+ items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
+ }
+ function panelOf() {
+ const panel=gallery.children[1];
+ // The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
+ const {height,...place}=relative(gallery,main);
+ return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
+ box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
+ }
+ const continuation=body?.querySelector(':scope > .infinite-continuation');
+ return {chrome:chromeOf(),heading:headingOf(),
+ toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
+ filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
+ caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
+ grid:when(grid,gridOf),
+ cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
+ continuation:when(continuation,continuationOf),
+ panel:when(gallery,panelOf),
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index c6b0fb8..44fd848 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -1,5 +1,9 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
+// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
+// "Inspector recipe", which names the negative variant.
+const POSITIVE_TITLE='Inspector portrait 猫 😀';
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
@@ -71,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
- expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
+ expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
@@ -86,7 +90,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
});
const inspector=page=>page.locator('.image-details');
-const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
+const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
async function fullFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
@@ -98,7 +102,7 @@ async function downloadText(page,action) {
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
- await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
+ await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
@@ -174,7 +178,7 @@ test('full inspector labels a limited workflow record without hiding prompt fact
test('full inspector rejects stale shared action state',async({page,request})=>{
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
- await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
+ await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
@@ -194,7 +198,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
- await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1);
+ await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
@@ -227,7 +231,7 @@ test('full inspector labels container representations and releases action URLs',
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
- await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
+ await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'control-heavy.png');
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
@@ -262,9 +266,9 @@ test.describe('full inspector coarse pointer',()=>{
}
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
}
- await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
+ await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
- for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
+ for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
@@ -272,25 +276,98 @@ test.describe('full inspector coarse pointer',()=>{
});
});
-const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
+const mediaDialog=page=>page.locator('dialog#viewer-dialog');
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
-const position=page=>mediaDialog(page).locator('.image-preview-position');
+const position=page=>mediaDialog(page).locator('.media-position');
async function openCollection(page) {
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
}
+// #249 C6: the concept viewer shell (h84–103, a889–926, s1008–1058, ≤700 s1456–1474), measured by one function in the
+// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details).
+const VIEWER_SHELL=()=>{
+ const dialog=document.querySelector('dialog#viewer-dialog');
+ const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`;
+ const paths=[];
+ const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}};
+ walk(dialog,describe(dialog));
+ const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);};
+ const toolbar=dialog.querySelector('#viewer-toolbar');
+ const part=selector=>dialog.querySelector(selector);
+ return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths,
+ title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(),
+ close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')},
+ details:part('#viewer-details').getAttribute('aria-label'),
+ toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})),
+ geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details']
+ .map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])),
+ toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))};
+};
+
+test('viewer matches the concept shell and toolbar order',async({page,request})=>{
+ await seedConcept(request);
+ for(const [width,height] of SIZES) {
+ await page.setViewportSize({width,height});
+ await openConcept(page,'images','dark');
+ const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open');
+ const filename=(await card.getAttribute('aria-label')).replace(/^Open /,'');
+ await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible();
+ const concept=await page.evaluate(VIEWER_SHELL);
+ expect(concept.toolbar.map(control=>control.text)).toEqual(['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →']);
+ await openProduction(page,'images','dark');
+ const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click();
+ const viewer=page.locator('dialog#viewer-dialog');
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title);
+ await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
+ // The dialog is named by its title, as the concept's aria-labelledby does.
+ await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible();
+ const production=await page.evaluate(VIEWER_SHELL);
+ // C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
+ production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
+ // #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
+ // reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
+ const appended=production.toolbar.slice(9);
+ expect(appended.map(control=>control.tag)).toEqual(['p','p','p']);
+ await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[\\^$.*+?()[\]{}|]/g,'\\$&')}$`));
+ expect(production.toolbar[7].tag).toBe('a');
+ production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control);
+ // #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's
+ // bottom edge, over the details; production keeps it in `.viewer-left` under the stage.
+ const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox;
+ // Inside the left box's 1px border (boxes are rounded to whole pixels).
+ for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]])
+ expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1);
+ expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]);
+ for(const shell of [concept,production]) {
+ delete shell.toolbarBox;delete shell.stageBox;
+ // At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds.
+ if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3);
+ }
+ if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
+ // #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ×, "−" and "+" (the title beside × narrows by the difference).
+ for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);}
+ expect(production.close).toEqual(concept.close);
+ }
+ expect(production,`${width}x${height}`).toEqual(concept);
+ await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused();
+ }
+});
+
test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
- await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
- await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
- await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
+ // #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's.
+ await page.getByRole('button',{name:'Filters',exact:true}).click();
+ const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
+ await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
+ await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
+ await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('name');
const more=page.getByRole('button',{name:'Load more images',exact:true});
- await expect(page.locator('.image-toolbar')).toContainText('40 of');
- for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
+ await expect(page.locator('.section-caption')).toContainText('40 of');
+ for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).toContainText(`${n} of`);}
const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
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);
@@ -298,30 +375,31 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
// 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');
+ const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`);
+ await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of');
const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
await modal.getByRole('button',{name:'Previous media',exact:true}).click();
await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
- await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
+ await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
- expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
await expect(opener).toBeFocused();
- await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
- await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
+ await page.getByRole('button',{name:'Filters',exact:true}).click();
+ await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
+ await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
- await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
+ await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
await expect(position(page)).toContainText('of 1 loaded: complete.png');
- await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
+ await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
});
@@ -334,8 +412,8 @@ test('collection viewer uses real public transforms and resets on navigation',as
for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
});
- await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
- await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
+ await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar');
+ await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
@@ -378,7 +456,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
-const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
+const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
async function readyClockImage(page) {
@@ -407,7 +485,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
- await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
+ await button.click();await modal.locator('#viewer-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
});
@@ -438,7 +516,7 @@ test('slideshow production query and route disposal cancel the active deadline',
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
await page.clock.runFor(3499);
- const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
+ const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
@@ -450,18 +528,18 @@ test('slideshow interaction visibility sender and disposal clear intent',async({
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
- ()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
+ ()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
- ()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
+ ()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
}
for(let mount=0;mount<3;mount++) {
- await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
+ await button.click();await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
await readyClockImage(page);
}
@@ -492,12 +570,12 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
async function capture(name,viewer=false) {
await page.evaluate(()=>document.fonts.ready);
if(viewer) {
- const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
+ const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
measurements.push({name,...await modal.evaluate(node=>{
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
- return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
- toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
+ return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')),
+ toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')),
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
@@ -511,19 +589,19 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
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}`);
- await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
+ await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
- await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
- await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
- await modal.getByRole('button',{name:'Close preview',exact:true}).click();
+ await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
}
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
@@ -534,12 +612,12 @@ test('collection viewer preserves original download and rooted retries',async({p
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
- await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
- const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
+ await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
+ const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
- await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
+ await expect.poll(()=>mediaStage(page).locator(':scope > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
await expect(download).toHaveAttribute('href',original);
@@ -547,10 +625,10 @@ test('collection viewer preserves original download and rooted retries',async({p
expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
- await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
+ await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
- await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
+ await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click();
await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
@@ -565,20 +643,20 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
- await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
+ await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
- await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
+ await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
- const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
+ const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-details');
// Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
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('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};});
+ const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).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`)});
}
@@ -594,7 +672,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
- await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
+ await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']);
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
@@ -608,16 +686,16 @@ test('collection viewer matches approved responsive frame',async({page,request},
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
- await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
+ await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
- await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
+ await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
- await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
+ await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
});
async function currentStageFit(page) {
return page.evaluate(()=>{
- const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
+ const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-stage');
const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
image:{width:image.width,height:image.height},
@@ -655,7 +733,7 @@ async function openStageFitFixture(page,request) {
window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
});
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
- await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
+ await expect(mediaDialog(page).locator('.media-reason')).toBeHidden();
return release;
}
async function attachStageFit(page,testInfo,name) {
@@ -679,7 +757,7 @@ test('collection viewer refits a late wrapped title without replacing current me
const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
- await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
+ await expect(modal.locator('#viewer-title')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
@@ -692,7 +770,7 @@ test('collection viewer refits a late wrapped title without replacing current me
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
- await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
+ await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);
await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs
index dec68f4..0feb49e 100644
--- a/tests/browser/history-canonical-comparison.mjs
+++ b/tests/browser/history-canonical-comparison.mjs
@@ -15,8 +15,7 @@ export const canonicalDifferences = {
'Current-file candidates disclose unknown identity and context membership',
],
remainingApplicationPolish: [
- 'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
- 'Shared Viewer stage background and toolbar alignment remain outside #209',
+ 'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
@@ -106,7 +105,7 @@ export async function exerciseCurrentPairSend(page, roles) {
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
} });
});
- const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
+ const viewer = page.locator('dialog#viewer-dialog');
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
new file mode 100644
index 0000000..8475cd4
--- /dev/null
+++ b/tests/browser/images-concept.spec.mjs
@@ -0,0 +1,567 @@
+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,mutate,stripExact,countCopy} from './concept-inventory.mjs';
+
+// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
+// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
+// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
+// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
+// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
+// Paired captures are artifacts only; there are no PNG baselines.
+test.use({locale:'en-US',timezoneId:'UTC'});
+
+// D37: the concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
+// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
+const FIXTURE_IMAGES=24;
+const CONCEPT_BATCH=12;
+const PRODUCTION_BATCH=40;
+// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
+const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
+const CARDS='.gallery-body > .image-grid > .media-card';
+const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
+
+/** D17: " · " of the newest file (the fixture's own mtime), in the test's UTC zone. */
+function captionDay(seconds,now=new Date()) {
+ const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
+ const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
+ const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
+ const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
+ return `${name} · ${date.toLocaleDateString('en-US',options)}`;
+}
+
+/**
+ * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
+ * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
+ * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
+ * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
+ */
+export function reflow(row,items) {
+ const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
+ // The unrounded boxes (`exact`); rounding happens once, on the result.
+ const rowWidth=row.exact?.width??row.width;
+ const exact=item=>item.exact??{left:item.left,width:item.width};
+ const grows=item=>parseFloat(item.flexGrow)>0;
+ const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
+ const lines=[];
+ const order=row.items.filter(entry=>entry.shown);
+ for(const item of items.filter(entry=>entry.shown)) {
+ const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
+ // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
+ if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
+ }
+ const placed=new Map();let top=0;
+ const sameLine=(a,b)=>a.toprowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
+ for(const line of lines) {
+ const free=freeOf(line);
+ const growing=line.filter(grows);
+ const height=Math.max(...line.map(item=>item.height));
+ let x=0,previous=null;
+ for(const item of line) {
+ // A growing item keeps its concept width plus the space its line gained.
+ const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
+ // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
+ // moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
+ const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
+ const before=previous&&placed.get(previous);
+ const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
+ placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
+ x=left+width+gap;previous=item;
+ }
+ top+=height+rowGap;
+ }
+ const moved=item=>{
+ const box=placed.get(item);
+ if(!box)return item;
+ const grown=box.width-exact(item).width;
+ return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
+ input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
+ };
+ return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
+}
+
+/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
+function withoutItems(row,isRemoved) {
+ return reflow(row,row.items.filter(item=>!isRemoved(item)));
+}
+
+/**
+ * D8 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box
+ * (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus
+ * padding and border when taller. Its place stays the concept's top-right (`rightInset`).
+ */
+function withoutCheckbox(overlays) {
+ // A list row hides its overlays (concept `display: none`).
+ if(!overlays.shown)return overlays;
+ const items=overlays.items.filter(item=>item.tag!=='label');
+ const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth']
+ .reduce((sum,key)=>sum+parseFloat(item[key]),0));
+ const height=Math.max(...items.filter(item=>item.shown).map(own));
+ // D8 (interim): alone, the star is pushed to the right by `margin-left: auto`, whose used value is the overlay's
+ // free width; the checkbox PR removes that rule and this line.
+ const pushed=item=>items.length===1?{marginLeft:`${overlays.width-item.width}px`}:{};
+ return {...overlays,height,items:items.map(item=>item.shown?{...item,height,...pushed(item)}:item)};
+}
+
+/**
+ * A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
+ * - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
+ * shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
+ * end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
+ * stretches it across, so the text, centred in either, moves only by half the lines it lost;
+ * - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
+ */
+function listInfo(card,actionsWidth) {
+ const {info}=card,{copy,p}=info;
+ const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
+ const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
+ const line=parseFloat(p.lineHeight);
+ const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
+ const copyHeight=copy.height-(p.height-pHeight);
+ const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
+ // The row's inner box is the stretched concept info: its top is the card border, its height the art's.
+ return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
+ copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
+ actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth};
+}
+
+/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
+const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;};
+
+export function mapImagesConcept(reference,{manifest,now=new Date()}) {
+ const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
+ const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
+ // D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
+ toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
+ // D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
+ // COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
+ toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
+ ?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
+ // D29: no "Illustrative previews" sample tag.
+ const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
+ filterRow.items=filterRow.items.map(item=>{
+ // D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
+ if(item.className==='segmented')return {...item,flexWrap:'wrap'};
+ // D14: the scope chip may shrink and wrap a long source or folder name inside itself.
+ if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
+ return item;
+ });
+ // A shorter toolbar or filter row moves what follows it up by as much.
+ const toolbarRise=reference.toolbar.height-toolbar.height;
+ filterRow.top-=toolbarRise;
+ const rise=toolbarRise+reference.filterRow.height-filterRow.height;
+ const list=reference.grid.className.split(' ').includes('list-grid');
+ const cards=reference.cards.map(card=>{
+ const filename=card.open.label.replace(/^Open /,'');
+ const actions=card.info.actions;
+ const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
+ // D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
+ const freed=tag?tag.width+parseFloat(actions.columnGap):0;
+ // D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
+ // it, centred in the row the concept stretches it across; the text and the actions keep their place.
+ const actionsWidth=actions.width-freed;
+ const listRow=list?listInfo(card,actionsWidth):{};
+ return {...card,
+ // D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
+ img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
+ // D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place.
+ overlays:withoutCheckbox(card.overlays),
+ info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
+ // D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
+ h3:{...card.info.h3,title:paths.get(filename)??null},
+ actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
+ buttons:actions.buttons.filter(button=>button!==tag)}}};
+ });
+ // D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
+ // D35: the count line uses the singular for one.
+ const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
+ ?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
+ const continuation=mapContinuation(reference.continuation);
+ return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}};
+}
+
+/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
+function mapContinuation(continuation,numbers=null) {
+ return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
+ // D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
+ text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
+}
+
+/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
+function compareLoadingRow(reference,actual,count) {
+ const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
+ // The row's place follows the grid above it, whose height is the batch's (D37).
+ const unplaced=({top,...row})=>stripExact(row);
+ return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
+}
+
+/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
+function mapImportConcept(reference) {
+ return {...reference,
+ intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
+ rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
+ ?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
+}
+
+const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll'];
+const pick=inventory=>stripExact(inventory);
+
+// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
+const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
+
+function compare(reference,actual,manifest,sections=SECTIONS) {
+ const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
+ return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
+}
+
+/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
+function assertImagesMatch(reference,actual,manifest) {
+ const diff=compare(reference,actual,manifest);
+ if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
+}
+
+/**
+ * D3 (masonry sub-pixel snapping): bounded rendering places masonry cards from the layout arithmetic (media width over
+ * the file's ratio), while the concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and
+ * WebKit, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
+ * within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
+ */
+const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
+function masonryDrift(reference,actual) {
+ return reference.cards.flatMap((card,index)=>{
+ const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
+ const bound=(above+1)/60+1e-6,other=actual.cards[index];
+ return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
+ .map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
+ });
+}
+function compareLayout(layout,reference,mapped,actual) {
+ const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
+ return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
+}
+
+// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
+const settle=page=>page.evaluate(async()=>{
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
+ new Promise(resolve=>setTimeout(resolve,2000))]);
+});
+const countText=page=>page.locator('#result-count').textContent();
+const toastText=page=>page.locator('.toast').textContent();
+
+/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
+async function openConceptLoaded(page,theme,motion) {
+ await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
+ const before=await page.locator(CARDS).count();
+ const loadingRow=(await inventoryOf(page,'#main')).continuation;
+ // A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
+ // Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
+ if(before{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
+ return load;
+}
+
+async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
+ await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
+ await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
+ await settle(page);
+}
+
+/** The Import dialog's intro and operation rows (D15). */
+async function importDialogOf(page) {
+ await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
+ const dialog=page.locator('dialog#modal[open]');
+ await expect(dialog).toBeVisible();
+ const inventory=await dialog.evaluate(node=>{
+ const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
+ const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
+ const intro=node.querySelector('#modal-body > p.small');
+ return {title:node.querySelector('#modal-title').textContent,
+ intro:intro&&{text:intro.textContent,...font(intro)},
+ ...css(node.querySelector('.operation-list'),'display','rowGap'),
+ rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
+ description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
+ titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
+ });
+ await page.keyboard.press('Escape');
+ await expect(dialog).toHaveCount(0);
+ return inventory;
+}
+
+/** The hovered first card image after its transition, then the pointer parked again. */
+async function hoverOf(page) {
+ const open=page.locator(CARDS).first().locator('.image-open');
+ await open.hover();
+ // The concept's 0.2s transition (instant under reduced motion) has started and finished.
+ await settle(page);
+ const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
+ transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
+ transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
+ await page.mouse.move(0,0);
+ return state;
+}
+
+const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
+ return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
+ card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
+const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
+
+const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
+/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
+async function layoutsOf(page,scope) {
+ const result={};
+ for(const layout of LAYOUTS) {
+ if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
+ else {
+ await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
+ await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
+ }
+ await page.mouse.move(0,0);await settle(page);await settle(page);
+ const inventory=await inventoryOf(page,scope);
+ result[layout]={grid:inventory.grid,cards:inventory.cards};
+ }
+ await page.locator('#image-size').selectOption('comfortable');await settle(page);
+ return result;
+}
+
+const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
+/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
+async function searchOf(page,term) {
+ const before=await countText(page);
+ await page.locator('#search').fill(term);
+ await expect.poll(()=>countText(page)).not.toBe(before);
+ await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
+ await settle(page);
+ const result={count:await countText(page),continuation:await continuationText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
+ await page.locator('#search').fill('');
+ await expect(page.locator('#result-count')).toHaveText(before);
+ return result;
+}
+
+/** The behaviors §7 lists, run with identical selectors in both documents. */
+async function behaviorsOf(page,scope) {
+ const hover=await hoverOf(page);
+ const second=page.locator(CARDS).nth(1).locator('.image-open');
+ const opened=await second.getAttribute('aria-label');
+ await second.click();
+ const viewer=page.locator('#viewer-dialog[open]');
+ await expect(viewer).toBeVisible();
+ const viewerTitle=await page.locator('#viewer-title').textContent();
+ await page.keyboard.press('Escape');
+ await expect(viewer).toHaveCount(0);
+ await settle(page);
+ const focusAfterViewer=await activeOf(page);
+ await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
+ await expect.poll(()=>selectedOf(page)).toEqual([2]);
+ const selected=await selectedOf(page);
+ const layouts=await layoutsOf(page,scope);
+ const search=await searchOf(page,'alpine');
+ // Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
+ const single=await searchOf(page,'alpine_00003');
+ return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
+}
+
+test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
+
+for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
+ test.setTimeout(240_000);
+ await page.setViewportSize({width,height});
+ const manifest=testInfo.manifest;const result={};
+ for(const motion of MOTIONS) {
+ const capture=motion==='no-preference';
+ const conceptLoad=await openConceptLoaded(page,theme,motion);
+ const reference=await inventoryOf(page,'#main');
+ if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
+ // The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
+ const removed={};
+ for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
+ const restore=await mutate(page,{remove:[selector]});
+ removed[key]=(await inventoryOf(page,'#main'))[key];
+ await restore();
+ }
+ const referenceImport=await importDialogOf(page);
+ const referenceBehaviors=await behaviorsOf(page,'#main');
+
+ await openProductionLoaded(page,theme,motion);
+ const actual=await inventoryOf(page,'#workspace-main');
+ if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
+ const actualImport=await importDialogOf(page);
+ const actualBehaviors=await behaviorsOf(page,'#workspace-main');
+ result[motion]={conceptLoad,reference,removed,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
+ importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
+ }
+ // Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
+ testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
+ for(const motion of MOTIONS) {
+ const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
+ await openProductionLoaded(page,theme,motion,total);
+ const before=await page.locator(CARDS).count();
+ const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
+ await page.getByRole('button',{name:'Load more images',exact:true}).click();
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
+ result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
+ result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
+ }
+ const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
+ await testInfo.attach('images-geometry',{path,contentType:'application/json'});
+
+ for(const [motion,entry] of Object.entries(result)) {
+ const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,actual,diff,referenceImport,importDiff,
+ referenceBehaviors:concept,actualBehaviors:production}=entry;
+ // The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
+ expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
+ toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
+ // D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
+ expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
+ toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
+ count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
+ // D37: the row with more to load, before its press, in both documents.
+ expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
+ expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
+ // D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
+ const first=manifest.images[0];
+ expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
+ expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
+ expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
+ // The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
+ // sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
+ expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
+ expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
+ expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
+ `${motion} toolbar without Save view`).toEqual([]);
+ expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
+ `${motion} filter row without the sample tag`).toEqual([]);
+ expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
+ expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
+ expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
+ expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
+ // Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
+ expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
+ transitionDuration:motion==='reduce'?'0s':'0.2s'});
+ expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
+ // The 2nd card's image opens the viewer titled " · ".
+ expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
+ expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
+ // Escape returns focus to the image that opened the viewer.
+ expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
+ // The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
+ // §7 and D19 keep production's restore, which is what is asserted above.
+ // Info on the 3rd card moves `.media-card.selected` there.
+ expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
+ // List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
+ expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
+ for(const layout of LAYOUTS) {
+ const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
+ expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${motion} ${layout} layout`).toEqual([]);
+ }
+ for(const card of concept.layouts.Masonry.cards) {
+ const [w,h]=card.ratio.split('/').map(Number);
+ expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
+ }
+ // Searches leave the same images, count line and continuation in both documents (D35 for one).
+ const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
+ expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
+ expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
+ expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
+ expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
+ }
+});
+
+// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
+// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
+test('images comparison detects mutations',async({page},testInfo)=>{
+ await page.setViewportSize({width:1600,height:1100});
+ const manifest=testInfo.manifest;
+ await openConceptLoaded(page,'dark','no-preference');
+ const reference=await inventoryOf(page,'#main');
+ await openProductionLoaded(page,'dark','no-preference');
+ const measure=()=>inventoryOf(page,'#workspace-main');
+ assertImagesMatch(reference,await measure(),manifest);
+
+ const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
+ const cssMutated=await measure();
+ expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
+ await restoreCss();await settle(page);
+ assertImagesMatch(reference,await measure(),manifest);
+
+ await mutate(page,{remove:['[aria-label^="Inspect"]']});
+ const domMutated=await measure();
+ expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
+});
+
+// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
+// 310px panel at 1800.
+for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
+ await page.setViewportSize({width,height});
+ const manifest=testInfo.manifest;
+ await openConceptLoaded(page,theme,'no-preference');
+ const reference=await inventoryOf(page,'#main');
+ await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
+ await openProductionLoaded(page,theme,'no-preference');
+ const actual=await inventoryOf(page,'#workspace-main');
+ await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
+ expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
+ expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
+ expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
+});
+
+// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
+// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
+test('one appended image reads in the singular',async({page,request})=>{
+ // A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
+ test.setTimeout(120_000);
+ await page.setViewportSize({width:1600,height:1100});
+ const total=PRODUCTION_BATCH+1;
+ await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
+ await openProductionLoaded(page,'dark','no-preference',total);
+ await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
+ // A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
+ await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
+ await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
+ expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
+ await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
+});
+
+// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
+test('one image appended by scrolling reads in the singular',async({page,request})=>{
+ test.setTimeout(120_000);
+ await page.setViewportSize({width:1600,height:1100});
+ const total=PRODUCTION_BATCH+1;
+ await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
+ await openProductionLoaded(page,'dark','no-preference',total);
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
+ await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
+ await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
+ await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
+});
+
+// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
+// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
+// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
+test('one footer height for the collection (D3)',async({page,request})=>{
+ // A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
+ test.setTimeout(120_000);
+ await page.setViewportSize({width:390,height:844});
+ await openConceptLoaded(page,'dark','no-preference');
+ const reference=await inventoryOf(page,'#main');
+ expect(reference.grid.columns).toBe(2);
+ expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
+ const manifest=await seedConcept(request,{seedless_images:2});
+ await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
+ const actual=await inventoryOf(page,'#workspace-main');
+ const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
+ const pitch=reference.cards[2].top-reference.cards[0].top;
+ expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
+ ...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
+ const seedless=actual.cards.slice(0,2);
+ expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
+ expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
+ // Their own footer is one line shorter than the row it reserves.
+ for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
+});
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
index aad76dd..de08c28 100644
--- a/tests/browser/prompt-inspector.spec.mjs
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -4,7 +4,7 @@ import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/inde
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
-const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
+const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click();
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
test.beforeEach(async({page,request,browserName})=>{
@@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
- await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
+ await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog');
const message='The source changed. Refresh the image collection before inspecting this file.';
await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
- const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
+ const original=preview.locator('#viewer-toolbar').getByLabel('Download original',{exact:true});
await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
@@ -114,9 +114,9 @@ test('sender requires explicit destination, previews full changes and acknowledg
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();
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
- await page.getByRole('button',{name:'Close preview',exact:true}).click();
+ await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
@@ -227,8 +227,9 @@ test('filtering away a selected image aborts its pending metadata request',async
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
const baseline=await page.evaluate(()=>window.inspectionAborts);
- await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
- await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
+ await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file');
+ await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible();
+ await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
} finally {release();}
});
@@ -247,10 +248,10 @@ test('responsive gallery defers observed size writes and still fits the new view
};
});
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
- await expect(page.locator('.image-card')).toHaveCount(4);
+ await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
- await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
- await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0);
+ await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
+ await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
});
diff --git a/tests/browser/server.py b/tests/browser/server.py
index 4c51d9d..26066f2 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -16,6 +16,7 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
+ MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
@@ -132,6 +133,7 @@ def create_app(root: Path) -> web.Application:
) -> None:
with api.db.model.get_connection() as conn:
for table in (
+ "image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -388,13 +390,20 @@ def create_app(root: Path) -> web.Application:
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
nonlocal history_fixture
data = await request.json() if request.can_read_body else {}
+ limits = {
+ "extra_images": CONCEPT_MAX_EXTRA_IMAGES,
+ "seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
+ }
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
+ or set(data) - set(limits)
+ or any(
+ isinstance(data.get(key, 0), bool)
+ or not isinstance(data.get(key, 0), int)
+ or not 0 <= data.get(key, 0) <= limit
+ for key, limit in limits.items()
+ )
):
raise web.HTTPBadRequest()
history_commands.reset()
@@ -402,7 +411,10 @@ def create_app(root: Path) -> web.Application:
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
- api, root, extra_images=data.get("extra_images", 0)
+ api,
+ root,
+ extra_images=data.get("extra_images", 0),
+ seedless_images=data.get("seedless_images", 0),
)
)
return web.json_response({"success": True, **manifest})
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index 3770585..320876e 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -224,7 +224,7 @@ test('every ordered receipt remains selected through distinct current locations,
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
await expect(dialog(page)).toContainText('Context membership unknown');
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
- const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
+ const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
@@ -245,7 +245,7 @@ test('inspection first discovers a real restore and immediately retires every su
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
try {
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
- await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
await page.evaluate(()=>{
window.recordedRecoveryStates=[];
@@ -259,7 +259,7 @@ test('inspection first discovers a real restore and immediately retires every su
} finally {await command(request,{action:'release'});}
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
- const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
+ const viewer=page.locator('dialog#viewer-dialog');
if(await viewer.count())await page.keyboard.press('Escape');
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
@@ -297,7 +297,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
- await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
+ await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
@@ -468,7 +468,7 @@ test('the inline current file names its prompts apart from the frozen run fields
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
- await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
@@ -485,7 +485,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
- await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
@@ -500,7 +500,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
- await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
+ await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index 83109df..ef955ba 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -1,11 +1,33 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
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 });
+// The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view".
+const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
+const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
+const caption = page => page.locator('.section-caption');
+const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
+const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true });
+async function refine(page, values) {
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ const dialog = refineDialog(page);
+ for (const [label, value] of Object.entries(values)) {
+ const control = dialog.getByLabel(label, { exact: true });
+ if (label === 'Tags') await control.fill(value);
+ else await control.selectOption(value);
+ }
+ await dialog.getByRole('button', { name: 'Apply filters', exact: true }).click();
+ await expect(dialog).toBeHidden();
+}
+async function rescan(page) {
+ await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
+}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
@@ -33,17 +55,17 @@ test('native Images keeps colliding filenames distinct and inspects the selected
.toEqual(['0:shared/same.png', '1:shared/same.png']);
const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src));
expect(new Set(originals).size).toBe(2);
- await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
+ await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expect(details(page)).toContainText('shared/same.png');
await expect(details(page)).toContainText(/input/i);
await capture('after-images-source-collision-input-1600');
- await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
+ await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText(/output/i);
await capture('after-images-source-collision-output-1600');
await page.setViewportSize({width:390,height:844});
for (const [index,source] of [[1,'input'],[0,'output']]) {
- await cards(page).nth(index).getByRole('button',{name:'Open same.png',exact:true}).click();
+ await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click();
await expect(details(page)).toContainText(new RegExp(source,'i'));
await details(page).scrollIntoViewIfNeeded();
await capture(`after-images-source-collision-${source}-390`);
@@ -59,20 +81,21 @@ test('workspace defaults to the native Images collection without a hash route',
.getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page');
});
-test('image selection is announced on the selected image button', async ({ page }) => {
+test('image selection is shown on the selected card', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
- const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
+ // D19: Info selects into the panel; as in the concept, the card carries `.selected` and Info has no pressed state.
+ const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true });
await selected.click();
- await expect(selected).toHaveAttribute('aria-pressed', 'true');
- await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }))
- .toHaveAttribute('aria-pressed', 'false');
+ await expect(cards(page).last()).toHaveClass(/\bselected\b/);
+ await expect(cards(page).first()).not.toHaveClass(/\bselected\b/);
+ await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0);
});
test('closing details preview restores its trigger focus', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
- const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
+ const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true });
await selected.click();
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
await preview.click();
@@ -129,26 +152,26 @@ for (const embedded of [false, true]) {
}, { images: data.images, embedded });
await expect(cards(page)).toHaveCount(40);
expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]);
- await page.locator('.image-continuation').scrollIntoViewIfNeeded();
+ await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']);
const key = await cards(page).last().getAttribute('data-image-key');
- const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / });
+ const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Open / });
await preview.click();
- const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
+ const viewer = page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await page.evaluate(() => window.continuationFixture.finishPage());
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false');
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
- expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.image-continuation').getBoundingClientRect().top <= innerHeight })))
+ expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.infinite-continuation').getBoundingClientRect().top <= innerHeight })))
.toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true });
if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose());
- else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click();
+ else if (close === 'Close') await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
else await page.keyboard.press('Escape');
await expect(viewer).toHaveCount(0);
if (close !== 'parent abort') {
await expect(preview).toBeFocused();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
- await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '80 of 80');
+ await expect(caption(page)).toContainText(embedded ? '80 loaded' : '80 of 80');
}
await page.evaluate(() => window.continuationFixture.scope.dispose());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
@@ -163,19 +186,18 @@ test('source, folder and media filters operate on real backend records', async (
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
- await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
+ await refine(page, { Source: '1' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
- await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all');
- await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk');
+ await refine(page, { Source: 'all', Folder: 'bulk' });
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//);
- await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
+ await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i);
- await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');
+ await refine(page, { Media: 'image' });
await expect(cards(page)).toHaveCount(3);
- await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('');
+ await refine(page, { Folder: '' });
await expect(cards(page)).toHaveCount(5);
});
@@ -183,23 +205,23 @@ test('filename search finds records beyond the initial batch and sort orders the
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
+ await search(page).fill('same.png');
await expect(cards(page)).toHaveCount(2);
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099');
+ await search(page).fill('0099');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png');
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
+ await search(page).fill('same.png');
await expect(cards(page)).toHaveCount(2);
- await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest');
+ await sortBy(page).selectOption('oldest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
- await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest');
+ await sortBy(page).selectOption('newest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
- await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
+ await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
@@ -218,14 +240,14 @@ test('returning to Images restores the filtered stream, layout, selection and sc
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
- const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
- await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0');
- await search.fill('00');
- await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images');
+ const box = search(page);
+ await refine(page, { Source: '0' });
+ await box.fill('00');
+ await expect(caption(page)).toContainText('40 of 100 images');
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
- await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
- await cards(page).first().getByRole('button', { name: /^Open / }).click();
+ await expect(caption(page)).toContainText('80 of 100 images');
+ await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
await cards(page).nth(30).scrollIntoViewIfNeeded();
const previousScroll = await page.evaluate(() => window.scrollY);
@@ -236,13 +258,18 @@ test('returning to Images restores the filtered stream, layout, selection and sc
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
- await expect(search).toHaveValue('00');
- await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0');
+ await expect(box).toHaveValue('00');
+ await expect(page.locator('.filter-row span.chip')).toHaveText(/ · All output folders$/);
await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true');
- await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
+ await expect(caption(page)).toContainText('80 of 100 images');
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
+ // Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view.
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0');
+ await page.keyboard.press('Escape');
+ await expect(refineDialog(page)).toBeHidden();
});
test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => {
@@ -252,7 +279,7 @@ test('continuous loading appends without replacing cards or losing selected deta
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; });
- await cards(page).first().getByRole('button', { name: /^Open / }).click();
+ await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selected = await cards(page).first().getAttribute('data-image-key');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
@@ -261,11 +288,14 @@ test('continuous loading appends without replacing cards or losing selected deta
await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes');
await expect(cards(page).first()).toHaveAttribute('data-image-key', selected);
await expect(details(page)).toBeVisible();
- await expect(more).toBeFocused();
+ // The concept (app.js loadMoreImages) focuses the first appended image after a Load more button press.
+ await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
+ await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(102);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102);
await expect(more).toBeHidden();
+ await expect(page.locator('article[aria-posinset="81"] .image-open')).toBeFocused();
await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0);
});
@@ -287,12 +317,12 @@ test('large continuous collections bound the mounted cards and restore earlier i
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
const firstKey = await first.getAttribute('data-image-key');
- await first.getByRole('button', { name: /^Open / }).click();
+ await first.getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
- await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
+ await expect(caption(page)).toContainText(`${loaded} of 202 images`);
}
await expect(more).toBeHidden();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
@@ -304,9 +334,9 @@ test('large continuous collections bound the mounted cards and restore earlier i
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await page.locator(`article[data-image-key="${firstKey}"]`)
- .getByRole('button', { name: /^Preview / }).click();
+ .getByRole('button', { name: /^Open / }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
- await page.getByRole('button', { name: 'Close preview', exact: true }).click();
+ await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
});
test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => {
@@ -316,12 +346,12 @@ test('virtual gallery keyboard navigation reaches loaded endpoints and announces
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label');
- await cards(page).first().getByRole('button', { name: /^Open / }).click();
+ await cards(page).first().getByRole('button', { name: /^Inspect / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
- await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
+ await expect(caption(page)).toContainText(`${loaded} of 202 images`);
}
await page.evaluate(() => window.scrollTo(0, 0));
const first = page.locator(`article[data-image-key="${firstKey}"]`);
@@ -346,12 +376,12 @@ test('virtual gallery keyboard navigation reaches loaded endpoints and announces
test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
- await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click();
+ await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/);
- await page.getByRole('button', { name: 'Close preview', exact: true }).click();
+ await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
- await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click();
+ await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
@@ -364,13 +394,17 @@ test('real video media has a usable card and native playback cleans up on close
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull();
await page.goto(workspace);
- await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
+ await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(1);
const card = cards(page).first();
await expect(card).toHaveAttribute('data-image-key', '0:clip.webm');
await expect(card.locator('img')).toHaveCount(0);
await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible();
- await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
+ // Legacy video art inside the concept card; D11 names the video's own extension.
+ await expect(card.locator('.media-art > .image-open > video')).toHaveCount(1);
+ await expect(card.locator('.media-info p')).toHaveText(/(?:^|· )WEBM$/);
+ expect(await card.locator('video').evaluate(node => node.getBoundingClientRect().height)).toBeGreaterThan(20);
+ await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
const video = page.locator('dialog[open] video');
await expect(video).toBeVisible();
await expect(video).toHaveJSProperty('controls', true);
@@ -382,10 +416,10 @@ test('real video media has a usable card and native playback cleans up on close
return !element.paused;
});
expect(playing).toBe(true);
- await page.getByRole('button', { name: 'Close preview', exact: true }).click();
+ await page.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(page.locator('dialog video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
- await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
+ await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
await expect(video).toBeVisible();
await video.evaluate(async element => {
window.__previewVideo = element;
@@ -407,33 +441,33 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
- await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting;
- const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
+ await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
+ const preview=page.locator('dialog#viewer-dialog');
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
- await expect(preview.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
+ await expect(preview.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/);
await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();
await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release();
expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90);
await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click();
await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);
expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true);
- await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
+ await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
- await page.locator('article[data-image-key="0:clip.webm"] .image-card-footer button').click();
- const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video');
+ await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
+ const preview=page.locator('dialog#viewer-dialog'),video=preview.locator('video');
await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true);
const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.');
- await video.focus();const position=await preview.locator('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position);
+ await video.focus();const position=await preview.locator('.media-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.media-position')).toHaveText(position);
await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();});
await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false');
expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true);
- await preview.getByRole('button',{name:'Close preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0);
+ await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(page.locator('#viewer-dialog')).toHaveCount(0);
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
@@ -454,7 +488,7 @@ test('failed image requests offer retry while stale results cannot replace a new
await pending;
await route.fulfill({ response }).catch(() => {});
});
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
+ await search(page).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Tools and system', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
@@ -464,19 +498,19 @@ test('failed image requests offer retry while stale results cannot replace a new
await expect(details(page)).toHaveCount(0);
});
-test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => {
+test('source and folder discovery failure stays visible and Import Rescan restores usable filters', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Sources unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
- await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.');
- await page.getByRole('button', { name: 'Refresh', exact: true }).click();
+ await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Rescan from Import to retry.');
+ await rescan(page);
await expect(page.getByRole('alert')).toHaveCount(0);
- await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
+ await refine(page, { Source: '1' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
- await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared');
+ await refine(page, { Folder: 'shared' });
await expect(cards(page)).toHaveCount(1);
});
@@ -484,15 +518,17 @@ async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
- const source = page.getByRole('combobox', { name: 'Source', exact: true });
- const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
- await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk');
+ const source = refineDialog(page).getByLabel('Source', { exact: true });
+ const folder = refineDialog(page).getByLabel('Folder', { exact: true });
+ const filters = page.getByRole('button', { name: 'Filters', exact: true });
+ await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
+ await refine(page, { Source: '0', Folder: 'bulk' });
const configured = page.waitForResponse(response => {
const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name';
});
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png');
- 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');
+ await search(page).fill('png');
+ await refine(page, { Media: 'image' });await sortBy(page).selectOption('name');
+ const initial = await (await configured).json();await expect(caption(page)).toContainText('40 of 80');
let oldPage;
await page.route(imagePages, async route => {
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
@@ -503,10 +539,10 @@ async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
- await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409);
+ await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
- await page.locator(`article[data-image-key="${key}"] .image-card-footer button`).click();
- const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.');
+ await page.locator(`article[data-image-key="${key}"] .image-open`).click();
+ const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
@@ -519,7 +555,7 @@ async function staleSourceRecovery(page, request, roots) {
await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1);
await oldPage.route.fulfill({ json: oldPage.data });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
- return { source, folder, pages, inspections, options, mutations };
+ return { source, folder, filters, pages, inspections, options, mutations };
}
for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) {
@@ -527,11 +563,13 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i
const recovery = await staleSourceRecovery(page, request, roots);
try {
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
- await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue('');
- for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
- await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled();
+ await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');
+ for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
+ for (const name of ['Filters', 'Import']) await expect(page.getByRole('button', { name, exact: true })).toBeDisabled();
+ await expect(search(page)).toBeDisabled();
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
- await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change');
+ await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change');
+ await expect(refineDialog(page)).toBeHidden();
await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } });
await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible();
expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0);
@@ -543,8 +581,8 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i
await expect.poll(() => recovery.pages.length).toBe(1);
const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false);
expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name');
- await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all');
- await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);
+ await expect(recovery.filters).toBeEnabled();await recovery.filters.click();await expect(recovery.source).toHaveValue('all');
+ await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);await page.keyboard.press('Escape');
await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0');
expect(recovery.mutations).toEqual([]);
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') });
@@ -558,7 +596,7 @@ test('source-change recovery ignores superseded discovery and route disposal', a
expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2);
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']);
+ expect(recovery.pages).toEqual([]);await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');await expect(recovery.filters).toBeDisabled();
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))));
@@ -572,11 +610,11 @@ test('mobile Images keeps filters, layouts and selected details within the viewp
await expect(cards(page)).toHaveCount(2);
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
- await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
+ await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText(/input/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
- await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible();
+ await expect(search(page)).toBeVisible();
});
test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => {
@@ -599,10 +637,10 @@ test('a delayed search cannot replace results for a newer filename query', async
await route.fulfill({ response }).catch(() => {});
complete();
});
- const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
- await search.fill('same.png');
+ const box = search(page);
+ await box.fill('same.png');
await received;
- await search.fill('0001');
+ await box.fill('0001');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
release();
@@ -627,25 +665,26 @@ test('refresh releases the previous inspector video source', async ({ page, requ
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
- await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
+ await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click();
await expect(details(page).locator('video')).toBeVisible();
await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; });
- await page.getByRole('button', { name: 'Refresh', exact: true }).click();
+ await rescan(page);
await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true);
expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull();
});
-test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
+test('Import Rescan rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({
status: 503, json: { success: false, error: 'Rescan unavailable' },
}), { times: 1 });
- await page.getByRole('button', { name: 'Refresh', exact: true }).click();
- await expect(page.getByRole('alert')).toContainText('could not be refreshed');
+ await rescan(page);
+ await expect(toast(page)).toHaveText('Rescan could not finish. Try again.');
+ await expect(page.getByRole('alert')).toHaveCount(0);
await expect(cards(page)).toHaveCount(2);
- await page.getByRole('button', { name: 'Refresh', exact: true }).click();
+ await rescan(page);
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1);
await expect(page.getByRole('alert')).toBeHidden();
@@ -655,14 +694,17 @@ test('folder discovery offers ancestors that filter nested images', async ({ pag
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
- const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
- await expect(folder.locator('option[value="project"]')).toHaveCount(1);
- await folder.selectOption('project');
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option[value="project"]')).toHaveCount(1);
+ // The concept's Folder default.
+ await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option').first()).toHaveText('All output folders');
+ await page.keyboard.press('Escape');
+ await refine(page, { Folder: 'project' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toContainText('new-output.png');
});
-test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => {
+test('leaving during Rescan cancels it without replacing the next workspace', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/rescan', async route => {
@@ -672,14 +714,14 @@ test('leaving during Refresh cancels it without replacing the next workspace', a
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
- await page.getByRole('button', { name: 'Refresh', exact: true }).click();
+ await rescan(page);
await requested;
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();
await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible();
- await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0);
+ await expect(toast(page)).not.toHaveText(/^Rescan (finished|could not finish)/);
});
@@ -688,12 +730,12 @@ 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-main > .heading p.muted')).toHaveText('Browse your images, explore the details, keep your creative context.');
+ await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.');
await expect(details(page)).toHaveCSS('width','310px');
- await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px');
- for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible();
- await expect(page.getByRole('searchbox',{name:'Search filenames',exact:true})).toHaveAttribute('placeholder',/filename or folder/);
- await expect(page.getByRole('link',{name:'Group by positive prompt',exact:true})).toHaveAttribute('href','#/library');
+ await expect(page.locator('.gallery-layout')).toHaveCSS('column-gap','22px');
+ for (const label of ['Filter by category','Sort results','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible();
+ await expect(search(page)).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
+ await expect(page.getByRole('button',{name:'Group by positive prompt →',exact:true})).toBeVisible();
const geometry = await cards(page).evaluateAll(nodes=>nodes.slice(0,4).map(node=>{
const box=node.getBoundingClientRect(),media=node.querySelector('.image-open').getBoundingClientRect();
return {x:box.x,y:box.y,right:box.right,bottom:box.bottom,width:box.width,mediaWidth:media.width,mediaHeight:media.height};
@@ -701,9 +743,10 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
expect(geometry[1].x-geometry[0].right).toBeCloseTo(13,0);
expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0);
expect(geometry[3].x).toBeCloseTo(geometry[0].x,0);
- expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2);
- expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y);
- await expect(cards(page).first()).toHaveCSS('outline-style','solid');
+ // The concept's `.image-open img { aspect-ratio: 1.33 }`.
+ expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2);
+ expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y);
+ await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/);
const cover=details(page).getByRole('button',{name:'Open full preview',exact:true});
await expect(cover).toHaveCSS('height','150px');
await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px');
@@ -719,7 +762,7 @@ test('density changes and resize preserve identity focus and selection', async (
await page.setViewportSize({width:1600,height:1100});await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
const first=cards(page).first();await first.evaluate(node=>{window.originalImageCard=node;});
- await first.getByRole('button',{name:/^Open /}).click();
+ await first.getByRole('button',{name:/^Inspect /}).click();
const key=await first.getAttribute('data-image-key');
let imageReads=0;page.on('request',request=>{if(request.url().includes('/images/output?')) imageReads++;});
for (const layout of ['Grid','List','Masonry']) for (const [density,columns] of [['comfortable',3],['large',2],['compact',4]]) {
@@ -743,3 +786,781 @@ test('density changes and resize preserve identity focus and selection', async (
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
});
+
+test('images toolbar and filter row match the concept controls', async ({ page, request }) => {
+ // Old → new: Search filenames → toolbar search; Source/Folder/Media → Filters; Refresh → Import → Rescan (D15).
+ await expect(await request.post('/prompt_manager/save', { data: { text: 'Dog landscape', category: 'Landscapes', tags: ['dogs'], rating: 5 } })).toBeOK();
+ const { roots } = await (await request.get('/prompt_manager/gallery/subfolders?mode=workspace')).json();
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ const main = page.locator('#workspace-main');
+ await expect(main.locator(':scope > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.');
+ await expect(main.locator(':scope > .heading .heading-actions > button')).toHaveText(['Import', 'New prompt']);
+ await expect(main.locator(':scope > .heading .heading-actions > button').nth(1)).toHaveClass(/ca-primary/);
+ await expect(page.getByRole('searchbox', { name: 'Search filenames' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
+ await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
+ await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
+ await expect(main.locator('search.toolbar > *')).toHaveCount(4);
+ await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
+ const row = main.locator('.filter-row');
+ await expect(row.locator(':scope > *')).toHaveCount(5);
+ await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
+ await expect(row.locator(':scope > label.small[for="image-size"]')).toHaveText('Thumbnail size');
+ await expect(row.locator(':scope > select#image-size option')).toHaveText(['Comfortable', 'Large', 'Compact']);
+ await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
+ await expect(row.locator(':scope > span.chip svg.icon')).toHaveCount(1);
+ await expect(row.locator(':scope > button.chip')).toHaveText('Group by positive prompt →');
+
+ await search(page).fill('cat');
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
+ await search(page).fill('dogs');
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
+ await search(page).fill('');
+ await expect(cards(page)).toHaveCount(2);
+ await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
+ await sortBy(page).selectOption('rating');
+ await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
+ await sortBy(page).selectOption('newest');
+ await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
+ await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('');
+ await expect(cards(page)).toHaveCount(2);
+
+ await refine(page, { Source: '1', Folder: 'shared', Media: 'image' });
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
+ await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`);
+ await refine(page, { Media: 'video' });
+ await expect(cards(page)).toHaveCount(0);
+ await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' });
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
+ await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
+ await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
+ await expect(cards(page)).toHaveCount(0);
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats');
+ await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click();
+ await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue('');
+ await expect(cards(page)).toHaveCount(2);
+
+ const rescans = [];
+ page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); });
+ await expect(await request.post('/__test__/add-nested-image')).toBeOK();
+ await rescan(page);
+ await expect(cards(page)).toHaveCount(3);
+ await expect(caption(page)).toContainText('3 of 3 images');
+ expect(rescans).toEqual(['POST']);
+ await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await importDialog(page).getByRole('button', { name: 'Open inspector', exact: true }).click();
+ await expect(page).toHaveURL(/#\/inspect$/);
+ await page.goBack();
+ await expect(page.locator('#workspace-main h1')).toHaveText('Images');
+ await row.locator(':scope > button.chip').click();
+ await expect(page).toHaveURL(/#\/library$/);
+});
+
+test('browser Back closes the open Images dialog and leaves the next page usable', async ({ page }) => {
+ await page.goto('/prompt_manager/workspace#/overview');
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ for (const [button, dialog] of [['Filters', refineDialog], ['Import', importDialog]]) {
+ await page.evaluate(() => { location.hash = '/images'; });
+ await expect(cards(page)).toHaveCount(2);
+ await page.getByRole('button', { name: button, exact: true }).click();
+ await expect(dialog(page)).toBeVisible();
+ await page.goBack();
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
+ await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click();
+ await expect(cards(page)).toHaveCount(2);
+ await search(page).fill('cat');
+ await expect(cards(page)).toHaveCount(1);
+ await search(page).fill('');
+ await expect(cards(page)).toHaveCount(2);
+ await page.evaluate(() => { location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ }
+});
+
+test('count line shows images and positive prompts', async ({ page, request }) => {
+ await page.addInitScript(() => { window.IntersectionObserver = undefined; });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts');
+ await expect(caption(page).locator(':scope > span')).toHaveCount(2);
+ await expect(caption(page).locator(':scope > span').nth(1)).toHaveText(/^Today · [A-Z][a-z]+ \d{1,2}$/);
+ await sortBy(page).selectOption('name');
+ await expect(cards(page).first()).toHaveAttribute('data-image-key', /same\.png$/);
+ await expect(caption(page).locator(':scope > span').nth(1)).toBeHidden();
+ await search(page).fill('cat');
+ await expect(caption(page).locator('#result-count')).toHaveText('1 of 1 image · 1 positive prompt');
+
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
+ // A plain cursor page leaves positive_count out; the first page's count stays.
+ const cursorPages = [];
+ await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => {
+ const response = await route.fetch();
+ const json = await response.json();
+ cursorPages.push('positive_count' in json);
+ await route.fulfill({ response, json });
+ });
+ // A fresh document: a same-page visit would restore the earlier search.
+ await page.goto('about:blank');
+ await page.goto(workspace);
+ await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images · 2 positive prompts');
+ await page.getByRole('button', { name: 'Load more images', exact: true }).click();
+ await expect(caption(page).locator('#result-count')).toHaveText('80 of 102 images · 2 positive prompts');
+ expect(cursorPages).toEqual([false]);
+
+ // A failed count read is null: the count line keeps only the images.
+ await page.unrouteAll({ behavior: 'wait' });
+ await page.route(imagePages, async route => {
+ const response = await route.fetch();
+ await route.fulfill({ response, json: { ...(await response.json()), positive_count: null } });
+ });
+ await page.goto('about:blank');
+ await page.goto(workspace);
+ await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images');
+});
+
+test('Load more toast reports the new total', async ({ page, request }) => {
+ await page.addInitScript(() => { window.IntersectionObserver = undefined; });
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(40);
+ await expect(page.locator('.infinite-continuation p.small')).toHaveText('40 of 102 images · more load as you scroll');
+ await expect(page.locator('#workspace-toast')).toHaveText('');
+ await page.getByRole('button', { name: 'Load more images', exact: true }).click();
+ await expect(page.locator('#workspace-toast')).toHaveText('40 more images loaded. 80 of 102.');
+ await page.getByRole('button', { name: 'Load more images', exact: true }).click();
+ await expect(page.locator('#workspace-toast')).toHaveText('22 more images loaded. 102 of 102.');
+ await expect(page.locator('.infinite-continuation p.small')).toHaveText('You’re all caught up · 102 images');
+ await search(page).fill('no-such-image');
+ await expect(cards(page)).toHaveCount(0);
+ await expect(page.locator('.infinite-continuation p.small')).toHaveText('No matching images · 0 images');
+});
+
+test('Load more focus never falls to the page when the last page hides the button', async ({ page, request }) => {
+ await page.addInitScript(() => { window.IntersectionObserver = undefined; });
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
+ // The last page repeats already listed files: nothing new to focus, and Load more disappears.
+ await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => {
+ const response = await route.fetch();
+ const json = await response.json();
+ await route.fulfill({ response, json: { ...json, images: [], has_more: false, next_cursor: null } });
+ });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(40);
+ const more = page.getByRole('button', { name: 'Load more images', exact: true });
+ await more.focus();
+ await page.keyboard.press('Enter');
+ await expect(more).toBeHidden();
+ await expect(page.locator('article[aria-posinset="40"] .image-open')).toBeFocused();
+ expect(await page.evaluate(() => document.activeElement !== document.body)).toBe(true);
+});
+
+test('a search with no match shows the concept empty state', async ({ page }) => {
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ const empty = page.locator('.gallery-body > .empty');
+ await expect(empty).toBeHidden();
+ await search(page).fill('no-such-image');
+ await expect(cards(page)).toHaveCount(0);
+ await expect(empty).toBeVisible();
+ await expect(empty).toHaveText('No matching images. Try another search or category.');
+ await search(page).fill('');
+ await expect(cards(page)).toHaveCount(2);
+ await expect(empty).toBeHidden();
+});
+
+test('a Load more that finishes after focus moved on leaves focus where the user put it', async ({ page, request }) => {
+ await page.addInitScript(() => { window.IntersectionObserver = undefined; });
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
+ let release, arrived;
+ const held = new Promise(resolve => { release = resolve; });
+ const started = new Promise(resolve => { arrived = resolve; });
+ await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => { arrived(); await held; await route.continue(); }, { times: 1 });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(40);
+ await page.getByRole('button', { name: 'Load more images', exact: true }).click();
+ await started;
+ await search(page).focus();
+ release();
+ await expect(cards(page)).toHaveCount(80);
+ await expect(search(page)).toBeFocused();
+});
+
+// #249 C3: the concept media card (a396–399), its activation (D19) and the concept grid breakpoints (D3).
+const CARD_PATHS = () => {
+ // Tag and class paths below the card; icon internals are one `svg.icon`.
+ const walk = (node, prefix, out) => {
+ for (const child of node.children) {
+ const name = child.tagName.toLowerCase();
+ const classes = [...child.classList].filter(value => value !== 'selected').sort();
+ const path = `${prefix} > ${name}${classes.map(value => `.${value}`).join('')}`;
+ out.push(path);
+ if (name !== 'svg') walk(child, path, out);
+ }
+ return out;
+ };
+ return walk;
+};
+async function cardInventory(page, concept) {
+ return page.locator(concept ? '#image-grid > article.media-card' : 'article[data-image-key]').evaluateAll((nodes, {walkSource, concept}) => {
+ const walk = new Function(`return (${walkSource})()`)();
+ return nodes.map(card => {
+ const copy = card.cloneNode(true);
+ // D8: no selection checkbox; D9: no tag button until image annotations. The D7 star is compared.
+ if (concept) for (const node of copy.querySelectorAll('.image-overlays > label, [aria-label^="Tag or auto-tag "]')) node.remove();
+ const open = card.querySelector('.image-open'), img = open.querySelector('img');
+ const star = card.querySelector('.image-overlays > button.star-button');
+ return { filename: open.getAttribute('aria-label').replace(/^Open /, ''),
+ // The concept's alt is the card title plus its demo-only " · sample illustration N" (m5).
+ alt: img.alt.replace(/ · sample illustration \d+$/, ''),
+ size: [img.getAttribute('width'), img.getAttribute('height')],
+ title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
+ ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
+ inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
+ star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
+ paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
+ });
+ }, { walkSource: CARD_PATHS.toString(), concept });
+}
+
+test('card matches the concept structure', async ({ page, request }) => {
+ await seedConcept(request);
+ await page.setViewportSize({ width: 1600, height: 1100 });
+ await openConcept(page, 'images', 'dark');
+ const concept = new Map((await cardInventory(page, true)).map(card => [card.filename, card]));
+ expect(concept.size).toBeGreaterThan(5);
+ await openProduction(page, 'images', 'dark');
+ await expect(cards(page)).toHaveCount(24);
+ const production = await cardInventory(page, false);
+ const listed = (await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json()).images;
+ let compared = 0;
+ for (const card of production) {
+ const reference = concept.get(card.filename);
+ if (!reference) continue;
+ compared++;
+ // D10 title (the linked positive's, also for pair links), D11 "W × H · PNG · Seed N", the concept's
+ // --media-ratio, image alt and size attributes, Inspect label, the D7 star (the fixture stars the concept's) and markup.
+ expect(card, card.filename).toEqual(reference);
+ }
+ expect(compared).toBeGreaterThan(5);
+ expect(production.filter(card => card.star?.pressed === 'true').length).toBeGreaterThan(0);
+ // D10/D20: a pair-linked file's inspector heading is the positive's title, as the concept panel's.
+ const pairLinked = 'alpine_00001.png';
+ expect(concept.get(pairLinked)).toBeTruthy();
+ await page.getByRole('button', { name: `Inspect ${pairLinked}`, exact: true }).click();
+ await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(concept.get(pairLinked).title);
+ await openConcept(page, 'images', 'dark');
+ await page.locator(`#image-grid [aria-label="Inspect ${pairLinked}"]`).click();
+ await expect(page.locator('#image-inspector h2')).toHaveText(concept.get(pairLinked).title);
+ await openProduction(page, 'images', 'dark');
+ await expect(cards(page)).toHaveCount(24);
+ const first = cards(page).first();
+ // The h3 keeps the full path; no byte size and no "Preview " button remain on the card.
+ for (const image of listed.slice(0, 6)) {
+ const card = page.locator(`article[data-image-key="${image.root_index}:${image.path}"]`);
+ await expect(card.locator('.media-info h3')).toHaveAttribute('title', image.path);
+ }
+ await expect(page.locator('article[data-image-key]').getByRole('button', { name: /^Preview / })).toHaveCount(0);
+ expect(await cards(page).evaluateAll(nodes => nodes.filter(node => /\d\s?(?:B|KB|MB|GB)\b/.test(node.textContent)).length)).toBe(0);
+ await expect(first.locator('.image-card-footer, small, strong')).toHaveCount(0);
+
+ // D10: anything but a confirmed link shows the filename; D11: the seed is the string as sent, unknown parts omitted.
+ await page.route(imagePages, async route => {
+ const response = await route.fetch(), json = await response.json();
+ const [a, b, c, ...rest] = json.images;
+ await route.fulfill({ response, json: { ...json, images: [
+ { ...a, linked: { ...a.linked, status: 'ambiguous' }, seed: '9007199254740993' },
+ { ...b, width: null, height: null, seed: null },
+ { ...c, linked: { status: 'unavailable', positive_id: null, title: null, title_source: null, category: null, rating: null } },
+ ...rest] } });
+ });
+ await page.goto('about:blank'); await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(24);
+ const [a, b, c] = listed;
+ await expect(cards(page).nth(0).locator('.media-info h3')).toHaveText(a.filename);
+ await expect(cards(page).nth(0).locator('.media-info p')).toHaveText(`${a.width} × ${a.height} · PNG · Seed 9007199254740993`);
+ await expect(cards(page).nth(1).locator('.media-info h3')).toHaveText(b.linked.title);
+ await expect(cards(page).nth(1).locator('.media-info p')).toHaveText('PNG');
+ await expect(cards(page).nth(2).locator('.media-info h3')).toHaveText(c.filename);
+});
+
+test('image click opens the viewer and Info selects the panel', async ({ page }) => {
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ // D19: the first load still selects the first image into the panel.
+ await expect(cards(page).first()).toHaveClass(/selected/);
+ await expect(details(page)).toContainText(/output/i);
+ const viewer = page.locator('dialog#viewer-dialog');
+ const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true });
+ await image.click();
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
+ await expect(viewer.locator('.viewer-canvas img')).toHaveAttribute('src', /root=1/);
+ await page.keyboard.press('Escape');
+ await expect(viewer).toHaveCount(0);
+ await expect(image).toBeFocused();
+ for (const key of ['Enter', ' ']) {
+ await image.focus(); await page.keyboard.press(key);
+ await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(viewer).toHaveCount(0);
+ await expect(image).toBeFocused();
+ }
+ const info = cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true });
+ await expect(info.locator('svg.icon')).toHaveCount(1);
+ await info.click();
+ await expect(viewer).toHaveCount(0);
+ await expect(cards(page).nth(1)).toHaveClass(/selected/);
+ await expect(cards(page).first()).not.toHaveClass(/selected/);
+ await expect(info).not.toHaveAttribute('aria-pressed');
+ await expect(details(page)).toContainText(/input/i);
+ await image.focus(); await page.keyboard.press('ArrowLeft');
+ await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
+ await expect(details(page)).toContainText(/input/i);
+});
+
+test('grid columns follow concept breakpoints', async ({ page, request }) => {
+ await seedConcept(request);
+ const expected = { comfortable: { 1600: 3, 1800: 4, 1200: 2, 390: 2 }, large: { 1600: 2, 1800: 2, 1200: 2, 390: 2 },
+ compact: { 1600: 4, 1800: 5, 1200: 3, 390: 2 } };
+ const rowLength = selector => page.locator(selector).evaluateAll(nodes => {
+ const top = nodes[0].getBoundingClientRect().top;
+ return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length;
+ });
+ for (const [url, selector] of [[null, '#image-grid > article.media-card'], [workspace, 'article[data-image-key]']]) {
+ for (const width of [1600, 1800, 1200, 390]) {
+ await page.setViewportSize({ width, height: 1100 });
+ if (url) { await page.goto('about:blank'); await page.goto(url); } else await openConcept(page, 'images', 'dark');
+ await expect.poll(() => page.locator(selector).count()).toBeGreaterThan(5);
+ for (const density of ['comfortable', 'large', 'compact']) {
+ await page.locator('#image-size').selectOption(density);
+ await expect.poll(() => rowLength(selector), `${url ? 'production' : 'concept'} ${density} ${width}`).toBe(expected[density][width]);
+ if (url) {
+ // The columns come from concept.css: the grid's own tracks, not a production breakpoint table.
+ expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length))
+ .toBe(expected[density][width]);
+ await cards(page).first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown');
+ await expect(cards(page).nth(expected[density][width]).locator('.image-open')).toBeFocused();
+ }
+ }
+ }
+ }
+});
+
+// `cssZoom` follows the suite's zoom convention (media queries keep the 1600px breakpoint); the 800px variant is a real
+// 200% browser zoom: half the CSS viewport at device scale 2, so the concept's narrower breakpoint applies too (m4).
+async function zoomedGallery(page, request, { cssZoom }) {
+ await page.addInitScript(() => { window.IntersectionObserver = undefined; });
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
+ if (cssZoom) await page.setViewportSize({ width: 1600, height: 1100 });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(40);
+ if (cssZoom) await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
+ const more = page.getByRole('button', { name: 'Load more images', exact: true });
+ for (const loaded of [80, 120, 160, 200, 202]) {
+ await more.click();
+ await expect(caption(page)).toContainText(`${loaded} of 202 images`);
+ }
+ await expect.poll(() => cards(page).count()).toBeLessThan(120);
+ // Every mounted card keeps the concept structure inside its reserved box.
+ expect(await cards(page).evaluateAll(nodes => nodes.filter(node => {
+ const box = node.getBoundingClientRect(), info = node.querySelector('.media-info').getBoundingClientRect();
+ return info.bottom > box.bottom + 1 || info.right > box.right + 1 || !node.querySelector('.media-art > .image-open');
+ }).length)).toBe(0);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ const first = cards(page).first();
+ await first.locator('.image-open').focus();
+ await page.keyboard.press('End');
+ await expect(page.locator('article[aria-posinset="202"] .image-open')).toBeFocused();
+ await page.keyboard.press('Home');
+ await expect(page.locator('article[aria-posinset="1"] .image-open')).toBeFocused();
+ const columns = await cards(page).evaluateAll(nodes => {
+ const top = nodes[0].getBoundingClientRect().top;
+ return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length;
+ });
+ await page.keyboard.press('ArrowDown');
+ await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused();
+ // The concept's card order: image, the D7 star, then Inspect.
+ await page.keyboard.press('Tab');
+ await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
+ await page.evaluate(() => {
+ const card = document.querySelector('article[aria-posinset="1"]');
+ const grid = document.querySelector('.image-grid');
+ window.scrollTo(0, (grid.getBoundingClientRect().top + window.scrollY) + card.offsetHeight * 30);
+ });
+ await expect.poll(() => cards(page).count()).toBeLessThan(120);
+ const previousScroll = await page.evaluate(() => window.scrollY);
+ expect(previousScroll).toBeGreaterThan(1000);
+ await page.evaluate(() => { window.location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await page.evaluate(() => { window.location.hash = '/images'; });
+ await expect(caption(page)).toContainText('202 of 202 images');
+ await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(240);
+ await expect.poll(() => cards(page).count()).toBeLessThan(120);
+ if (cssZoom) await page.evaluate(() => { document.documentElement.style.zoom = ''; });
+}
+
+test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async ({ page, request }) => {
+ await zoomedGallery(page, request, { cssZoom: true });
+});
+
+test.describe('at an 800px viewport and device scale 2', () => {
+ test.use({ viewport: { width: 800, height: 550 }, deviceScaleFactor: 2 });
+ test('bounded DOM, keyboard and scroll restoration survive at real 200% zoom', async ({ page, request }) => {
+ await zoomedGallery(page, request, { cssZoom: false });
+ // The concept's narrow breakpoint, read from the grid's own tracks.
+ expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length)).toBe(2);
+ });
+});
+
+test('a debounced search that lands after a sort change does not reload the same page', async ({ page }) => {
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ const fetches = await recordFetches(page);
+ // The sort change applies the typed query at once; the search debounce then reports the same values.
+ await search(page).fill('same');
+ await sortBy(page).selectOption('name');
+ // HTML timers of equal delay run in the order they were set, so a timer of the toolbar's own debounce delay set
+ // now runs after the debounced report.
+ await page.evaluate(async () => {
+ const { SEARCH_DELAY } = await import(new URL('js/workspace/toolbar.mjs', document.baseURI).href);
+ await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY));
+ });
+ const reads = (await fetches()).filter(item => imagePages(new URL(`http://host${item.path}${item.search}`)));
+ expect(reads.filter(item => new URLSearchParams(item.search).get('q') === 'same')).toHaveLength(1);
+});
+
+// #249 D18: the concept's hover zoom (1.03 over 0.2s); reduced motion makes it instant, and a touch device (hover: none)
+// has no sticky zoom after a tap.
+for (const motion of ['no-preference', 'reduce']) {
+ test(`card image zooms on hover per concept (${motion})`, async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: motion });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ const card = cards(page).first();
+ const img = card.locator('.image-open img');
+ const touch = await page.evaluate(() => matchMedia('(hover: none)').matches);
+ expect(await img.evaluate(n => getComputedStyle(n).transform)).toBe('none');
+ await card.locator('.image-open').hover();
+ // Touch: the emulated hover must not leave a zoom behind.
+ await expect.poll(() => img.evaluate(n => getComputedStyle(n).transform)).toBe(touch ? 'none' : 'matrix(1.03, 0, 0, 1.03, 0, 0)');
+ expect(await img.evaluate(n => getComputedStyle(n).transitionDuration)).toBe(motion === 'reduce' ? '0s' : '0.2s');
+ });
+}
+
+// D7 = A: the concept's star (app.js mediaCard, actions.favorite) against the PR B favorites store.
+const favoriteRoute = '**/prompt_manager/images/favorite';
+const star = (card, filename) => card.getByRole('button', { name: `Favorite ${filename}`, exact: true });
+const toast = page => page.locator('#workspace-toast');
+const listedFavorites = async request => Object.fromEntries((await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json())
+ .images.map(image => [`${image.root_index}:${image.path}`, image.favorite]));
+async function expectStar(button, favorite) {
+ await expect(button).toHaveText(favorite ? '★' : '☆');
+ await expect(button).toHaveAttribute('aria-pressed', String(favorite));
+}
+
+test('favorite persists across reload', async ({ page, request }) => {
+ await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
+ const sent = [];
+ page.on('request', item => {
+ if (new URL(item.url()).pathname === '/prompt_manager/images/favorite') {
+ sent.push({ method: item.method(), type: item.headers()['content-type'], body: item.postDataJSON() });
+ }
+ });
+ await page.goto(workspace);
+ const input = page.locator('article[data-image-key="1:shared/same.png"]');
+ const video = page.locator('article[data-image-key="0:clip.webm"]');
+ await expect(input).toBeVisible(); await expect(video).toBeVisible();
+ // The star sits in the concept's overlay on image and video cards alike.
+ await expect(input.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
+ await expect(video.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
+ await expectStar(star(input, 'same.png'), false);
+ await expectStar(star(video, 'clip.webm'), false);
+ const selected = await page.locator('article.selected').getAttribute('data-image-key');
+ expect(selected).not.toBe('1:shared/same.png');
+
+ await star(input, 'same.png').click();
+ await expectStar(star(input, 'same.png'), true);
+ await expect(toast(page)).toHaveText('Added to favorites.');
+ // The star neither opens the viewer nor inspects the card.
+ await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
+ await expect(page.locator('article.selected')).toHaveAttribute('data-image-key', selected);
+ await expect(star(input, 'same.png')).toBeFocused();
+
+ // Keyboard: Enter stars the video, Space clears it again.
+ await star(video, 'clip.webm').focus();
+ await page.keyboard.press('Enter');
+ await expectStar(star(video, 'clip.webm'), true);
+ await expect(toast(page)).toHaveText('Added to favorites.');
+ // One request per file at a time: a press while the star is still busy is ignored, so wait for it to settle.
+ await expect(star(video, 'clip.webm')).not.toHaveAttribute('aria-disabled', 'true');
+ await page.keyboard.press(' ');
+ await expectStar(star(video, 'clip.webm'), false);
+ await expect(toast(page)).toHaveText('Removed from favorites.');
+ await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
+ await expect.poll(() => sent.length).toBe(3);
+ expect(sent).toEqual([
+ { method: 'PUT', type: 'application/json', body: { root_index: 1, path: 'shared/same.png' } },
+ { method: 'PUT', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
+ { method: 'DELETE', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
+ ]);
+ // Favorites are keyed by source: the same file name in the other root stays unstarred.
+ await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': true, '0:shared/same.png': false, '0:clip.webm': false });
+
+ await page.reload();
+ await expectStar(star(input, 'same.png'), true);
+ await expectStar(star(page.locator('article[data-image-key="0:shared/same.png"]'), 'same.png'), false);
+ await expectStar(star(video, 'clip.webm'), false);
+ await star(input, 'same.png').click();
+ await expectStar(star(input, 'same.png'), false);
+ await expect(toast(page)).toHaveText('Removed from favorites.');
+ await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': false });
+ await page.reload();
+ await expectStar(star(input, 'same.png'), false);
+
+ // Leaving Images and coming back restores the browsing state with the current star.
+ await star(input, 'same.png').click();
+ await expect(toast(page)).toHaveText('Added to favorites.');
+ await page.evaluate(() => { window.location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await page.evaluate(() => { window.location.hash = '/images'; });
+ await expectStar(star(input, 'same.png'), true);
+});
+
+test('favorite failure keeps the previous state and announces it', async ({ page, request }) => {
+ await page.goto(workspace);
+ const card = page.locator('article[data-image-key="0:shared/same.png"]');
+ const button = star(card, 'same.png');
+ await expectStar(button, false);
+ const held = [];
+ await page.route(favoriteRoute, route => { held.push(route); });
+ const fetches = await recordFetches(page);
+ const favoriteFetches = async () => (await fetches()).filter(item => item.path === '/prompt_manager/images/favorite').length;
+ // Optimistic while in flight; a second press during the request sends nothing more.
+ await button.click();
+ await expectStar(button, true);
+ // The pending star says it is busy, so the ignored second press is not a silent no-op.
+ await expect(button).toHaveAttribute('aria-disabled', 'true');
+ // Playwright waits for an aria-disabled button to enable; a user can still press it, so force the press.
+ await button.click({ force: true });
+ expect(await favoriteFetches()).toBe(1);
+ await expect.poll(() => held.length).toBe(1);
+ await held[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
+ await expectStar(button, false);
+ await expect(button).not.toHaveAttribute('aria-disabled', 'true');
+ await expect(toast(page)).toHaveText('Favorite could not be added. Try again.');
+ expect((await listedFavorites(request))['0:shared/same.png']).toBe(false);
+
+ // Removal failure keeps the star; a network failure is announced the same way.
+ await page.unroute(favoriteRoute);
+ await button.click();
+ await expectStar(button, true);
+ await expect(toast(page)).toHaveText('Added to favorites.');
+ await page.route(favoriteRoute, route => route.abort('failed'));
+ await button.click();
+ await expectStar(button, true);
+ await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
+ await page.unroute(favoriteRoute);
+ await page.reload();
+ await expectStar(star(card, 'same.png'), true);
+ expect((await listedFavorites(request))['0:shared/same.png']).toBe(true);
+
+ // A failure that lands after leaving Images still restores the star the user comes back to, and is announced.
+ const late = [];
+ await page.route(favoriteRoute, route => { late.push(route); });
+ await star(card, 'same.png').click();
+ await expectStar(star(card, 'same.png'), false);
+ await expect.poll(() => late.length).toBe(1);
+ await page.evaluate(() => { window.location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await late[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
+ await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
+ await page.evaluate(() => { window.location.hash = '/images'; });
+ await expectStar(star(card, 'same.png'), true);
+ await page.unroute(favoriteRoute);
+
+ // One request per file at a time, across leaving and returning: the request outlives the page that sent it, the
+ // returned page still waits for it, and its answer reaches the card the user is looking at.
+ const across = [];
+ await page.route(favoriteRoute, route => { across.push(route); });
+ await star(card, 'same.png').click();
+ await expectStar(star(card, 'same.png'), false);
+ await expect.poll(() => across.length).toBe(1);
+ await page.evaluate(() => { window.location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await page.evaluate(() => { window.location.hash = '/images'; });
+ await expectStar(star(card, 'same.png'), false);
+ await expect(star(card, 'same.png')).toHaveAttribute('aria-disabled', 'true');
+ // The page was reloaded above: record afresh, from the ignored press on.
+ const afterReturn = await recordFetches(page);
+ await star(card, 'same.png').click({ force: true });
+ expect((await afterReturn()).filter(item => item.path === '/prompt_manager/images/favorite')).toEqual([]);
+ expect(across.length).toBe(1);
+ await expectStar(star(card, 'same.png'), false);
+ await across[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
+ await expectStar(star(card, 'same.png'), true);
+ await expect(star(card, 'same.png')).not.toHaveAttribute('aria-disabled', 'true');
+ await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
+ // The rolled-back star is also what the next return shows, and the server never changed.
+ await page.evaluate(() => { window.location.hash = '/overview'; });
+ await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
+ await page.evaluate(() => { window.location.hash = '/images'; });
+ await expectStar(star(card, 'same.png'), true);
+ expect((await listedFavorites(request))['0:shared/same.png']).toBe(true);
+});
+
+// #249 final review I1: the old Refresh button showed that it ran; Import → Rescan says so too, and when it ends.
+test('Import Rescan shows that it runs, ignores a second press and announces the outcome', async ({ page, request }) => {
+ const held = [];
+ await page.route('**/prompt_manager/gallery/rescan', route => { held.push(route); });
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ await expect(await request.post('/__test__/add-nested-image')).toBeOK();
+ await rescan(page);
+ await expect.poll(() => held.length).toBe(1);
+ await expect(toast(page)).toHaveText('Rescanning output folders…');
+ // Reopened while the scan runs, Rescan says it is running; a press on it is ignored and keeps the dialog open.
+ await page.getByRole('button', { name: 'Import', exact: true }).click();
+ const running = importDialog(page).getByRole('button', { name: 'Rescanning…', exact: true });
+ await expect(running).toHaveAttribute('aria-disabled', 'true');
+ await running.click({ force: true });
+ await expect(importDialog(page)).toBeVisible();
+ expect(held.length).toBe(1);
+ await expect(cards(page)).toHaveCount(2);
+ // The scan's own answer: every file in both fixture roots (output and input).
+ await held[0].continue();
+ await expect(toast(page)).toHaveText('Rescan finished. 3 files in 2 output folders.');
+ const idle = importDialog(page).getByRole('button', { name: 'Rescan', exact: true });
+ await expect(idle).not.toHaveAttribute('aria-disabled', 'true');
+ await page.keyboard.press('Escape');
+ await expect(cards(page)).toHaveCount(3);
+ await expect(caption(page)).toContainText('3 of 3 images');
+});
+
+// #249 final review I2: a polite live region re-announces replaced text, so an unchanged status is never rewritten.
+test('a star press leaves the continuation status, count and caption date untouched', async ({ page }) => {
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ await expect(page.locator('#gallery-continuation p')).toHaveText(/caught up/);
+ await expect(caption(page).locator('span').nth(1)).toBeVisible();
+ await page.evaluate(() => {
+ window.__statusMutations = [];
+ const observer = new MutationObserver(records => {
+ window.__statusMutations.push(...records.map(record => `${record.type}:${record.target.nodeName}`));
+ });
+ for (const node of [document.querySelector('#gallery-continuation p'), document.querySelector('#result-count'),
+ document.querySelector('.section-caption > span:nth-child(2)')]) {
+ observer.observe(node, { childList: true, characterData: true, subtree: true, attributes: true });
+ }
+ });
+ const card = page.locator('article[data-image-key="0:shared/same.png"]');
+ await star(card, 'same.png').click();
+ await expectStar(star(card, 'same.png'), true);
+ await expect(toast(page)).toHaveText('Added to favorites.');
+ expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
+});
+
+// #249 final review m1: as in the old field row, a Source offers only its own folders.
+test('Refine offers only the chosen source folders', async ({ page, request }) => {
+ await expect(await request.post('/__test__/add-nested-image')).toBeOK();
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(3);
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ const source = refineDialog(page).getByLabel('Source', { exact: true });
+ const folder = refineDialog(page).getByLabel('Folder', { exact: true });
+ const values = () => folder.locator('option').evaluateAll(options => options.map(option => option.value));
+ expect(await values()).toEqual(['', 'project', 'project/day', 'shared']);
+ await folder.selectOption('project');
+ await source.selectOption('1');
+ expect(await values()).toEqual(['', 'shared']);
+ await expect(folder).toHaveValue('');
+ await source.selectOption('0');
+ expect(await values()).toEqual(['', 'project', 'project/day', 'shared']);
+ await folder.selectOption('shared');
+ await source.selectOption('1');
+ await expect(folder).toHaveValue('shared');
+ await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
+ await expect(cards(page)).toHaveCount(1);
+ await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
+ // Reopened on that Source, the dialog again lists only its folders.
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ expect(await values()).toEqual(['', 'shared']);
+});
+
+/**
+ * Record every fetch the page's code calls from now on, as `{method, path, search}`. The API client calls fetch in the
+ * same task as the action that needs it, so after a press the record already holds any request the press started.
+ */
+async function recordFetches(page) {
+ await page.evaluate(() => {
+ window.__fetches = [];
+ const original = window.fetch;
+ window.fetch = (resource, init = {}) => {
+ const url = new URL(String(resource instanceof Request ? resource.url : resource), window.location.href);
+ window.__fetches.push({ method: (init.method || 'GET').toUpperCase(), path: url.pathname, search: url.search });
+ return original.call(window, resource, init);
+ };
+ });
+ return () => page.evaluate(() => window.__fetches);
+}
+/** The listing page reads among them (the sidebar count's `limit=1` read is not one). */
+async function countImageFetches(page) {
+ const fetches = await recordFetches(page);
+ return async () => (await fetches()).filter(item => imagePages(new URL(`http://host${item.path}${item.search}`))).length;
+}
+
+// #249 final review m2: like the toolbar, an unchanged refinement reloads nothing, so the loaded cards (and with them
+// the reader's place) stay as they were.
+test('Apply or Clear without a change keeps the loaded page', async ({ page, request }) => {
+ await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
+ await page.setViewportSize({ width: 1200, height: 700 });
+ await page.goto(workspace);
+ await expect(caption(page)).toContainText(/of 82 images/);
+ const fetches = await countImageFetches(page);
+ await cards(page).first().evaluate(card => { card.dataset.kept = 'yes'; });
+ const before = await caption(page).textContent();
+ for (const action of ['Apply filters', 'Clear filters']) {
+ await page.getByRole('button', { name: 'Filters', exact: true }).click();
+ await refineDialog(page).getByRole('button', { name: action, exact: true }).click();
+ await expect(refineDialog(page)).toBeHidden();
+ expect(await fetches()).toBe(0);
+ await expect(cards(page).first()).toHaveAttribute('data-kept', 'yes');
+ await expect(caption(page)).toHaveText(before);
+ }
+ // A real change still applies.
+ await refine(page, { Media: 'image' });
+ await expect.poll(fetches).toBe(1);
+});
+
+// #249 final review m6: discovery and categories failing together on mount give one alert, not two.
+test('unreachable sources and categories announce one error', async ({ page }) => {
+ await page.route('**/prompt_manager/gallery/subfolders?*', route => route.abort('failed'));
+ await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
+ await page.goto(workspace);
+ await expect(cards(page)).toHaveCount(2);
+ await expect(page.getByRole('alert')).toHaveText('Sources, folders and categories could not be loaded. Rescan from Import to retry.');
+ await expect(page.locator('.options-status')).toHaveCount(1);
+ await page.unroute('**/prompt_manager/gallery/subfolders?*');
+ await page.unroute('**/prompt_manager/library/options*');
+ await rescan(page);
+ await expect(page.getByRole('alert')).toHaveCount(0);
+});
+
+// #249 final review m8: D36's alphabetical categories hold whatever order the server sends.
+test('toolbar categories are alphabetical regardless of server order', async ({ page }) => {
+ await page.route('**/prompt_manager/library/options*', route => route.fulfill({
+ json: { success: true, categories: ['zeta', 'Alpha', 'beta'] },
+ }));
+ await page.goto(workspace);
+ const category = page.getByRole('combobox', { name: 'Filter by category', exact: true });
+ await expect(category.locator('option')).toHaveText(['All categories', 'Alpha', 'beta', 'zeta']);
+});
diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs
index 1efc62b..50dda65 100644
--- a/tests/browser/workspace-library-editor.spec.mjs
+++ b/tests/browser/workspace-library-editor.spec.mjs
@@ -478,8 +478,9 @@ test('library heading offers Export then + New prompt, and New opens the saved p
test('new from Images cancel preserves browse',async({page})=>{
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
- const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
- const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
+ // #249 C2: the concept's Images heading puts Import before New prompt.
+ const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
+ const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs
index 7efae6d..327d5a3 100644
--- a/tests/browser/workspace-library.spec.mjs
+++ b/tests/browser/workspace-library.spec.mjs
@@ -197,7 +197,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
- await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
+ await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs
index 7662960..c933206 100644
--- a/tests/browser/workspace-visual.spec.mjs
+++ b/tests/browser/workspace-visual.spec.mjs
@@ -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', '.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', '.media-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);
@@ -106,10 +106,10 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
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 page.locator('.library-shared-gallery .image-card-actions button').first().click();
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
- await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
@@ -122,7 +122,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
- const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
+ const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
@@ -131,7 +131,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
- await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
+ await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog');
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
await compareCanonicalHistory(reference, actual, testInfo);
@@ -290,18 +290,18 @@ async function containedImageContent(page) {
const failures = [];
for (const card of nodes) {
const outer = card.getBoundingClientRect();
- for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
+ for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
const node = card.querySelector(selector), box = node.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
- (selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
+ (selector === '.media-info p' && node.scrollHeight > node.clientHeight + 1)) {
failures.push({ key: card.dataset.imageKey, selector,
card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom },
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
}
}
- const action = card.querySelector('.image-card-footer button').getBoundingClientRect();
- const range = document.createRange(); range.selectNodeContents(card.querySelector('small'));
+ const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
+ const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
for (const text of range.getClientRects()) {
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) {
@@ -341,18 +341,25 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused();
expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey);
}
- const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' });
- const filename = await longCard.locator('strong').textContent();
+ const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') });
+ const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
expect(filename).toMatch(/layered-context-.*\.png/);
- await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter');
+ // D19: Info selects into the panel; the image itself opens the viewer.
+ await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
await expect(page.locator('.image-details h2')).toHaveText(filename);
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
- await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
- await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
+ await expect(page.locator('#viewer-title')).toHaveText(filename);
await page.keyboard.press('Escape');
+ // The concept's card order: image, the D7 star (hidden in List, as the concept hides its overlays), then Inspect.
+ const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
- await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
+ if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Tab'); }
+ await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
+ await page.keyboard.press('Shift+Tab');
+ if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Shift+Tab'); }
+ await expect(longCard.locator('.image-open')).toBeFocused();
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect.poll(() => containedImageContent(page)).toEqual([]);
@@ -374,7 +381,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26);
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
- await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
+ await more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`);
}
await expect.poll(() => cards.count()).toBeLessThan(120);
await page.evaluate(stride => {
@@ -401,33 +408,32 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
- await selected.locator('.image-open').click(); await selected.locator('.image-open').focus();
- const filename = await selected.locator('strong').textContent();
+ await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
+ const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
await page.evaluate(() => {
- const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')];
- const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get;
- const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]);
- const restore = () => { for (const [node, descriptor] of descriptors) {
- if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight;
- } };
+ const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')];
+ // Production measures each footer's fractional height (`getBoundingClientRect().height / scale`, #249 C7), so the
+ // probe records the visible anchor at the first measurement after the zoom, before the grid relays out.
+ const nativeRect = Element.prototype.getBoundingClientRect;
+ const restore = () => { for (const node of footers) delete node.getBoundingClientRect; };
window.footerMeasurementProbe = { before: null, restore };
- for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
- const height = nativeHeight.call(this);
+ for (const footer of footers) Object.defineProperty(footer, 'getBoundingClientRect', { configurable: true, writable: true, value() {
+ const rect = nativeRect.call(this);
if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' &&
- Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
+ Math.abs(Number.parseFloat(this.closest('.media-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey,
top: first.getBoundingClientRect().top - top } : null;
restore();
}
- return height;
+ return rect;
} });
document.documentElement.style.zoom = '4';
const box = grid.getBoundingClientRect();
- // The approved initial reservation is 54px with 4:3 media and a 13px row gap.
- const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13;
+ // The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap.
+ const stride = (box.width / 4 - 2) / 1.33 + 54 + 2 + 13;
// Place row eight inside the 52px scaled gap so the preceding row is not visible.
const index = 8;
scrollTo(0, box.top + scrollY + index * stride * 4 - 16);
@@ -454,10 +460,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.getByRole('button', { name: 'List', exact: true }).click();
await expect.poll(() => containedImageContent(page)).toEqual([]);
expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97);
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match');
+ await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('no-real-media-match');
await expect(cards).toHaveCount(0);
expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
- await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('');
+ await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('');
await expect(cards).toHaveCount(24);
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')));
@@ -479,7 +485,7 @@ test('supplemental wrapped real footer reservation survives append without movin
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(40);
- await cards.first().locator('.image-open').click();
+ await cards.first().locator('.image-card-actions button').click();
const selectedKey = await cards.first().getAttribute('data-image-key');
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
@@ -491,18 +497,23 @@ test('supplemental wrapped real footer reservation survives append without movin
});
try {
const more = page.getByRole('button', { name: 'Load more images', exact: true });
- await more.click(); await expect.poll(() => pending).toBe(true);
+ // At 4x zoom the concept continuation (copy + Load more) fills the viewport, so keep the last cards in view and
+ // press Load more from the keyboard without scrolling to it.
+ await cards.last().scrollIntoViewIfNeeded();
+ await more.evaluate(node => node.focus({ preventScroll: true })); await page.keyboard.press('Enter');
+ await expect.poll(() => pending).toBe(true);
const anchor = await cards.evaluateAll(nodes => {
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null;
});
expect(anchor).not.toBeNull();
- release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images');
+ release(); await expect(page.locator('.section-caption')).toContainText('80 of 202 images');
await expect(cards).toHaveCount(80);
await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1);
await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/);
- await expect(more).toBeFocused();
+ // The concept focuses the first appended image after a Load more button press.
+ await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
await expect.poll(() => containedImageContent(page)).toEqual([]);
} finally { release(); }
});
@@ -577,33 +588,34 @@ test('Images visual layouts retain real delayed media long filenames and loaded
await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'});
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
await expect.poll(()=>mediaRequests).toBeGreaterThan(0);
- expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false);
- await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px');
+ expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false);
+ await expect(page.locator('.media-card').first()).toHaveCSS('border-radius','7px');
await orderedMedia(page);
} finally {release();}
for (const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});
for (const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
- await page.locator('.image-card .image-open').first().click();
+ await page.locator('.media-card .image-card-actions button').first().click();
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
for (const layout of ['Grid','List','Masonry']) {
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
- if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px');
+ if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px');
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false);
}
await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded();
- const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'});
- await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/);
- await longCard.locator('.image-open').click();
- await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent());
+ const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
+ const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
+ expect(longName).toMatch(/layered-context-.*\.png/);
+ await longCard.locator('.image-card-actions button').click();
+ await expect(page.locator('.image-details h2')).toHaveText(longName);
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
}
}
await page.evaluate(()=>{location.hash='#/overview';});
- await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
+ await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
});
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
@@ -624,6 +636,6 @@ test('embedded history gallery keeps the history heading and disposes its layout
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
- await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
+ await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0);
}
});
diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs
index c22f6a0..761c393 100644
--- a/tests/browser/workspace.spec.mjs
+++ b/tests/browser/workspace.spec.mjs
@@ -11,7 +11,8 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
const sidebarLinks = page => page.locator('aside.sidebar nav a');
const routes = [
['overview', 'Overview', null],
- ['images', 'Images', '/prompt_manager/gallery'],
+ // D29: Images no longer links the legacy gallery; Overview's tool cards still do.
+ ['images', 'Images', null],
['library', 'Prompt library', '/prompt_manager/admin'],
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
@@ -80,6 +81,13 @@ test('all shell destinations deep-link, reload and expose working existing appli
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
}
+ // D29: the legacy image gallery is reached from Overview's tool card, not from Images.
+ await page.goto(`${workspace}#/overview`);
+ const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
+ await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
+ await expect(await request.get('/prompt_manager/gallery')).toBeOK();
+ await gallery.click();
+ await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
@@ -308,7 +316,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
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 expect(page.locator('#workspace-main .section-caption')).toBeVisible();
await countThemeWrites(page);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py
index ed27cb2..0c5f187 100644
--- a/tests/browser/workspace_concept_fixture.py
+++ b/tests/browser/workspace_concept_fixture.py
@@ -155,6 +155,9 @@ CONCEPT_RUN_COUNT = 4
# The concept's Model fact (app.js:408).
CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
+# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
+MAX_SEEDLESS_IMAGES = 2
+SEEDLESS_SIZE = (1536, 1152)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
"execution_run_outputs",
@@ -165,6 +168,7 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
+ "image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -176,6 +180,11 @@ _TABLES = (
)
+def concept_starred(prompt_index: int, round_index: int) -> bool:
+ """The concept's favorites (app.js ``starred``): each other prompt's first image."""
+ return round_index == 0 and prompt_index % 2 == 0
+
+
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
@@ -313,18 +322,34 @@ def _record_run(
return start.run_id
+def _write_seedless_png(path: Path) -> None:
+ """A plain PNG with no metadata: no prompt, no seed, no link."""
+ with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
+ image.save(path)
+
+
+def _bounded(name: str, value: int, maximum: int) -> None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"{name} must be an integer")
+ if not 0 <= value <= maximum:
+ raise ValueError(f"{name} is out of range")
+
+
def seed_workspace_concept(
- api: PromptManagerAPI, root: Path, *, extra_images: int = 0
+ api: PromptManagerAPI,
+ root: Path,
+ *,
+ extra_images: int = 0,
+ seedless_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.
+ ``seedless_images`` adds unlinked PNGs without metadata, newest of all.
"""
- 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")
+ _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
+ _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
@@ -356,6 +381,10 @@ def seed_workspace_concept(
folder = root / "output" / "concepts"
folder.mkdir()
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
+ # The API package needs the host stubs the server installs before importing it.
+ from py.api.images import _favorite_relative_key, _favorite_root_key
+
+ favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
@@ -376,6 +405,10 @@ def seed_workspace_concept(
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"})
+ if concept_starred(prompt_index, round_index):
+ db.set_image_favorite(
+ favorite_root, _favorite_relative_key(f"concepts/{name}")
+ )
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
)
@@ -400,10 +433,19 @@ def seed_workspace_concept(
"prompt": prompt.id,
"variant": variant,
"seed": seed,
+ "path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
+ seedless = []
+ for number in range(seedless_images):
+ name = f"seedless_{number + 1:05}.png"
+ path = folder / name
+ _write_seedless_png(path)
+ stamp = now - 10 - number * 10
+ os.utime(path, (stamp, stamp))
+ seedless.append({"filename": name, "path": f"concepts/{name}"})
with db.model.get_connection() as conn:
counts = conn.execute(
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
@@ -416,6 +458,9 @@ def seed_workspace_concept(
api.invalidate_gallery_cache()
return {
"epoch": epoch,
+ # The newest file's mtime (epoch seconds), for the D17 caption date.
+ "newest_modified": now - 10 if seedless_images else now - 60,
+ "seedless": seedless,
"prompts": prompts,
"images": images,
"runs": runs,
diff --git a/tests/browser/workspace_library_editor_fixture.py b/tests/browser/workspace_library_editor_fixture.py
index 270c42d..1b46c95 100644
--- a/tests/browser/workspace_library_editor_fixture.py
+++ b/tests/browser/workspace_library_editor_fixture.py
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
+ "image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
diff --git a/tests/browser/workspace_visual_fixture.py b/tests/browser/workspace_visual_fixture.py
index 3504d46..2e78cc9 100644
--- a/tests/browser/workspace_visual_fixture.py
+++ b/tests/browser/workspace_visual_fixture.py
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
+ "image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
diff --git a/tests/browser/workspace_vocabulary_fixture.py b/tests/browser/workspace_vocabulary_fixture.py
index 4841858..91ebff2 100644
--- a/tests/browser/workspace_vocabulary_fixture.py
+++ b/tests/browser/workspace_vocabulary_fixture.py
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
+ "image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs
index 85884f9..3c33882 100644
--- a/tests/comfyui/execution-history.spec.mjs
+++ b/tests/comfyui/execution-history.spec.mjs
@@ -237,7 +237,7 @@ test.describe('public chronological history with the actual acknowledged host br
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
await expect(run.locator('[data-receipt]')).toHaveCount(4);
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
- const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
+ const viewer = workspace.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
const frozen = attempts[0].context;
diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs
index b015a5a..c74a523 100644
--- a/tests/comfyui/prompt-sender.spec.mjs
+++ b/tests/comfyui/prompt-sender.spec.mjs
@@ -41,13 +41,15 @@ test('native image inspector previews and sends a pair into real visible widgets
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/images');
- await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
+ // D15: Refresh is Import → Rescan.
+ await workspace.getByRole('button',{name:'Import',exact:true}).click();
+ await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click();
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
- await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
+ await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
- const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
+ const viewer=workspace.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs
index d860b65..c4c1b45 100644
--- a/tests/comfyui/workspace.spec.mjs
+++ b/tests/comfyui/workspace.spec.mjs
@@ -34,9 +34,16 @@ test('workspace serves real library data beside either ComfyUI renderer', async
if (collection.images.length) {
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
} else {
- await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
+ await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
+ await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
}
- await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
+ // D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
+ await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
+ await workspace.evaluate(() => { location.hash = '/overview'; });
+ const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
+ await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
+ expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
+ await workspace.evaluate(() => { location.hash = '/images'; });
await workspace.reload();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js
index bde3ba0..5aad215 100644
--- a/tests/js/api-client.test.js
+++ b/tests/js/api-client.test.js
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
await Promise.all([p1, p2, p3]);
});
+test("del sends an optional JSON body", async () => {
+ const fetch = fakeFetch();
+ const api = createApiClient(fetch);
+ const controller = new AbortController();
+ const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
+ assert.equal(fetch.calls[0].init.method, "DELETE");
+ assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
+ assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
+ assert.equal(fetch.calls[0].init.signal, controller.signal);
+ fetch.calls[0].resolve(jsonResponse(200, { success: true }));
+ await pending;
+});
+
test("network failures propagate as ordinary errors", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
diff --git a/tests/js/workspace-layout.test.js b/tests/js/workspace-image-layout.test.js
similarity index 54%
rename from tests/js/workspace-layout.test.js
rename to tests/js/workspace-image-layout.test.js
index 59039dc..0b3d3dc 100644
--- a/tests/js/workspace-layout.test.js
+++ b/tests/js/workspace-image-layout.test.js
@@ -19,7 +19,7 @@ test('layouts preserve data order, remain within width and never overlap', async
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
const {layoutImages}=await source;
- assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
+ assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0});
for(const mode of ['grid','list','masonry']) {
const result=layoutImages([{}, {width:0,height:null}],1,mode);
assert.ok(Number.isFinite(result.height));
@@ -74,64 +74,78 @@ test('binary-searched windows handle boundaries, empty collections and focus wit
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
const {layoutImages,visibleImages}=await source;
- const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
- assert.equal(geometry.maxHeight,7554.5);
+ const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1});
+ assert.equal(geometry.maxHeight,9998/1.33+56);
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
});
+// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only.
+function computedGrid(columns, gap) {
+ globalThis.getComputedStyle = node => {
+ assert.equal(node, grid);
+ return {gridTemplateColumns:columns, columnGap:gap};
+ };
+ const grid = {};
+ return grid;
+}
-test('approved workspace densities share viewport metrics and reject unknown values', async () => {
- const {workspaceImageMetrics, layoutImages} = await source;
- assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported');
- for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) {
- assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected);
- for (const density of ['comfortable','large','compact']) {
- const metrics = workspaceImageMetrics(width,density);
- assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97});
- assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0);
- }
- }
- assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError);
- assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable'));
-});
-
-test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => {
- const {workspaceImageMetrics,layoutImages} = await source;
- assert.equal(typeof workspaceImageMetrics,'function');
- const metrics = workspaceImageMetrics(1600);
- const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics);
- assert.equal(positions[1].x-positions[0].width,13);
- assert.equal(positions[3].x,0);
- assert.equal(positions[3].y-positions[0].height,13);
- assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75);
- assert.equal(layoutImages([{}],990,'list',240,metrics).height,97);
- const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100});
- assert.equal(custom.positions[0].width,485);
- assert.equal(custom.height,(485-2)*.75+62);
- assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100);
+test('layout uses columns and gap from computed style', async () => {
+ const {gridMetrics, layoutImages} = await source;
+ const original = globalThis.getComputedStyle;
+ try {
+ for (const [columns, gap, expected] of [
+ ['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}],
+ ['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}],
+ ['990px', '13px', {columns:1, gap:13}],
+ ['none', 'normal', {columns:1, gap:0}],
+ ['', '', {columns:1, gap:0}],
+ ]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`);
+ } finally { globalThis.getComputedStyle = original; }
+ const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13});
+ assert.equal(columns, 3);
+ assert.equal(positions[1].x, positions[0].width + 13);
+ assert.equal(positions[3].x, 0);
+ assert.equal(positions[3].y, positions[0].height + 13);
+ // The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`.
+ assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2);
+ const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60});
+ assert.equal(wide.positions[0].width, (990 - 80) / 5);
+ assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62);
+ assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0);
+ assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97);
+ assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100);
});
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
const {layoutImages}=await source;
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
- const {positions}=layoutImages(images,990,'masonry',240,{columns:3});
+ const {positions}=layoutImages(images,990,'masonry',{columns:3});
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
+ assert.equal(positions[2].height,(positions[2].width-2)*.75+56);
for (const position of positions) assert.ok(Number.isFinite(position.height));
});
-test('gallery width limits density columns without changing ordinary workspace geometry', async () => {
- const {workspaceImageMetrics, layoutImages} = await source;
- for (const [viewport, width, density, columns] of [
- [1600,990,'comfortable',3], [1600,990,'compact',4],
- [951,428,'compact',3], [390,362,'comfortable',2],
- [1600,190,'comfortable',1], [1600,190,'compact',1],
- [390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1],
+test('masonry falls back to the concept ratio when a finite size underflows the proportion', async () => {
+ const {layoutImages}=await source;
+ // Both sides are finite and positive, but their ratio underflows to 0, so the proportional height is infinite.
+ const {positions,height}=layoutImages([{width:Number.MIN_VALUE,height:1e300}],300,'masonry',{columns:1});
+ assert.equal(positions[0].height,(300-2)/1.33+56);
+ assert.ok(Number.isFinite(height));
+});
+
+test('a narrow gallery caps the concept columns at the smallest readable card', async () => {
+ const {layoutImages, MINIMUM_CARD_WIDTH} = await source;
+ // One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at.
+ assert.equal(MINIMUM_CARD_WIDTH, 120);
+ // At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31).
+ for (const [width, asked, columns] of [
+ [990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1],
]) {
- const metrics = workspaceImageMetrics(viewport,density,width);
- assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`);
- const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics);
- assert.equal(positions[columns].x,0);
- assert.ok(positions[columns].y > 0);
+ const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13});
+ assert.equal(geometry.columns, columns, `${width}/${asked}`);
+ assert.equal(geometry.positions[columns].x, 0);
+ assert.ok(geometry.positions[columns].y > 0);
}
+ assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
});
diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js
index e7bcbaf..efe3309 100644
--- a/tests/js/workspace-images-state.test.js
+++ b/tests/js/workspace-images-state.test.js
@@ -183,3 +183,133 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
+
+test('stream sends category, tags and the rating sort and validates them', async () => {
+ const {createImageStream}=await source;const calls=[];
+ const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
+ await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
+ assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
+ await stream.reset({category:'',tags:[]});
+ assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
+ for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
+});
+
+test('positive count comes from the first page, survives plain cursor pages and hides when unknown', async () => {
+ const {createImageStream}=await source;
+ const replies=[{...page([image('a')],3,'one'),positive_count:2},page([image('b')],3,'two'),{...page([image('c')],3),positive_count:null}];
+ const stream=createImageStream({api:{get:async()=>replies.shift()}});
+ assert.equal(stream.state.positiveCount,null);
+ await stream.reset();assert.equal(stream.state.positiveCount,2);
+ await stream.loadMore();assert.equal(stream.state.positiveCount,2);
+ await stream.loadMore();assert.equal(stream.state.positiveCount,2);
+ const failed=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count:null})}});
+ await failed.reset();assert.equal(failed.state.positiveCount,null);
+ const absent=createImageStream({api:{get:async()=>page([image('a')])}});
+ await absent.reset();assert.equal(absent.state.positiveCount,null);
+ for(const positive_count of [-1,'2',1.5]) {
+ const invalid=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count})}});
+ await invalid.reset();assert.match(invalid.state.error.message,/page/i);
+ }
+ const restored=createImageStream({api:{get(){assert.fail('unexpected request');}},restore:{filters:{},state:{...stream.state}}});
+ assert.equal(restored.state.positiveCount,2);
+ assert.throws(()=>createImageStream({api:{get(){}},restore:{filters:{},state:{...stream.state,positiveCount:-3}}}),TypeError);
+});
+
+test('the count line reads N of M images and K positive prompts only when known', async () => {
+ const {countLine}=await source;
+ assert.equal(countLine({loaded:40,total:102,positiveCount:2}),'40 of 102 images · 2 positive prompts');
+ assert.equal(countLine({loaded:1,total:1,positiveCount:1}),'1 of 1 image · 1 positive prompt');
+ assert.equal(countLine({loaded:2,total:2,positiveCount:null}),'2 of 2 images');
+ assert.equal(countLine({loaded:1200,total:3400,positiveCount:0}),'1,200 of 3,400 images · 0 positive prompts');
+});
+
+test('the caption date names the newest result day under Newest first only', async () => {
+ const {captionDate}=await source;
+ const now=new Date(2026,9,17,15,0);
+ const at=(...parts)=>({modified_time:new Date(...parts).getTime()/1000});
+ assert.equal(captionDate([at(2026,9,17,9),at(2026,9,10)],'newest',now),'Today · October 17');
+ assert.equal(captionDate([at(2026,9,16,23),at(2026,9,10)],'newest',now),'Yesterday · October 16');
+ // D17 ruling: only under Newest first; Oldest first hides the date too.
+ assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
+ assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
+ assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
+ for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
+ assert.equal(captionDate([],'newest',now),'');
+ assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
+});
+
+test('the scope chip summarizes source and folder', async () => {
+ const {scopeLabel}=await source;
+ const roots=[{index:0,label:'output'},{index:1,label:'input'}];
+ assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders');
+ assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared');
+ assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All output folders');
+ assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk');
+ assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
+});
+
+test('the card title is the confirmed linked title, else the filename (D10)', async () => {
+ const {cardTitle}=await source;
+ const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}});
+ assert.equal(cardTitle(linked('linked','Alpine observatory')),'Alpine observatory');
+ for (const status of ['ambiguous','unlinked','unavailable','none']) assert.equal(cardTitle(linked(status,'Alpine observatory')),'alpine_00001.png');
+ assert.equal(cardTitle(linked('linked',null)),'alpine_00001.png');
+ assert.equal(cardTitle(linked('linked','')),'alpine_00001.png');
+ assert.equal(cardTitle({filename:'plain.png'}),'plain.png');
+ assert.equal(cardTitle({filename:'plain.png',linked:null}),'plain.png');
+});
+
+test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D11)', async () => {
+ const {cardSubtitle, mediaRatio}=await source;
+ assert.equal(cardSubtitle({filename:'a.png',width:1536,height:1152,seed:'42001'}),'1536 × 1152 · PNG · Seed 42001');
+ // The seed is the listing's decimal string: never a Number, so 2^53 + 1 survives.
+ assert.equal(cardSubtitle({filename:'a.png',width:8,height:8,seed:'9007199254740993'}),'8 × 8 · PNG · Seed 9007199254740993');
+ assert.equal(cardSubtitle({filename:'clip.webm',width:null,height:null,seed:null}),'WEBM');
+ assert.equal(cardSubtitle({filename:'clip.mp4',media_type:'video',width:640,height:360}),'640 × 360 · MP4');
+ assert.equal(cardSubtitle({filename:'noext',width:0,height:10,seed:''}),'');
+ assert.equal(cardSubtitle({filename:'.hidden',width:10,height:Infinity,seed:42}),'');
+ assert.equal(cardSubtitle({filename:'trailing.',seed:'7'}),'Seed 7');
+ assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152');
+ for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3');
+});
+
+test('updating one item replaces it immutably and later pages append after it', async () => {
+ const { createImageStream, imageKey } = await source;
+ const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)];
+ const states = [];
+ const hints = [];
+ const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:(s, hint) => { states.push(s); hints.push(hint); }, batchSize:2});
+ await stream.reset({});
+ const before = stream.state, notified = states.length;
+ // Page loads carry no hint; a patch says so, so the page can repaint one card instead of laying out the collection.
+ assert.ok(hints.every(hint => hint === undefined));
+ assert.equal(stream.updateItem('0:a', {favorite:true}), true);
+ const after = stream.state;
+ assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after);
+ assert.deepEqual(hints.at(-1), {patch:'0:a'});
+ assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0]));
+ assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false);
+ assert.equal(after.items[1], before.items[1]);
+ assert.equal(after.loaded, before.loaded); assert.equal(after.cursor, before.cursor); assert.equal(after.total, before.total);
+ // Identity fields cannot be patched, and an unknown key changes nothing.
+ assert.throws(() => stream.updateItem('0:a', {path:'z'}), TypeError);
+ assert.throws(() => stream.updateItem('0:a', {root_index:1}), TypeError);
+ assert.equal(stream.updateItem('0:missing', {favorite:true}), false);
+ assert.equal(stream.state, after); assert.equal(states.length, notified + 1);
+ await stream.loadMore();
+ assert.deepEqual(stream.state.items.map(imageKey), ['0:a', '0:b', '0:c']);
+ assert.equal(stream.state.items[0].favorite, true);
+ stream.dispose();
+ assert.equal(stream.updateItem('0:b', {favorite:false}), false);
+ assert.equal(stream.state.items[1].favorite, true);
+});
+
+test('withItem patches a saved browsing state without touching the other items', async () => {
+ const { withItem } = await source;
+ const items = Object.freeze([Object.freeze({...image('a'), favorite:false}), Object.freeze({...image('b'), favorite:false})]);
+ const state = Object.freeze({items, total:2, cursor:null, hasMore:false, loading:false, error:null, loaded:2, positiveCount:null});
+ const next = withItem(state, '0:b', {favorite:true});
+ assert.ok(Object.isFrozen(next)); assert.equal(next.items[1].favorite, true); assert.equal(next.items[0], items[0]);
+ assert.equal(state.items[1].favorite, false);
+ assert.equal(withItem(state, '1:b', {favorite:true}), state);
+});
diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js
index d4e9994..c8bc44b 100644
--- a/tests/js/workspace-media-viewer.test.js
+++ b/tests/js/workspace-media-viewer.test.js
@@ -89,7 +89,7 @@ test('occurrence Viewer retains exact caller selection and synchronously closes
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
- f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
+ f.walk(f.container,n=>n.attributes['aria-label']==='Close image viewer')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
@@ -106,7 +106,7 @@ test('slideshow stops for detail reading nested send close and parent disposal w
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
- find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
+ find('Play slideshow').fire('click');find('Close image viewer').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
} finally {view.dispose();parentScope.dispose();f.restore();}
});
@@ -152,7 +152,7 @@ test('real inspection navigation preserves slideshow across two advances and def
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
- find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
+ find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close image viewer').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
@@ -173,7 +173,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
- f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
+ f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
// Viewer.js lists only images that still have a src; the pending view needs it.
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
@@ -185,7 +185,63 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
view.open({items,selectedKey:'c'.repeat(64)});
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
- f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
+ f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
+
+test('viewer renders the concept shell and toolbar order', async () => {
+ const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
+ const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
+ const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
+ try {
+ view.open({items,selectedKey:'c'.repeat(64)});
+ const [dialog] = f.container.children;
+ assert.equal(dialog.tagName,'dialog');assert.equal(dialog.id,'viewer-dialog');assert.equal(dialog.attributes['aria-labelledby'],'viewer-title');
+ assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
+ const [header,layout] = dialog.children;
+ assert.equal(header.className,'dialog-header');
+ const [heading,close] = header.children, [title,subtitle] = heading.children;
+ assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']);
+ assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']);
+ assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']);
+ assert.equal(layout.className,'viewer-layout');
+ const [left,details] = layout.children;
+ assert.equal(left.className,'viewer-left');
+ assert.deepEqual([details.tagName,details.id,details.attributes['aria-label']],['aside','viewer-details','Image metadata']);
+ const [stage,toolbar] = left.children;
+ assert.deepEqual([stage.tagName,stage.id,toolbar.tagName,toolbar.id,toolbar.className],['div','viewer-stage','div','viewer-toolbar','viewer-toolbar']);
+ // #249 D34 #8: the toolbar keeps production's named group (the concept's has no role; adding one is invisible).
+ assert.deepEqual([toolbar.attributes.role,toolbar.attributes['aria-label']],['group','Media controls']);
+ // With more than one loaded item, Previous/Next are enabled and not described by the position line.
+ for (const label of ['Previous media','Next media']) {
+ const [node] = f.walk(f.container,n => n.attributes['aria-label'] === label);
+ assert.deepEqual([node.attributes['aria-disabled'],node.attributes['aria-describedby']],['false',undefined],label);
+ }
+ // The concept's toolbar order, then production's position status and its two reasons.
+ assert.deepEqual(toolbar.children.map(node => node.textContent.split(':')[0]),
+ ['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →','1 of 2 loaded','Image transforms are unavailable because Viewer.js could not load.','']);
+ assert.deepEqual(toolbar.children.slice(9).map(node => [node.tagName,node.attributes.role ?? null]),[['p','status'],['p',null],['p',null]]);
+ f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
+ assert.equal(toolbar.children[9].textContent,'2 of 2 loaded: a.png');
+ } finally { view.dispose(); parentScope.dispose(); f.restore(); }
+});
+
+test('viewer titles a linked card " · " before any inspection arrives', async () => {
+ const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
+ const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
+ const linked = {...occurrenceMedia('1',1,'c'),linked:{status:'linked',title:'Quiet geometry'}};
+ const unlinked = {...occurrenceMedia('2',0,'d'),linked:{status:'unlinked',title:'Ignored'}};
+ const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
+ try {
+ view.open({items:[linked,unlinked],selectedKey:'c'.repeat(64)});
+ const title = f.walk(f.container,n=>n.id==='viewer-title')[0];
+ assert.equal(title.textContent,`Quiet geometry · ${linked.filename}`);
+ } finally { view.dispose(); parentScope.dispose(); f.restore(); }
+ const g = mountedDom(), scope = createScope();
+ const other = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
+ try {
+ other.open({items:[linked,unlinked],selectedKey:'d'.repeat(64)});
+ assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
+ } finally { other.dispose(); scope.dispose(); g.restore(); }
+});
diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js
new file mode 100644
index 0000000..db2818e
--- /dev/null
+++ b/tests/js/workspace-toolbar.test.js
@@ -0,0 +1,613 @@
+// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
+// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
+const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
+
+function optionsCollection(list) {
+ const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
+ list.forEach((option, index) => { collection[index] = option; });
+ return Object.freeze(collection);
+}
+
+/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
+function fakeDOM() {
+ const previous = { document: global.document };
+ let active = null;
+ class Element extends EventTarget {
+ constructor(tag) {
+ super();
+ this.tagName = tag.toUpperCase();
+ this.children = [];
+ this.attributes = {};
+ this.parentNode = null;
+ this.className = '';
+ this.id = '';
+ this.value = '';
+ this.disabled = false;
+ this.open = false;
+ this.selected = false;
+ this.selectionStart = 0;
+ this.selectionEnd = 0;
+ this._text = '';
+ }
+ get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
+ set textContent(text) { this.children = []; this._text = String(text); }
+ get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
+ // Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
+ get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
+ append(...nodes) {
+ for (const node of nodes) {
+ node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
+ node.parentNode = this;
+ this.children.push(node);
+ }
+ }
+ replaceChildren(...nodes) {
+ for (const child of this.children) child.parentNode = null;
+ this.children = [];
+ this._text = '';
+ this.append(...nodes);
+ }
+ remove() {
+ if (!this.parentNode) return;
+ this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
+ this.parentNode = null;
+ }
+ setAttribute(name, value) { this.attributes[name] = String(value); }
+ getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
+ removeAttribute(name) { delete this.attributes[name]; }
+ focus() { active = this; }
+ select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
+ showModal() { this.open = true; }
+ close() {
+ if (!this.open) return;
+ this.open = false;
+ // Browsers queue the close event as a later task.
+ setTimeout(() => this.dispatchEvent(new Event('close')), 0);
+ }
+ click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
+ }
+ const body = new Element('body');
+ global.document = {
+ body,
+ get activeElement() { return active ?? body; },
+ createElement: tag => new Element(tag),
+ createElementNS: (namespace, tag) => new Element(tag),
+ };
+ const all = (root, predicate, found = []) => {
+ if (predicate(root)) found.push(root);
+ for (const child of root.children) all(child, predicate, found);
+ return found;
+ };
+ return {
+ body, Element,
+ all: predicate => all(body, predicate),
+ find: (root, predicate) => all(root, predicate)[0],
+ restore() { Object.assign(global, previous); },
+ };
+}
+
+const byId = (dom, id) => dom.all(node => node.id === id)[0];
+const tags = node => node.children.map(child => child.tagName.toLowerCase());
+const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
+const modal = dom => byId(dom, 'modal');
+const tick = () => new Promise(resolve => setTimeout(resolve, 5));
+const coreModule = import('../../web/js/shared/toolkit/core.js');
+const button = (root, text) => {
+ const found = [];
+ const walk = node => {
+ if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
+ node.children.forEach(walk);
+ };
+ walk(root);
+ return found[0];
+};
+
+test('toolbar emits concept markup and order', async () => {
+ const { mountToolbar } = await toolbarModule;
+ const { createScope } = await import('../../web/js/shared/toolkit/core.js');
+ const dom = fakeDOM();
+ const scope = createScope();
+ try {
+ const container = new dom.Element('div');
+ dom.body.append(container);
+ const changes = [];
+ const filters = [];
+ const toolbar = mountToolbar(container, {
+ scope, categories: ['Portraits', 'Landscapes'],
+ sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
+ value: { q: 'castle', category: 'Landscapes', sort: 'name' },
+ onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
+ });
+ const root = container.children[0];
+ assert.equal(toolbar.element, root);
+ assert.equal(root.tagName, 'SEARCH');
+ assert.equal(root.className, 'toolbar');
+ assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
+
+ const [label, category, sort, filtersButton] = root.children;
+ assert.equal(label.className, 'search-field');
+ assert.equal(label.htmlFor, 'search');
+ assert.deepEqual(tags(label), ['svg', 'input']);
+ assert.equal(label.children[0].getAttribute('class'), 'icon');
+ assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
+ const input = label.children[1];
+ assert.equal(input.id, 'search');
+ assert.equal(input.type, 'search');
+ assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
+ // The listing API rejects search text over 500 characters, as the legacy search capped it.
+ assert.equal(input.maxLength, 500);
+ assert.equal(input.getAttribute('aria-label'), 'Search workspace');
+ assert.equal(input.value, 'castle');
+
+ assert.equal(category.id, 'category-filter');
+ assert.equal(category.getAttribute('aria-label'), 'Filter by category');
+ assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
+ assert.equal(category.value, 'Landscapes');
+ assert.equal(sort.id, 'sort');
+ assert.equal(sort.getAttribute('aria-label'), 'Sort results');
+ assert.equal(sort.value, 'name');
+
+ // D16: no Save view until saved views are persisted.
+ assert.equal(button(root, 'Save view'), undefined);
+ assert.equal(filtersButton.textContent, 'Filters');
+ assert.equal(filtersButton.type, 'button');
+ filtersButton.click();
+ assert.deepEqual(filters, [filtersButton]);
+
+ category.value = '';
+ category.dispatchEvent(new Event('change'));
+ sort.value = 'rating';
+ sort.dispatchEvent(new Event('change'));
+ assert.deepEqual(changes, [
+ { q: 'castle', category: '', sort: 'name' },
+ { q: 'castle', category: '', sort: 'rating' },
+ ]);
+
+ // Categories refresh without dropping a still-listed selection.
+ toolbar.setCategories(['Archive']);
+ assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
+ toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
+ assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
+ assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
+ // A still-listed selection survives a category refresh.
+ toolbar.setCategories(['Portraits', 'Archive']);
+ assert.equal(category.value, 'Archive');
+ assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
+ // A selection that is no longer listed drops to All categories.
+ toolbar.setCategories(['Portraits']);
+ assert.equal(category.value, '');
+ // An unknown sort falls back to the first option instead of an empty select.
+ toolbar.setValue({ sort: 'bogus' });
+ assert.equal(sort.value, 'newest');
+ assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
+ onChange() {}, onFilters() {} }), TypeError);
+ } finally {
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('toolbar search uses a debounced onChange that stops with its scope', async () => {
+ const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
+ const { createScope } = await import('../../web/js/shared/toolkit/core.js');
+ const dom = fakeDOM();
+ const scope = createScope();
+ try {
+ const container = new dom.Element('div');
+ const changes = [];
+ mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
+ value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
+ const input = container.children[0].children[0].children[1];
+ assert.equal(input.id, 'search');
+ for (const text of ['c', 'ca', 'cat']) {
+ input.value = text;
+ input.dispatchEvent(new Event('input'));
+ }
+ assert.deepEqual(changes, []);
+ await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
+ assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
+ input.value = 'dog';
+ input.dispatchEvent(new Event('input'));
+ scope.dispose();
+ await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
+ assert.equal(changes.length, 1);
+ assert.equal(container.children.length, 0);
+ } finally {
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('sort lists concept options first', async () => {
+ const { mountToolbar, orderSorts } = await toolbarModule;
+ const { createScope } = await import('../../web/js/shared/toolkit/core.js');
+ // D14: the concept's three, in the concept's order, then the page's appended options in their own order.
+ const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
+ ['rating', 'Highest rated'], ['name', 'Name A–Z']];
+ assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
+ ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
+ // A page without a concept option keeps the ones it has, concept first.
+ assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
+ [['newest', 'Recently added'], ['oldest', 'Oldest first']]);
+ // Matching is by the concept's exact labels (en dash): a variant label is an appended option.
+ assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
+ [['newest', 'Recently added'], ['name', 'Name A-Z']]);
+ assert.throws(() => orderSorts([]), TypeError);
+ assert.throws(() => orderSorts([['newest']]), TypeError);
+
+ const dom = fakeDOM();
+ const scope = createScope();
+ try {
+ const container = new dom.Element('div');
+ mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
+ onChange() {}, onFilters() {} });
+ const sort = container.children[0].children[2];
+ assert.deepEqual(optionPairs(sort).map(([, label]) => label),
+ ['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
+ assert.equal(sort.value, 'oldest');
+ } finally {
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+const REFINE_FIELDS = [
+ { key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
+ { key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
+ { key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
+ { key: 'tags' },
+];
+
+test('refine dialog round-trips values and clear resets them', async () => {
+ const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const trigger = new dom.Element('button');
+ dom.body.append(trigger);
+ const applied = [];
+ const cleared = [];
+ const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
+ openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
+ onApply: value => applied.push(value), onClear: value => cleared.push(value) });
+ const dialog = modal(dom);
+ assert.equal(dialog.open, true);
+ assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
+ const body = byId(dom, 'modal-body');
+ const fields = body.children.filter(child => child.className === 'field');
+ assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
+ assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
+ assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
+ const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
+ assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
+ assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
+ assert.equal(tagsInput.placeholder, 'e.g. cinematic');
+ // D14: no mock-status note, because every field is a real filter.
+ assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
+ assert.equal(body.children.some(child => child.tagName === 'P'), false);
+ const actions = body.children[4];
+ assert.equal(actions.className, 'dialog-actions');
+ assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
+ [['Clear filters', ''], ['Apply filters', 'ca-primary']]);
+
+ // Apply returns the values as edited and leaves the caller's object alone.
+ media.value = 'image';
+ tagsInput.value = ' moody ';
+ button(body, 'Apply filters').click();
+ assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
+ assert.equal(values.media, 'video');
+ assert.equal(dialog.open, false);
+ assert.equal(trigger.isConnected && document.activeElement, trigger);
+
+ // Reopening with the applied values shows them again (a round trip).
+ openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
+ button(byId(dom, 'modal-body'), 'Apply filters').click();
+ assert.deepEqual(applied[1], applied[0]);
+
+ // Clear resets every field to its first option (Tags to empty) and reports that.
+ openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
+ onClear: value => cleared.push(value) });
+ const reopened = byId(dom, 'modal-body');
+ button(reopened, 'Clear filters').click();
+ // The concept's Clear also clears the toolbar category; the page applies `category`.
+ assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
+ assert.equal(applied.length, 2);
+ assert.equal(modal(dom).open, false);
+
+ // A page offers only its own fields; the modal is the one shared shell.
+ openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
+ const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
+ assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
+ button(byId(dom, 'modal-body'), 'Apply filters').click();
+ assert.deepEqual(applied[2], { tags: '' });
+ assert.equal(dom.all(node => node.id === 'modal').length, 1);
+ assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('the Folder list follows the chosen Source', async () => {
+ const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
+ const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
+ const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
+ const applied = [];
+ openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
+ onApply: value => applied.push(value), onClear() {} });
+ const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
+ .map(field => field.children[1]);
+ // Opened on a chosen Source: only its folders.
+ assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
+ assert.equal(folder.value, 'temp-only');
+ // Another Source drops a folder it does not have.
+ source.value = '0';
+ source.dispatchEvent(new Event('change'));
+ assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
+ assert.equal(folder.value, '');
+ folder.value = 'portraits';
+ // A folder the next Source also has stays chosen.
+ source.value = 'all';
+ source.dispatchEvent(new Event('change'));
+ assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
+ assert.equal(folder.value, 'portraits');
+ button(byId(dom, 'modal-body'), 'Apply filters').click();
+ assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('a running rescan marks Rescan busy and ignores a second press', async () => {
+ const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const calls = [];
+ const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
+ onRescan: () => calls.push('rescan') });
+ const body = byId(dom, 'modal-body');
+ const busy = button(body, 'Rescanning…');
+ assert.ok(busy, 'the busy Rescan says it is running');
+ assert.equal(busy.getAttribute('aria-disabled'), 'true');
+ busy.click();
+ assert.deepEqual(calls, []);
+ assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
+ handle.setRescanBusy(false);
+ assert.equal(busy.textContent, 'Rescan');
+ assert.equal(busy.getAttribute('aria-disabled'), null);
+ busy.click();
+ assert.deepEqual(calls, ['rescan']);
+ assert.equal(modal(dom).open, false);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('import dialog lists only real operations', async () => {
+ const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const calls = [];
+ openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
+ assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
+ const body = byId(dom, 'modal-body');
+ assert.deepEqual(tags(body), ['p', 'div']);
+ assert.equal(body.children[0].className, 'small');
+ const list = body.children[1];
+ assert.equal(list.className, 'operation-list');
+ const rows = list.children.map(row => {
+ assert.equal(row.className, 'operation');
+ const [text, actions] = row.children;
+ assert.deepEqual(tags(text), ['h3', 'p']);
+ assert.equal(actions.className, 'row-actions');
+ return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
+ });
+ // D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
+ assert.deepEqual(rows, [
+ ['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
+ ['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
+ ]);
+ assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
+
+ button(body, 'Rescan').click();
+ assert.deepEqual(calls, ['rescan']);
+ assert.equal(modal(dom).open, false);
+ openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
+ button(byId(dom, 'modal-body'), 'Open inspector').click();
+ assert.deepEqual(calls, ['rescan', 'inspect']);
+ assert.equal(modal(dom).open, false);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('copy fallback selects the text', async () => {
+ const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const text = 'a castle, "moonlight" & fog';
+ openCopyFallback(text, { scope });
+ assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
+ const body = byId(dom, 'modal-body');
+ assert.deepEqual(tags(body), ['div', 'p']);
+ const [field, note] = body.children;
+ assert.equal(field.className, 'field');
+ const [label, area] = field.children;
+ assert.equal(label.textContent, 'Select and copy this text');
+ assert.equal(label.htmlFor, 'copy-text');
+ assert.equal(area.tagName, 'TEXTAREA');
+ assert.equal(area.id, 'copy-text');
+ assert.equal(area.readOnly, true);
+ assert.equal(area.value, text);
+ assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
+ assert.equal(document.activeElement, area);
+ assert.equal(note.className, 'small');
+ assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
+ assert.throws(() => openCopyFallback(42, { scope }), TypeError);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('dialogs restore focus to the trigger on Escape', async () => {
+ const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const opens = [
+ trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
+ trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
+ trigger => openCopyFallback('text', { trigger, scope }),
+ ];
+ for (const open of opens) {
+ const trigger = new dom.Element('button');
+ dom.body.append(trigger);
+ open(trigger);
+ const dialog = modal(dom);
+ assert.equal(dialog.open, true);
+ assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
+ const header = dialog.children[0];
+ assert.equal(header.className, 'dialog-header');
+ assert.deepEqual(tags(header), ['h2', 'button']);
+ const close = header.children[1];
+ assert.equal(close.id, 'modal-close');
+ assert.equal(close.textContent, '×');
+ assert.equal(close.getAttribute('aria-label'), 'Close dialog');
+ assert.notEqual(document.activeElement, trigger);
+ // Escape fires `cancel` on a modal dialog.
+ const escape = new Event('cancel', { cancelable: true });
+ dialog.dispatchEvent(escape);
+ assert.equal(dialog.open, false);
+ assert.equal(document.activeElement, trigger);
+ }
+
+ // × closes and restores focus too; a removed trigger leaves focus alone.
+ const trigger = new dom.Element('button');
+ dom.body.append(trigger);
+ openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
+ byId(dom, 'modal-close').click();
+ assert.equal(modal(dom).open, false);
+ assert.equal(document.activeElement, trigger);
+ openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
+ trigger.remove();
+ const close = byId(dom, 'modal-close');
+ close.click();
+ assert.notEqual(document.activeElement, trigger);
+
+ // Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
+ const first = new dom.Element('button');
+ dom.body.append(first);
+ openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
+ openCopyFallback('swap', { scope });
+ assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
+ modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
+ assert.equal(document.activeElement, first);
+ openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
+ closeWorkspaceDialog();
+ assert.equal(modal(dom), undefined);
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('an action may open the next dialog without the late close event closing it', async () => {
+ const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
+ const dom = fakeDOM();
+ const scope = (await coreModule).createScope();
+ try {
+ const first = new dom.Element('button');
+ const second = new dom.Element('button');
+ dom.body.append(first, second);
+ openImportDialog({ scope, trigger: first, onInspect() {},
+ onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
+ button(byId(dom, 'modal-body'), 'Rescan').click();
+ await tick();
+ assert.equal(modal(dom).open, true);
+ assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
+ assert.equal(document.activeElement, byId(dom, 'copy-text'));
+ // Its own Escape still restores focus to its own trigger.
+ modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
+ await tick();
+ assert.equal(document.activeElement, second);
+ // Replacing the shell ignores the old shell's late close event too.
+ openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
+ closeWorkspaceDialog();
+ openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
+ await tick();
+ assert.equal(modal(dom).open, true);
+ assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
+ } finally {
+ closeWorkspaceDialog();
+ scope.dispose();
+ dom.restore();
+ }
+});
+
+test('dialogs need the page scope and close with it', async () => {
+ const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
+ const { createScope } = await coreModule;
+ const dom = fakeDOM();
+ try {
+ assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
+ assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
+ assert.throws(() => openCopyFallback('text'), /page scope/);
+ const scope = createScope();
+ assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
+ assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
+ assert.equal(modal(dom), undefined);
+
+ // Disposing the page scope closes and removes the shell, however often the page opened it.
+ const trigger = new dom.Element('button');
+ dom.body.append(trigger);
+ openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
+ openCopyFallback('again', { scope });
+ scope.dispose();
+ assert.equal(modal(dom), undefined);
+ assert.equal(document.activeElement, trigger);
+
+ // An old page's scope never closes the next page's dialog.
+ const older = createScope();
+ const newer = createScope();
+ openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
+ openCopyFallback('next page', { scope: newer });
+ older.dispose();
+ assert.equal(modal(dom).open, true);
+ newer.dispose();
+ assert.equal(modal(dom), undefined);
+ } finally {
+ closeWorkspaceDialog();
+ dom.restore();
+ }
+});
+
+test('every route change closes the shared workspace dialog', async () => {
+ const { readFile } = require('node:fs/promises');
+ const path = require('node:path');
+ const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
+ assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
+ const render = source.slice(source.indexOf('function render(route) {'));
+ const body = render.slice(0, render.indexOf('viewScope = createScope();'));
+ assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
+});
diff --git a/tests/test_api_library.py b/tests/test_api_library.py
index 6cc6329..3c72a57 100644
--- a/tests/test_api_library.py
+++ b/tests/test_api_library.py
@@ -339,6 +339,11 @@ class TestLibraryAPI(ImageAPITestCase):
relative = "nested/猫 % #.png"
pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
+ with self.api.db.model.get_connection() as connection:
+ connection.execute(
+ "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
+ (positive, "Positive title"),
+ )
path = make_png(self.output_dir / relative)
make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png")
image = self.api.db.link_image_to_prompt(positive, str(path))
@@ -373,7 +378,8 @@ class TestLibraryAPI(ImageAPITestCase):
self.assertEqual(result["library"]["metadata_scope"], "pair")
self.assertEqual(result["library"]["pair_id"], pair)
self.assertEqual(result["library"]["positive_id"], positive)
- self.assertEqual(result["library"]["title"], "Pair title")
+ # D10/D20: the linked positive's title; "Pair title" names the variant.
+ self.assertEqual(result["library"]["title"], "Positive title")
async def test_epoch_scoped_all_library_cursors(self):
from database.execution_history_schema import rotate_history_epoch
diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py
index 83aa576..4d27826 100644
--- a/tests/test_api_workspace_images.py
+++ b/tests/test_api_workspace_images.py
@@ -247,6 +247,33 @@ class TestWorkspaceImages(ImageAPITestCase):
)
self.assertEqual(response.status, 400)
+ async def test_workspace_roots_list_their_own_folders(self):
+ # "Refine this view" offers only the chosen Source's folders (#249 m1).
+ second = self.comfy_dir / "second"
+ second.mkdir()
+ self.media("first/deep/a.png")
+ self.media("nested/b.png", root=second)
+ self.api._get_all_output_dirs = lambda: [self.output_dir, second]
+ deep = os.path.join("first", "deep")
+ response = await self.client.get(
+ "/prompt_manager/gallery/subfolders?mode=workspace&include_ancestors=true"
+ )
+ folders = await response.json()
+ self.assertEqual(folders["subfolders"], sorted(["first", deep, "nested"]))
+ self.assertEqual(
+ [(r["index"], r["subfolders"]) for r in folders["roots"]],
+ [(0, sorted(["first", deep])), (1, ["nested"])],
+ )
+ # A chosen root narrows the union; every root still lists its own.
+ response = await self.client.get(
+ "/prompt_manager/gallery/subfolders?mode=workspace&root=1"
+ )
+ folders = await response.json()
+ self.assertEqual(folders["subfolders"], ["nested"])
+ self.assertEqual(
+ [r["subfolders"] for r in folders["roots"]], [[deep], ["nested"]]
+ )
+
async def test_workspace_dimensions_are_header_only_and_video_is_unknown(self):
self.media("a.png")
self.media("b.mp4")
@@ -420,7 +447,8 @@ class TestWorkspaceImages(ImageAPITestCase):
)
def _link_fixtures(self):
- """Linked (titled, first-line, pair, untitled pair), ambiguous and none."""
+ """Linked (titled, first-line, pair, variant-titled pair, untitled pair),
+ ambiguous and none."""
db = self.api.db
long_line = "lantern " * 15
titled = self._save_prompt("Titled positive", category="Night", rating=5)
@@ -430,12 +458,20 @@ class TestWorkspaceImages(ImageAPITestCase):
(titled, "Stored library title"),
)
plain = self._save_prompt(long_line)
+ # A pair's own title names its negative variant; the card shows the
+ # linked positive's title (D10/D20), never the variant name.
pair = db.save_prompt_pair("Pair positive", "Noise", title="Pair title")
+ with db.model.get_connection() as connection:
+ connection.execute(
+ "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
+ (db.get_prompt_pair(pair)["positive_prompt_id"], "Pair positive title"),
+ )
+ variant = db.save_prompt_pair("Variant positive", "Grain", title="Variant name")
bare = db.save_prompt_pair("Bare pair positive\nmore", "Blur")
first, second = self._save_prompt("Owner one"), self._save_prompt("Owner two")
files = {
name: self.media(f"{name}.png")
- for name in ("titled", "pair", "ambiguous", "none")
+ for name in ("titled", "pair", "variant", "ambiguous", "none")
}
# First-line titles come from files whose own prompt evidence agrees.
files["plain"] = embedded_png(self.output_dir / "plain.png", long_line)
@@ -444,7 +480,7 @@ class TestWorkspaceImages(ImageAPITestCase):
)
db.link_image_to_prompt(titled, str(files["titled"]))
db.link_image_to_prompt(plain, str(files["plain"]))
- for name, pair_id in (("pair", pair), ("bare", bare)):
+ for name, pair_id in (("pair", pair), ("variant", variant), ("bare", bare)):
positive = db.get_prompt_pair(pair_id)["positive_prompt_id"]
image = db.link_image_to_prompt(positive, str(files[name]))
db.link_image_to_pair(pair_id, image, {"source": "fixture"})
@@ -456,7 +492,13 @@ class TestWorkspaceImages(ImageAPITestCase):
"pair": (
"linked",
db.get_prompt_pair(pair)["positive_prompt_id"],
- "Pair title",
+ "Pair positive title",
+ ),
+ # No stored title and no file evidence: the card shows the filename.
+ "variant": (
+ "linked",
+ db.get_prompt_pair(variant)["positive_prompt_id"],
+ None,
),
"bare": (
"linked",
@@ -568,6 +610,13 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(response.status, 200)
return (await response.json())["library"]
+ def test_link_summary_reads_no_pair_title(self):
+ # The pair's own title names its negative variant and never titles a
+ # card (D10), so the summary query does not read it (#249 m4).
+ from database.operations import _LINK_SUMMARY_SQL
+
+ self.assertNotIn("pp.title", _LINK_SUMMARY_SQL)
+
async def test_link_summary_uses_one_query_per_page(self):
prompt = self._save_prompt("Paged prompt")
for index in range(12):
@@ -1053,8 +1102,9 @@ class TestWorkspaceImages(ImageAPITestCase):
cases = (
# Six unambiguous positives; the ambiguous file and unlinked count none.
({}, 10, 6),
- # Text, title, tag, pair title (its positive) and the filename match.
- ({"q": "ZEBRA"}, 6, 4),
+ # Text, stored title, tag and the filename match (never a pair's
+ # own variant title, D12).
+ ({"q": "ZEBRA"}, 5, 3),
({"category": "Night"}, 4, 3),
({"tags": "a,b"}, 3, 2),
({"q": "plain_unlinked"}, 1, 0),
@@ -1185,18 +1235,24 @@ class TestWorkspaceImages(ImageAPITestCase):
files = self._filter_fixtures()
_, data = await self.get(q="ZEBRA", sort="name")
- # Text, stored title, tag, pair title and filename; never an ambiguous link.
+ # Text, stored title, tag and filename; never an ambiguous link.
self.assertEqual(
self._names(data),
[
"day_ab.png",
"night_ab.png",
- "paired.png",
"titled.png",
"zebra_unlinked.png",
],
)
- self.assertEqual(data["total"], 5)
+ self.assertEqual(data["total"], 4)
+ # D12: a pair's own title names its negative variant, so the variant
+ # name alone matches nothing; the linked positive's text still does.
+ _, data = await self.get(q="Zebra pair")
+ self.assertEqual(self._names(data), [])
+ self.assertEqual(data["total"], 0)
+ _, data = await self.get(q="pair positive")
+ self.assertEqual(self._names(data), ["paired.png"])
_, data = await self.get(q="0% w")
self.assertEqual(self._names(data), ["percent.png"])
_, data = await self.get(q="%")
@@ -1207,7 +1263,7 @@ class TestWorkspaceImages(ImageAPITestCase):
self.api.db.get_image_paths_matching("zebra", None, []),
{
normalize_image_path(str(files[name]))
- for name in ("night_ab", "day_ab", "titled", "paired")
+ for name in ("night_ab", "day_ab", "titled")
},
)
diff --git a/tests/test_database.py b/tests/test_database.py
index 36bad16..bcc8498 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -1541,6 +1541,40 @@ class TestImageLinkSummaries(DatabaseTestCase):
self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text"))
self.assertEqual(sources["blank"], ("Blank title text", "text"))
+ def test_pair_summary_title_is_the_linked_positives_never_the_variant(self):
+ # D10/D20: a pair's own title names its negative variant.
+ stored = self.db.save_prompt_pair("Stored positive", "Noise", title="Variant")
+ plain = self.db.save_prompt_pair("Plain positive\nmore", "Blur", title="Other")
+ positives = {
+ name: self.db.get_prompt_pair(pair_id)["positive_prompt_id"]
+ for name, pair_id in (("stored", stored), ("plain", plain))
+ }
+ with self.db.model.get_connection() as conn:
+ conn.execute(
+ "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
+ (positives["stored"], "Positive title"),
+ )
+ paths = {}
+ for name, pair_id in (("stored", stored), ("plain", plain)):
+ paths[name] = os.path.join("out", f"pair-{name}.png")
+ image = self.db.link_image_to_prompt(positives[name], paths[name])
+ self.db.link_image_to_pair(pair_id, image, {"source": "test"})
+ summaries = self.db.get_image_link_summaries(list(paths.values()))
+ titles = {
+ name: (
+ summaries[path]["positive_id"],
+ summaries[path]["title"],
+ summaries[path]["title_source"],
+ )
+ for name, path in paths.items()
+ }
+ self.assertEqual(
+ titles["stored"], (positives["stored"], "Positive title", "stored")
+ )
+ self.assertEqual(
+ titles["plain"], (positives["plain"], "Plain positive", "text")
+ )
+
def test_display_title_matches_library_title_cut(self):
from database.library import display_title
diff --git a/tests/vendor/concept-css.test.mjs b/tests/vendor/concept-css.test.mjs
index 0f6b27c..6e11e46 100644
--- a/tests/vendor/concept-css.test.mjs
+++ b/tests/vendor/concept-css.test.mjs
@@ -73,6 +73,22 @@ test('overlay rules on concept selectors carry a decision marker', () => {
assert.deepEqual(violations, []);
});
+// PRs C and D lint each page stylesheet they keep: a page rule on a concept selector is an override like the overlay's.
+test('kept page stylesheets mark their concept-selector overrides', () => {
+ for (const name of ['images.css', 'library.css', 'inspector.css']) {
+ const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
+ assert.deepEqual(violations, [], name);
+ }
+});
+
+// #249 C8: the Images page renders the concept DOM, so the replaced production rules stay retired.
+test('images.css keeps no retired Images page rules', () => {
+ const css = readFileSync(join(root, 'web/js/workspace/images.css'), 'utf8');
+ for (const retired of ['.image-facts', '.image-card', '.image-grid', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation']) {
+ assert.equal(css.includes(retired), false, `${retired} is retired`);
+ }
+});
+
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/'))
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index 747366b..4ca07c9 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -299,6 +299,18 @@
"functions": 98,
"statements": 98
},
+ "workspace/toast.mjs": {
+ "lines": 98,
+ "branches": 64,
+ "functions": 98,
+ "statements": 98
+ },
+ "workspace/toolbar.mjs": {
+ "lines": 94,
+ "branches": 93,
+ "functions": 98,
+ "statements": 94
+ },
"workspace/topbar.mjs": {
"lines": 98,
"branches": 98,
@@ -328,5 +340,11 @@
"branches": 91,
"functions": 93,
"statements": 98
+ },
+ "workspace/workspace-dialogs.mjs": {
+ "lines": 97,
+ "branches": 95,
+ "functions": 98,
+ "statements": 97
}
}
diff --git a/web/js/api-client.js b/web/js/api-client.js
index 8c07992..4fe4039 100644
--- a/web/js/api-client.js
+++ b/web/js/api-client.js
@@ -91,7 +91,8 @@
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
- del: (url, { signal } = {}) => request("DELETE", url, { signal }),
+ // A DELETE body is optional; the favorites store names its file in one.
+ del: (url, { signal, body } = {}) => request("DELETE", url, { body, signal }),
};
}
diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs
index 0fdfb42..a17bf1f 100644
--- a/web/js/workspace/image-layout.mjs
+++ b/web/js/workspace/image-layout.mjs
@@ -1,31 +1,38 @@
-export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) {
- if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density');
- const columns = viewportWidth <= 700 || density === 'large' ? 2 :
- density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) :
- viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3;
- const gap = 13, minimumCardWidth = 120;
- const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap)));
- return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97};
+// The concept card (the concept stylesheet's `.media-card`): a 1px border around `aspect-ratio: 1.33` media and the measured
+// `.media-info` row; a list row is the concept's 95px image plus that border.
+const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
+// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
+export const MINIMUM_CARD_WIDTH = 120;
+const AUTOMATIC_CARD_WIDTH = 240;
+
+/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
+export function gridMetrics(grid) {
+ const style = getComputedStyle(grid);
+ const tracks = String(style.gridTemplateColumns || '').trim();
+ const gap = Number.parseFloat(style.columnGap);
+ return {columns: tracks && tracks !== 'none' ? tracks.split(/\s+/).length : 1, gap: Number.isFinite(gap) ? gap : 0};
}
-export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) {
- const {gap = 13, footerHeight = 54, listHeight = 97} = metrics;
- const columns = mode === 'list' ? 1 : metrics.columns ?? Math.max(1, Math.floor((width + gap) / (minWidth + gap)));
+export function layoutImages(images, width, mode, metrics = {}) {
+ const {gap = 13, footerHeight = FOOTER_HEIGHT, listHeight = LIST_HEIGHT} = metrics;
+ const fitting = Math.max(1, Math.floor((width + gap) / (MINIMUM_CARD_WIDTH + gap)));
+ const asked = metrics.columns ?? Math.max(1, Math.floor((width + gap) / (AUTOMATIC_CARD_WIDTH + gap)));
+ const columns = mode === 'list' ? 1 : Math.min(asked, fitting);
const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns);
const bottoms = Array(columns).fill(0);
let maxHeight = 0;
const positions = images.map((image, index) => {
const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns;
const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
- const mediaWidth = Math.max(1, cardWidth - 2);
- const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth * .75;
- const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth * .75) + footerHeight + 2;
+ const mediaWidth = Math.max(1, cardWidth - BORDER);
+ const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth / MEDIA_RATIO;
+ const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth / MEDIA_RATIO) + footerHeight + BORDER;
maxHeight = Math.max(maxHeight, height);
const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column];
bottoms[column] = y + height + gap;
return { x: column * (cardWidth + gap), y, width: cardWidth, height };
});
- return { positions, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
+ return { positions, columns, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
}
export function visibleImages(positions, top, viewportHeight, focusedIndex = -1, maxHeight = 1024) {
diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs
index 25cfdfe..071ba63 100644
--- a/web/js/workspace/images-state.mjs
+++ b/web/js/workspace/images-state.mjs
@@ -1,15 +1,28 @@
+import { noun } from './plural.mjs';
+
/** Identity is the source and relative path; content hashes may be shared by files. */
export function imageKey(image) {
return `${image.root_index}:${image.path}`;
}
+/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */
+export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
+
+const isCount = value => Number.isSafeInteger(value) && value >= 0;
+
function queryFor(filters, batchSize) {
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
- for (const key of ['q', 'subfolder']) {
+ for (const key of ['q', 'subfolder', 'category']) {
const value = filters[key];
if (value !== undefined && typeof value !== 'string') throw new TypeError('Invalid image filter');
if (value) query.set(key, value);
}
+ const tags = filters.tags ?? [];
+ // The listing takes a comma list that must all match, so a tag can neither be empty nor hold a comma.
+ if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim() || tag.includes(','))) {
+ throw new TypeError('Invalid tags filter');
+ }
+ if (tags.length) query.set('tags', tags.map(tag => tag.trim()).join(','));
const root = filters.root;
if (root !== undefined && root !== 'all' && root !== '') {
if (!/^(0|[1-9]\d*)$/.test(String(root)) || !Number.isSafeInteger(Number(root))) {
@@ -21,7 +34,7 @@ function queryFor(filters, batchSize) {
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
if (media !== 'all') query.set('media', media);
const sort = filters.sort ?? 'newest';
- if (!['newest', 'oldest', 'name', 'name-desc'].includes(sort)) throw new TypeError('Invalid sort filter');
+ if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
return query.toString();
}
@@ -30,7 +43,8 @@ function validatePage(page, cursor) {
if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 ||
typeof page.has_more !== 'boolean' ||
!(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) ||
- page.has_more !== (page.next_cursor !== null)) {
+ page.has_more !== (page.next_cursor !== null) ||
+ !(page.positive_count === undefined || page.positive_count === null || isCount(page.positive_count))) {
throw new TypeError('Invalid image page');
}
if (page.has_more && page.next_cursor === cursor) throw new TypeError('Image cursor did not advance');
@@ -42,6 +56,18 @@ function validatePage(page, cursor) {
}
}
+/** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */
+export function withItem(state, key, patch) {
+ if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) {
+ throw new TypeError('An image patch cannot change its identity');
+ }
+ const index = state.items.findIndex(item => imageKey(item) === key);
+ if (index < 0) return state;
+ const items = [...state.items];
+ items[index] = Object.freeze({ ...items[index], ...patch });
+ return Object.freeze({ ...state, items: Object.freeze(items) });
+}
+
/** Keep metadata for backward scrolling; the view bounds rendered media independently. */
export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore, fetchPage }) {
if (fetchPage !== undefined && typeof fetchPage !== 'function') throw new TypeError('Invalid image page adapter');
@@ -50,8 +76,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) {
throw new TypeError('Invalid image batch size');
}
+ // positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract).
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
- hasMore: true, loading: false, error: null, loaded: 0 });
+ hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null });
let query = queryFor({}, batchSize);
let controller = null;
let disposed = false;
@@ -65,13 +92,13 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
const pristine = Array.isArray(saved.items) && saved.items.length === 0 && saved.total === 0 &&
saved.cursor === null && saved.hasMore === true;
validatePage({ images: saved.items, total: saved.total, next_cursor: saved.cursor,
- has_more: pristine ? false : saved.hasMore });
+ has_more: pristine ? false : saved.hasMore, positive_count: saved.positiveCount });
seen = new Set(saved.items.map(imageKey));
if (seen.size !== saved.items.length) throw new TypeError('Duplicate restored image identity');
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
- loading: false, error: null, loaded: items.length });
+ loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null });
}
function publish(update) {
@@ -101,9 +128,12 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
}
for (const key of batchKeys) seen.add(key);
const items = incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items;
+ // A plain cursor page leaves the count out and a failed count read is null: a later page keeps the
+ // first page's count, and a first page without one leaves it unknown.
+ const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount;
controller = null;
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
- loading: false, error: null, loaded: items.length });
+ loading: false, error: null, loaded: items.length, positiveCount });
return incoming.length;
} catch (error) {
if (!disposed && request === controller) {
@@ -122,7 +152,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
- loading: false, error: null, loaded: 0 });
+ loading: false, error: null, loaded: 0, positiveCount: null });
return load ? loadMore() : 0;
}
@@ -130,6 +160,15 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
get state() { return state; },
reset,
loadMore,
+ /** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
+ updateItem(key, patch) {
+ const next = withItem(state, key, patch);
+ if (disposed || next === state) return false;
+ state = next;
+ // The hint names the one patched item, so a page can repaint it without laying out the collection again.
+ onChange(state, {patch:key});
+ return true;
+ },
dispose() {
if (disposed) return;
disposed = true;
@@ -138,3 +177,56 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
},
};
}
+
+/** The concept's count line (app.js a446): "N of M images · K positive prompts", without the count while unknown. */
+export function countLine({ loaded, total, positiveCount }) {
+ const images = `${loaded.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} ${noun(total, 'image')}`;
+ return isCount(positiveCount) ? `${images} · ${positiveCount.toLocaleString('en-US')} ${noun(positiveCount, 'positive prompt')}` : images;
+}
+
+const DAY_MS = 86400000;
+const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+
+/**
+ * D17: the caption's right-hand date, " · " of the newest result under Newest first,
+ * with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts.
+ */
+export function captionDate(items, sort, now = new Date()) {
+ if (sort !== 'newest') return '';
+ const newest = items.reduce((latest, item) => Number.isFinite(item.modified_time) ? Math.max(latest, item.modified_time) : latest, -Infinity);
+ if (!Number.isFinite(newest)) return '';
+ const date = new Date(newest * 1000);
+ const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY_MS);
+ const day = days === 0 ? 'Today' : days === 1 ? 'Yesterday' : date.toLocaleDateString('en-US', { weekday: 'long' });
+ const options = { month: 'long', day: 'numeric', ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }) };
+ return `${day} · ${date.toLocaleDateString('en-US', options)}`;
+}
+
+/** D14: the filter row's scope chip, "All output folders" or " · ". */
+export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) {
+ if ((root === 'all' || root === '') && !subfolder) return 'All output folders';
+ const source = root === 'all' || root === '' ? 'All sources' :
+ roots.find(item => String(item.index) === String(root))?.label ?? `Source ${root}`;
+ return `${source} · ${subfolder || 'All output folders'}`;
+}
+
+/** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */
+export function cardTitle(image) {
+ const title = image.linked?.status === 'linked' ? image.linked.title : null;
+ return typeof title === 'string' && title ? title : image.filename;
+}
+
+const hasSize = image => [image.width, image.height].every(value => Number.isFinite(value) && value > 0);
+
+/** D11: "W × H · EXT · Seed N", omitting unknown parts. The seed is the listing's decimal string, shown as sent. */
+export function cardSubtitle(image) {
+ const dot = image.filename.lastIndexOf('.');
+ const extension = dot > 0 ? image.filename.slice(dot + 1) : '';
+ return [hasSize(image) ? `${image.width} × ${image.height}` : '', extension.toUpperCase(),
+ typeof image.seed === 'string' && image.seed ? `Seed ${image.seed}` : ''].filter(Boolean).join(' · ');
+}
+
+/** The concept card's `--media-ratio` (masonry art); unknown dimensions use the layout's 4:3 fallback. */
+export function mediaRatio(image) {
+ return hasSize(image) ? `${image.width}/${image.height}` : '4/3';
+}
diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css
index 3cff41b..24b9b68 100644
--- a/web/js/workspace/images.css
+++ b/web/js/workspace/images.css
@@ -1,101 +1,18 @@
-.image-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
-.image-filters .ca-field { display: flex; flex: 1 1 130px; min-inline-size: 0; flex-direction: column; align-items: stretch; gap: 5px; }
-.image-filters .ca-field:first-child { flex: 3 1 260px; }
-.image-filters .ca-field-label { font-size: 10px; color: var(--ca-muted); }
-.ca-tools .image-filters :where(input, select) { inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 11px; }
-.image-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-block: 14px 22px; }
-.ca-tools .image-view-controls button { min-block-size: 36px; font-size: 11px; }
-.image-view-controls > .workspace-link { gap: 7px; min-block-size: 36px; font-size: 11px; margin-inline-start: auto; }
-.image-view-controls .ca-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-inline-size: 0; }
-.image-view-controls .ca-field-label { font-size: 10px; color: var(--ca-muted); }
-.ca-tools .image-view-controls select { min-block-size: 36px; font-size: 11px; }
-.image-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin-block: 0 13px; }
-.image-toolbar h2 { font-size: 14px; font-weight: 600; margin: 0; }
-.image-toolbar p { margin: 0; font-size: 11px; }
-.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
-.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
-.image-layouts .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); }
+/* The Images page renders the concept DOM (#249 PR C), so its replaced rules are retired. What remains is the side
+ panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel and deletes with this file. */
.images-main { container: images-page / inline-size; }
-.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
-.image-collection { min-inline-size: 0; }
-.image-grid { position: relative; inline-size: 100%; }
-.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); }
-.image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; }
-.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); }
-.image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
-.image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; }
-.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; }
-.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
-.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
-.image-card-footer .icon { inline-size: 14px; block-size: 14px; }
-.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
-.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
-.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; }
.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; }
.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; }
-.image-facts { font-size: 10px; margin-block: 17px; }
-.image-facts > div { padding-block: 8px; border-block-end: 1px solid var(--ca-line); }
-.image-facts dt { color: var(--ca-muted); margin-block-end: 4px; }
-.image-facts dd { margin: 0; overflow-wrap: anywhere; }
.image-details .workspace-link { display: flex; font-size: 11px; }
-.image-continuation { text-align: center; padding-block: 24px; font-size: 12px; }
-.image-continuation [role="alert"] { color: var(--ca-error-text); }
-.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; }
-.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; }
-.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; }
-.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; }
-.image-preview-header > div { min-inline-size: 0; }
-.image-preview-header h2 { font-size: 18px; font-weight: 600; line-height: 1.5; margin: 0; overflow-wrap: anywhere; }
-.image-preview-position { font-size: 12px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; }
-.ca-tools .image-preview-header > button { align-self: start; min-inline-size: 32px; padding: 4px 10px; font-size: 20px; line-height: 1.5; }
-.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 20px; min-block-size: 0; min-inline-size: 0; }
-.image-preview-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-inline-size: 0; min-block-size: 0; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 6px; overflow: hidden; }
-.image-preview-stage { position: relative; min-inline-size: 0; min-block-size: 0; }
-.image-preview-media { position: absolute; inset: 0; }
-.image-preview-media > :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
-.image-preview-media > img[hidden] { display: none; }
-.image-preview-media > .viewer-container { position: absolute; inset: 0; inline-size: 100% !important; block-size: 100% !important; background: var(--ca-field); }
-.image-preview-toolbar { border-block-start: 1px solid var(--ca-line); padding: 10px; }
-.image-preview-controls { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; }
-.ca-tools .image-preview-controls :where(button, a) { display: inline-flex; align-items: center; justify-content: center; min-block-size: 32px; padding: 4px 10px; background: var(--ca-control); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 12px; line-height: 1.5; text-decoration: none; }
-.image-preview-controls [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
-.image-preview-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; }
-.image-preview-failure { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; }
-.image-preview-failure[hidden] { display: none; }
-.image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; }
-.image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; }
-@media (max-width: 1250px) {
- .image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; }
- .image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; }
-}
-@media (max-width: 950px) {
- .image-workspace { grid-template-columns: minmax(0, 1fr) 280px; }
- .image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; }
-}
@media (max-width: 700px) {
- .image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; padding: 17px; }
.image-details > :where(img, video) { block-size: 200px; }
- .image-filters { gap: 8px; }
- .image-filters .ca-field { flex-basis: 140px; }
- .image-filters .ca-field:first-child { flex-basis: 100%; }
- .image-view-controls { gap: 8px; }
- .image-view-controls > .workspace-link { margin-inline-start: 0; }
- .image-preview { block-size: 94dvh; overflow: auto; }
- .image-preview[open] { grid-template-rows: auto auto; }
- .image-preview-body { grid-template-columns: minmax(0, 1fr); }
- .image-preview-left { block-size: 48dvh; min-block-size: 300px; }
- .image-preview-details { overflow: visible; }
- .ca-tools .image-preview-controls :where(button, a) { font-size: 10px; }
}
@container images-page (inline-size < 600px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; }
}
-.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
.options-status { font-size: 12px; padding-inline: 4px; }
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
@@ -108,15 +25,16 @@
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; }
.prompt-source { margin-block: 6px 10px; }
+/* #249 D22 interim: production's prompt blocks keep their text, action row and 26px buttons until PR D renders the
+ concept prompt block and retires these three overrides. */
.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; }
.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
-.prompt-actions { display: flex; flex-wrap: wrap; gap: 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 { /* #249 D22 interim (PR D) */ display: flex; flex-wrap: wrap; gap: 4px; }
+.ca-tools .prompt-actions button { /* #249 D22 interim (PR D) */ 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 .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; }
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
@@ -137,7 +55,3 @@
@media (max-width: 780px) {
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
}
-@media (pointer: coarse) {
- .ca-tools .image-preview :is(button, a) { min-inline-size: 40px; min-block-size: 40px; }
- .ca-tools .image-preview .prompt-actions button { min-inline-size: 36px; min-block-size: 36px; }
-}
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 8ce578d..ba9af95 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -1,6 +1,6 @@
-import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
-import { createImageStream, imageKey } from './images-state.mjs';
-import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs';
+import { createScope, createButton } from '../shared/toolkit/core.js';
+import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
+import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { createPromptSender } from './prompt-sender.mjs';
@@ -9,6 +9,9 @@ import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
+import { mountToolbar } from './toolbar.mjs';
+import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
+import { showWorkspaceToast } from './toast.mjs';
function el(tag, text, className) {
const element = document.createElement(tag);
@@ -17,11 +20,68 @@ function el(tag, text, className) {
return element;
}
-function formatBytes(value) {
- return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
+/**
+ * Live regions re-announce replaced text even when it is the same, and a patched publish (a favorite) repeats the
+ * same status: write text and attributes only when they change.
+ */
+function setText(element, text) {
+ if (element.textContent !== text) element.textContent = text;
+}
+function setAttr(element, name, value) {
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
}
+function selectOf(id, options, value) {
+ const select = el('select');
+ select.id = id;
+ select.replaceChildren(...options.map(([optionValue, label]) => {
+ const node = el('option', label);
+ node.value = optionValue;
+ return node;
+ }));
+ select.value = value;
+ return select;
+}
+
+// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
+const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
+ ['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
+const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
+const RETRY_RESCAN = 'Rescan from Import to retry.';
+const RETRY_REFRESH = 'Refresh image collection to retry.';
+/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
+function loadFailure(sourcesRetry, categoriesFailed) {
+ if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
+ if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
+ return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
+}
+// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer
+// counts every listed file across the output roots it walked.
+const RESCAN_STARTED = 'Rescanning output folders…';
+const RESCAN_FAILED = 'Rescan could not finish. Try again.';
+function rescanFinished(result) {
+ const {total, roots} = result ?? {};
+ if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
+ return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
+}
+// D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's
+// "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
+const FAVORITE_URL = '/prompt_manager/images/favorite';
+const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
+const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
+// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
+const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
+
let lastVisit = null;
+// One favorite request per file at a time (TESTING.md): the requests outlive the Images page that sent them, so the keys
+// in flight and the page that receives each answer live here, beside the saved visit.
+const favoritePending = new Set();
+let favoriteTarget = null;
+/** Give a favorite state to the mounted Images page, or to the saved visit while none is mounted. */
+function deliverFavorite(key, favorite) {
+ if (favoriteTarget) favoriteTarget(key, favorite);
+ else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
+}
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
const scope = createScope();
@@ -38,40 +98,56 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(() => detailInspector?.dispose());
const visit = embedded ? null : lastVisit;
let density = visit?.density || 'comfortable';
- let metrics = workspaceImageMetrics(window.innerWidth, density);
+ let metrics = {...INITIAL_METRICS};
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
- let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false;
+ let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
+ // Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
+ let roots = [], folders = [];
const rendered = new Map();
const itemIndexes = new Map();
- const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
+ const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
+ filters.tags = [...filters.tags];
scope.cleanup(() => {
- if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null;
+ if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
- 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;
- search.type = 'search'; search.placeholder = 'Find an image by filename or folder…'; search.maxLength = 256;
- const source = createSelect([['all', 'All sources']], '');
- const folder = createSelect([['', 'All folders']], '');
- const media = createSelect([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']], '');
- const sort = createSelect([['newest', 'Newest first'], ['oldest', 'Oldest first'], ['name', 'Filename A–Z'], ['name-desc', 'Filename Z–A']], '');
- media.value = filters.media; sort.value = filters.sort;
- filtersBar.append(createField('Search filenames', search, 'image-search'), createField('Source', source, 'image-source'),
- createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort'));
+ // The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
+ const heading = embedded ? null : pageHeading(main);
+ let importButton = null, importDialog = null;
+ if (heading) {
+ heading.description.textContent = 'Your generations, their prompts, and the next idea.';
+ importButton = createButton('');
+ importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
+ heading.actions.prepend(importButton);
+ scope.cleanup(() => importButton.remove());
+ scope.listen(importButton, 'click', () => {
+ importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
+ onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan});
+ });
+ }
+ // The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
+ const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
+ sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
+ onChange:value => {
+ if (recovering) return;
+ const next = {q:value.q.trim(), category:value.category, sort:value.sort};
+ // A debounced search report can land after a select change already applied the same values; reloading
+ // then would refetch the first page and drop the reader's scroll position.
+ if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
+ Object.assign(filters, next);
+ reset();
+ }, onFilters:openFilters});
+ const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
- if (!embedded) main.append(filtersBar, optionsStatus);
- const controls = el('div', undefined, 'image-view-controls');
- const toolbar = el('div', undefined, 'image-toolbar');
- const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1;
- toolbar.append(collectionHeading);
- const count = el('p', 'Loading images…', 'muted');
- const layouts = el('div', undefined, 'image-layouts');
+ // The failed loads' retry hint (null once sources load) and whether the categories failed.
+ let sourcesRetry = null, categoriesFailed = false;
+ if (!embedded) main.append(optionsStatus);
+ // The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15).
+ const filterRow = el('div', undefined, 'filter-row');
+ const layouts = el('div', undefined, 'segmented');
layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout');
for (const mode of ['grid', 'list', 'masonry']) {
const button = createButton(mode[0].toUpperCase() + mode.slice(1));
@@ -84,50 +160,51 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
});
layouts.append(button);
}
- const refresh = createButton('Refresh');
- scope.listen(refresh, 'click', async () => {
- if (refresh.disabled) return;
- refresh.disabled = true;
- optionsRequest?.abort(); optionsRequest = null;
- optionsStatus.hidden = true;
- try {
- await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
- if (scope.signal.aborted) return;
- if (await loadOptions() && !scope.signal.aborted) reset();
- } catch {
- if (scope.signal.aborted) return;
- optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
- optionsStatus.hidden = false;
- } finally {
- refresh.disabled = recovering;
- }
- });
- const currentGallery = el('a', 'Current gallery', 'workspace-link');
- currentGallery.href = resolveUrl('/prompt_manager/gallery');
- const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], '');
- thumbnails.value = density;
+ const sizeLabel = el('label', 'Thumbnail size', 'small');
+ sizeLabel.htmlFor = 'image-size';
+ const thumbnails = selectOf('image-size', [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], density);
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
- controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density'));
+ filterRow.append(layouts, sizeLabel, thumbnails);
+ const scopeText = document.createTextNode('');
if (!embedded) {
- const group = el('a', 'Group by positive prompt', 'workspace-link');
- group.href = '#/library'; group.prepend(createWorkspaceIcon('branch'));
- controls.append(group, refresh);
+ const chip = el('span', undefined, 'chip');
+ chip.append(createWorkspaceIcon('folder'), scopeText);
+ const group = createButton('Group by positive prompt →', 'chip');
+ scope.listen(group, 'click', () => { window.location.hash = '#/library'; });
+ filterRow.append(chip, group);
}
- main.append(controls);
- toolbar.append(count);
- const body = el('div', undefined, 'image-workspace');
- const collection = el('section', undefined, 'image-collection');
+ main.append(filterRow);
+ const body = el('div', undefined, 'gallery-layout image-workspace');
+ const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
+ // The concept's caption replaces the old "All images" heading; the embedded gallery keeps its own heading.
+ const caption = el('div', undefined, 'section-caption');
+ const collectionHeading = embedded ? el('h2', 'Linked images') : null;
+ if (collectionHeading) { collectionHeading.tabIndex = -1; caption.append(collectionHeading); }
+ const count = el('span', 'Loading images…');
+ if (!embedded) count.id = 'result-count';
+ const captionDay = el('span');
+ captionDay.hidden = true;
+ caption.append(count);
+ if (!embedded) caption.append(captionDay);
+ const focusFallback = collectionHeading || heading?.h1 || main;
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
- const continuation = el('div', undefined, 'image-continuation');
- const message = el('p');
+ const continuation = el('div', undefined, 'infinite-continuation');
+ // The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it.
+ if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; }
+ const message = el('p', '', 'small');
const more = createButton('Load more images');
const retry = createButton('Retry');
- const end = el('p', '', 'muted');
- continuation.append(message, more, retry, end);
- collection.append(toolbar, grid, continuation);
- if (!embedded) collection.append(currentGallery);
+ continuation.append(message, more, retry);
+ // The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
+ const empty = el('div', 'No matching images. Try another search or category.', 'empty');
+ empty.hidden = true;
+ collection.append(caption, grid);
+ if (!embedded) collection.append(empty);
+ collection.append(continuation);
+ // After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
+ let focusAfterLoad = null;
const details = el('section', undefined, 'image-details panel');
details.setAttribute('aria-label', 'Image details');
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
@@ -137,7 +214,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
- if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true});
+ if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
}});
scope.cleanup(() => mediaViewer.dispose());
@@ -146,7 +223,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const node = el(video ? 'video' : 'img');
node.src = resolveUrl(image.thumbnail_url || image.url);
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
- else { node.alt = image.filename; node.decoding = 'async'; if (lazy) node.loading = 'lazy'; }
+ else {
+ // The concept card image: alt is the card title, with the listing's intrinsic size when known.
+ node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
+ if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
+ node.width = image.width; node.height = image.height;
+ }
+ }
return node;
}
function releaseVideo(video) {
@@ -160,41 +243,94 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector.setState(inspection.state);
inspection.select(image);
- for (const [key, card] of rendered) {
- card.classList.toggle('selected', key === selection);
- card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
- }
+ for (const [key, card] of rendered) markSelected(card, key === selection);
+ }
+ /** The concept's `.media-card.selected` (D19); as in the concept, Info carries no pressed state. */
+ function markSelected(card, selected) {
+ card.classList.toggle('selected', selected);
}
function previewImage(image) {
if (!image) return;
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
}
+ /** The concept media card (app.js mediaCard): the image opens the viewer, Info inspects (D7–D11, D19). */
function cardFor(image) {
const key = imageKey(image);
- const card = el('article', undefined, 'image-card');
+ const card = el('article', undefined, 'media-card');
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
- const open = createButton('');
- open.className = 'image-open'; open.setAttribute('aria-label', `Open ${image.filename}`);
- open.title = 'Show image details'; open.setAttribute('aria-pressed', String(key === selection));
- const img = mediaElement(image, true);
- open.append(img);
- const footer = el('div', undefined, 'image-card-footer');
- const title = el('strong', image.filename); title.title = image.path;
- const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : '';
- const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase();
- const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`);
- const preview = createButton('', 'ca-icon-button');
- preview.append(createWorkspaceIcon('images'));
- preview.setAttribute('aria-label', `Preview ${image.filename}`);
- footer.append(title, sub, preview); card.append(open, footer);
+ card.style.setProperty('--media-ratio', mediaRatio(image));
+ const art = el('div', undefined, 'media-art');
+ const open = createButton('', 'image-open');
+ open.setAttribute('aria-label', `Open ${image.filename}`);
+ open.append(mediaElement(image, true));
+ // D7: the star, wherever the listing reports favorites (not on Library-embedded media); D8: no checkbox yet.
+ const overlays = el('div', undefined, 'image-overlays');
+ if (typeof image.favorite === 'boolean') {
+ const star = createButton('', 'star-button');
+ star.setAttribute('aria-label', `Favorite ${image.filename}`);
+ paintStar(star, image.favorite, favoritePending.has(key));
+ overlays.append(star);
+ }
+ art.append(open, overlays);
+ const info = el('div', undefined, 'media-info');
+ const text = el('div');
+ const title = el('h3', cardTitle(image)); title.title = image.path;
+ text.append(title, el('p', cardSubtitle(image)));
+ const actions = el('div', undefined, 'image-card-actions');
+ // D9: the tag button waits for the image-annotations slice.
+ const inspectButton = createButton('', 'icon-btn');
+ inspectButton.append(createWorkspaceIcon('info'));
+ inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
+ actions.append(inspectButton);
+ info.append(text, actions);
+ card.append(art, info);
+ markSelected(card, key === selection);
return card;
}
+ /** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
+ function paintStar(button, favorite, pending) {
+ button.textContent = favorite ? '★' : '☆';
+ button.setAttribute('aria-pressed', String(favorite));
+ if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled');
+ }
+ function setFavorite(key, favorite) {
+ if (scope.signal.aborted) return;
+ stream.updateItem(key, {favorite});
+ const button = rendered.get(key)?.querySelector('.star-button');
+ if (button) paintStar(button, favorite, favoritePending.has(key));
+ }
+ // Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7).
+ if (!embedded) {
+ favoriteTarget = setFavorite;
+ scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
+ }
+ /** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
+ async function toggleFavorite(image) {
+ const key = imageKey(image);
+ if (favoritePending.has(key)) return;
+ const favorite = !image.favorite;
+ favoritePending.add(key);
+ deliverFavorite(key, favorite);
+ const body = {root_index: image.root_index, path: image.path};
+ let saved = false;
+ try {
+ await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
+ saved = true;
+ } catch {
+ // Announced below; the star returns to its previous state.
+ }
+ favoritePending.delete(key);
+ deliverFavorite(key, saved ? favorite : !favorite);
+ showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
+ }
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
- if (event.target.closest('.image-card-footer button')) previewImage(image);
- else if (event.target.closest('button')) inspect(image);
+ if (!image) return;
+ if (event.target.closest('.star-button')) toggleFavorite(image);
+ else if (event.target.closest('.image-open')) previewImage(image);
+ else if (event.target.closest('.image-card-actions button')) inspect(image);
});
function renderVisible() {
@@ -225,9 +361,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
previous = card;
}
- // Natural footer measurements and absolute reservations share unzoomed CSS units.
- const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight));
- if (footerHeight > metrics.footerHeight) {
+ // Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the
+ // concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row.
+ const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale));
+ // A small epsilon: under zoom `rect / scale` carries float noise that must not relayout and scroll for nothing.
+ if (footerHeight > metrics.footerHeight + 0.01) {
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;
metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)};
@@ -242,12 +380,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function positionCard(card, index) {
const image = state.items[index], position = geometry.positions[index];
card.dataset.index = index;
- card.classList.toggle('selected', imageKey(image) === selection);
- card.querySelector('.image-open').setAttribute('aria-pressed', String(imageKey(image) === selection));
+ markSelected(card, imageKey(image) === selection);
card.setAttribute('aria-posinset', String(index + 1)); card.setAttribute('aria-setsize', String(state.total));
Object.assign(card.style, { left: `${position.x}px`, top: `${position.y}px`, width: `${position.width}px`, height: `${position.height}px` });
}
- function focusImage(index, preview = false) {
+ function focusImage(index, actions = false, {scroll = true} = {}) {
if (index < 0 || index >= state.items.length) return;
const image = state.items[index], key = imageKey(image);
let card = rendered.get(key);
@@ -255,8 +392,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
card = cardFor(image); rendered.set(key, card); positionCard(card, index);
grid.insertBefore(card, [...grid.children].find(child => Number(child.dataset.index) > index) || null);
}
- card.querySelector(preview ? '.image-card-footer button' : '.image-open').focus({ preventScroll: true });
- card.scrollIntoView({ block: 'nearest' });
+ card.querySelector(actions ? '.image-card-actions button' : '.image-open').focus({ preventScroll: true });
+ if (scroll) card.scrollIntoView({ block: 'nearest' });
cancelAnimationFrame(frame); frame = 0;
renderVisible();
}
@@ -264,13 +401,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const index = Number(card.dataset.index);
- const columns = layout === 'list' ? 1 : metrics.columns;
+ const columns = geometry.columns;
const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1,
ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key];
if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); }
else if (event.key === 'Tab') {
const backward = event.shiftKey && event.target.matches('.image-open');
- const forward = !event.shiftKey && event.target.closest('.image-card-footer');
+ const forward = !event.shiftKey && event.target.closest('.image-card-actions');
const adjacent = backward ? index - 1 : index + 1;
if ((backward || forward) && state.items[adjacent] && !rendered.has(imageKey(state.items[adjacent]))) {
event.preventDefault(); focusImage(adjacent, backward);
@@ -279,17 +416,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
});
function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); }
function updateGeometry() {
- geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics);
+ geometry = layoutImages(state.items, galleryWidth, layout, metrics);
+ // Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
+ grid.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH);
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
function updateLayout() {
if (!state || scope.signal.aborted) return;
cancelAnimationFrame(frame); frame = 0;
+ // The concept's grid classes (app.js updateImages); the concept stylesheet then owns the columns and gap (D3).
+ grid.className = ['image-grid', density === 'comfortable' ? '' : density,
+ layout === 'list' ? 'list-grid' : layout === 'masonry' ? 'masonry-grid' : ''].filter(Boolean).join(' ');
const width = Number.parseFloat(getComputedStyle(grid).width);
const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null;
galleryWidth = width;
- metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth);
+ metrics = {...INITIAL_METRICS, ...gridMetrics(grid)};
if (retained) {
metrics.footerHeight = retained.footerHeight;
metrics.listHeight = retained.listHeight;
@@ -315,21 +457,42 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
- function renderState(next) {
- const changed = state?.items !== next.items;
+ function continuationText(next) {
+ if (recovering) return 'Images paused until sources and folders are available.';
+ if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
+ if (next.loading && !next.loaded) return 'Loading images…';
+ if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
+ if (embedded) return next.loaded ? 'You’re all caught up.' : 'No linked media available for this selection.';
+ // The concept's end of the collection (app.js updateImageContinuation).
+ return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
+ }
+ function renderState(next, {patch} = {}) {
+ const previous = state;
+ // A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
+ const changed = !patch && state?.items !== next.items;
if (changed) {
if (!next.items.length) itemIndexes.clear();
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
state = next;
- count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`;
+ setText(count, embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next));
+ if (!embedded) {
+ setText(captionDay, captionDate(next.items, filters.sort));
+ if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
+ }
if (changed) updateLayout();
- message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
- if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
+ // The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
+ if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) {
+ const added = next.loaded - previous.loaded;
+ showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
+ }
+ setText(message, continuationText(next));
+ setAttr(message, 'role', next.error ? 'alert' : 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
- end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
+ if (!embedded) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
+ if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
@@ -338,10 +501,31 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
- scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); });
- scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); });
+ function pressLoad() {
+ if (recovering || state?.loading) return;
+ focusAfterLoad = state?.loaded ?? 0;
+ stream.loadMore();
+ }
+ /** The concept focuses the first appended image; focus never stays on a control that just hid. */
+ function settleLoadFocus(next) {
+ const first = focusAfterLoad;
+ focusAfterLoad = null;
+ const active = document.activeElement;
+ // Only while focus is still on the pressed control: a viewer or another control the user moved to keeps it.
+ const stranded = !active || active === document.body || active === more || active === retry;
+ if (!stranded) return;
+ if (next.error) { retry.focus(); return; }
+ // Without scrolling: the visible cards keep their place (bounded-DOM anchor); the next arrow key reveals it.
+ if (next.loaded > first) focusImage(first, false, {scroll:false});
+ else if (more.hidden) {
+ if (next.loaded) focusImage(next.loaded - 1, false, {scroll:false});
+ else focusFallback.focus({preventScroll:true});
+ }
+ }
+ scope.listen(more, 'click', pressLoad);
+ scope.listen(retry, 'click', pressLoad);
function maybeLoad() {
- if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
+ if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
}
@@ -354,65 +538,154 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}, { rootMargin: '250px' });
observer.observe(continuation);
}
+ function updateScope() {
+ scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
+ }
function reset() {
mediaViewer.stopSlideshow();
- main.querySelector('.image-preview[open]')?.close();
+ main.querySelector('#viewer-dialog[open]')?.close();
if (!recovering) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
}
selection = null;
+ focusAfterLoad = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
+ updateScope();
stream.reset(filters, {load:!recovering});
}
+ /** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
+ function openFilters(trigger) {
+ if (recovering) return;
+ openRefineDialog({scope, trigger, fields:[
+ {key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]},
+ {key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
+ {key:'media', options:MEDIA_OPTIONS},
+ {key:'tags'},
+ ], values:{source:filters.root, folder:filters.subfolder, media:filters.media, tags:filters.tags.join(', ')},
+ onApply:applyRefinement, onClear:cleared => {
+ toolbar.setValue({category:cleared.category});
+ applyRefinement(cleared);
+ }});
+ }
+ /** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
+ function folderOptions(source) {
+ const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
+ return [['', 'All output folders'], ...(root?.subfolders ?? folders).map(path => [path, path])];
+ }
+ /** Apply the dialog's values (Clear adds `category`); unchanged filters keep the loaded page, as the toolbar does. */
+ function applyRefinement({source, folder, media, tags, category = filters.category}) {
+ if (recovering) return;
+ const next = {root:source, subfolder:folder, media, category,
+ tags:[...new Set(tags.split(',').map(tag => tag.trim()).filter(Boolean))]};
+ const same = Object.entries(next).every(([key, value]) => key === 'tags'
+ ? value.length === filters.tags.length && value.every((tag, index) => tag === filters.tags[index])
+ : filters[key] === value);
+ if (same) return;
+ Object.assign(filters, next);
+ reset();
+ }
+ function setControlsDisabled(disabled) {
+ for (const control of [...toolbarControls, more, retry]) control.disabled = disabled;
+ if (importButton) importButton.disabled = disabled;
+ }
+ /**
+ * Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. A toast
+ * says it started and how it ended; meanwhile a reopened Import dialog shows Rescan as running.
+ */
+ async function rescan() {
+ if (rescanning || recovering || scope.signal.aborted) return;
+ rescanning = true;
+ optionsRequest?.abort(); optionsRequest = null;
+ showWorkspaceToast(RESCAN_STARTED);
+ try {
+ const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
+ if (scope.signal.aborted) return;
+ const [found] = await Promise.all([loadOptions(), loadCategories()]);
+ if (scope.signal.aborted) return;
+ renderLoadStatus();
+ if (found) reset();
+ if (!scope.signal.aborted) showWorkspaceToast(rescanFinished(result));
+ } catch {
+ // A failed rescan leaves the collection and any discovery alert as they were.
+ if (!scope.signal.aborted) showWorkspaceToast(RESCAN_FAILED);
+ } finally {
+ rescanning = false;
+ importDialog?.setRescanBusy(false);
+ }
+ }
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
- filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim();
- source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', ''));
- for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true;
- optionsStatus.hidden = true;
+ filters.root = 'all';filters.subfolder = '';
+ if (toolbar) filters.q = toolbar.value().q.trim();
+ roots = [];folders = [];
+ setControlsDisabled(true);
+ sourcesRetry = null;
+ renderLoadStatus();
reset();
const discovery = loadOptions(), request = optionsRequest;
- if (!await discovery || scope.signal.aborted || request !== optionsRequest) return;
+ const found = await discovery;
+ if (scope.signal.aborted || request !== optionsRequest) return;
+ renderLoadStatus();
+ if (!found) return;
recovering = false;
- for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false;
+ setControlsDisabled(false);
reset();
}
+ function renderLoadStatus() {
+ const text = loadFailure(sourcesRetry, categoriesFailed);
+ setText(optionsStatus, text);
+ optionsStatus.hidden = !text;
+ }
+ /** Discover the sources and folders; the caller renders the outcome with renderLoadStatus. */
async function loadOptions() {
optionsRequest?.abort();
const request = new AbortController(); optionsRequest = request;
try {
+ // Every source's folders (with ancestors), listed per source too, so the dialog can follow its Source.
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
- if (filters.root !== 'all') params.set('root', filters.root);
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return false;
- source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
- source.value = filters.root;
- folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
- folder.value = filters.subfolder;
- optionsStatus.hidden = true;
+ if (!Array.isArray(data?.roots) || !Array.isArray(data.subfolders)) throw new TypeError('Invalid image sources');
+ const paths = list => Array.isArray(list) ? list.filter(path => typeof path === 'string' && path) : null;
+ roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
+ .map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
+ folders = paths(data.subfolders);
+ updateScope();
+ sourcesRetry = null;
return true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return false;
- optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.';
- optionsStatus.hidden = false;
+ sourcesRetry = recovering ? RETRY_REFRESH : RETRY_RESCAN;
return false;
}
}
- scope.cleanup(() => optionsRequest?.abort());
- scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200));
- for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
- scope.listen(input, 'change', () => {
- if (recovering) return;
- filters[key] = input.value;
- if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
- reset();
- });
+ /** The toolbar's categories are the Library's (the linked positive's category, D13). */
+ async function loadCategories() {
+ if (!toolbar) return;
+ categoryRequest?.abort();
+ const request = new AbortController(); categoryRequest = request;
+ try {
+ const data = await api.get('/prompt_manager/library/options', {signal:request.signal});
+ if (scope.signal.aborted || request !== categoryRequest) return;
+ if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories');
+ // D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
+ const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]);
+ names.delete('');
+ toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)));
+ categoriesFailed = false;
+ } catch {
+ if (scope.signal.aborted || request !== categoryRequest) return;
+ categoriesFailed = true;
+ }
}
- if (!embedded) loadOptions();
+ scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
+ updateScope();
+ // The page's one discovery alert is written once both first loads settled (#249 final review m6).
+ if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
if (visit) {
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs
index a525b17..0df1415 100644
--- a/web/js/workspace/index.mjs
+++ b/web/js/workspace/index.mjs
@@ -9,6 +9,7 @@ import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading } from './heading.mjs';
+import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -85,6 +86,8 @@ function mountWorkspace() {
scope.cleanup(() => viewScope?.dispose());
function render(route) {
+ // A shared page dialog never stays open over the next route.
+ closeWorkspaceDialog();
viewScope?.dispose();
images = null;
viewScope = createScope();
diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css
index 710d14b..07e5b82 100644
--- a/web/js/workspace/inspector.css
+++ b/web/js/workspace/inspector.css
@@ -1,9 +1,8 @@
.image-details.file-inspector { padding: 0; }
-.image-preview-slideshow-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; }
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector-inner { padding: 17px; }
-.image-preview-details .file-inspector-inner { padding: 0; }
-.image-preview-details .file-inspector-footer { padding-inline: 0; }
+#viewer-details .file-inspector-inner { padding: 0; }
+#viewer-details .file-inspector-footer { padding-inline: 0; }
.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); }
.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
@@ -36,7 +35,7 @@
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
.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; }
+.ca-tools #viewer-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 .icon { inline-size: 14px; block-size: 14px; }
.file-status { padding: 17px; }
diff --git a/web/js/workspace/library-editor.mjs b/web/js/workspace/library-editor.mjs
index 67ec411..4eb238b 100644
--- a/web/js/workspace/library-editor.mjs
+++ b/web/js/workspace/library-editor.mjs
@@ -19,21 +19,12 @@ const NO_PAIR = 'Archive needs a selected saved negative variant.';
const ALREADY_ARCHIVED = 'This negative variant is already archived.';
const REVIEW = 'Loaded the latest saved values. Review your entries, then save.';
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
-const TOAST_MS = 6500;
const REFRESH = ['Reload Library', 'refresh'];
// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline:
// every exit (Reload Library, Cancel, × or Escape) drops the draft.
const DISCARDS = 'Leaving this dialog discards your entries.';
-let toastTimer;
-
-/** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */
-export function showWorkspaceToast(message) {
- const toast = document.getElementById('workspace-toast');
- if (!toast) return;
- toast.textContent = message;
- clearTimeout(toastTimer);
- toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
-}
+// The toast lives in the shell module; re-exported for the pages that already import it from here.
+export { showWorkspaceToast } from './toast.mjs';
function el(tag, text, className) {
const node = document.createElement(tag);
diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs
index 5f10bf0..67e1b36 100644
--- a/web/js/workspace/media-viewer.mjs
+++ b/web/js/workspace/media-viewer.mjs
@@ -40,23 +40,24 @@ export function createMediaViewer({container,parentScope,inspectionController,se
update(state,selected);
}});
const local = createScope();
- const dialog = element('dialog','image-preview');
- dialog.setAttribute('aria-label','Image preview');
- const header = element('header','image-preview-header');
- const heading = element('div'), title = element('h2');
- const position = element('p','image-preview-position muted');
- position.setAttribute('role','status');
- heading.append(title,position);
- const close = createButton('×');close.setAttribute('aria-label','Close preview');close.autofocus = true;
+ // The concept's viewer shell (index.html h84–103, app.js a889–926): `dialog#viewer-dialog` named by its title,
+ // `.dialog-header`, then `.viewer-layout` > `.viewer-left` (`#viewer-stage`, `#viewer-toolbar`) and
+ // `aside#viewer-details`. #249 D34: production keeps a focusable stage, its controls' accessible names, the
+ // Download link and, after Next →, the position status and the two unavailability reasons.
+ const dialog = element('dialog');dialog.id = 'viewer-dialog';dialog.setAttribute('aria-labelledby','viewer-title');
+ const header = element('div','dialog-header');
+ const heading = element('div'), title = element('h2');title.id = 'viewer-title';
+ heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow'));
+ const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true;
header.append(heading,close);
- const body = element('div','image-preview-body'), left = element('div','image-preview-left');
- const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview');
- const media = element('div','image-preview-media'), failure = element('div','image-preview-failure');
+ const body = element('div','viewer-layout'), left = element('div','viewer-left');
+ const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
+ stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
+ const failure = element('div','media-failure');
const error = element('p',undefined,'Media could not be loaded. Retry this file.');
- const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure);
- const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls');
+ const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(failure);
+ const controls = element('div','viewer-toolbar');controls.id = 'viewer-toolbar';
controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls');
- const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`;
function button(label,text,action) {
const node = createButton(text);node.setAttribute('aria-label',label);
local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); });
@@ -67,13 +68,16 @@ export function createMediaViewer({container,parentScope,inspectionController,se
button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)),
button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))];
const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play());
- const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
- const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
+ const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
const next = button('Next media','Next →',() => session.navigate(1));
- toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar);
- const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details');
+ const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
+ position.setAttribute('role','status');
+ const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
+ const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
+ controls.append(position,reason,slideshowReason);left.append(stage,controls);
+ const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
body.append(left,details);dialog.append(header,body);container.append(dialog);
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
@@ -106,7 +110,8 @@ export function createMediaViewer({container,parentScope,inspectionController,se
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
});
function rebuild(selected) {
- mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren();
+ mediaScope?.dispose();mediaScope = createScope();viewer = null;
+ for (const child of [...stage.children]) if (child !== failure) child.remove();
failure.hidden = true;
const mediaLocal = mediaScope, key = session.state.key;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
@@ -135,7 +140,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
}
- node.src = url.href;media.append(node);
+ node.src = url.href;stage.append(node);
if (!selected.is_video && window.Viewer) {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
@@ -147,12 +152,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se
fitFrame = requestAnimationFrame(() => {
fitFrame = 0;
if (!current() || !instance.viewed) return;
- const width = media.offsetWidth, height = media.offsetHeight;
+ const width = stage.offsetWidth, height = stage.offsetHeight;
if (width === fitWidth && height === fitHeight) return;
fitWidth = width;fitHeight = height;instance.resize();
});
};
- const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false,
+ const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
@@ -183,7 +188,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
}
for (const node of [previous,next]) {
node.setAttribute('aria-disabled',String(state.total === 1));
- if (state.total === 1) {node.setAttribute('aria-describedby',position.id);}
+ if (state.total === 1) node.setAttribute('aria-describedby',position.id);else node.removeAttribute('aria-describedby');
}
const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play';
slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`);
@@ -211,14 +216,15 @@ export function createMediaViewer({container,parentScope,inspectionController,se
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`;
if (position.textContent !== label) position.textContent = label;
if (currentKey !== state.key) {
- currentKey = state.key;title.textContent = entry.filename;
+ // A linked card already carries its title, so the header reads " · " at once.
+ const known = entry.linked?.status === 'linked' && entry.linked.title;
+ currentKey = state.key;title.textContent = known ? `${known} · ${entry.filename}` : entry.filename;
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
onSelect?.(entry);
rebuild(entry);
}
updateControls(session.state);
}
- position.id = `media-viewer-position-${viewerId}`;
dialog.showModal();mounted = true;update(...initial);
local.cleanup(inspectionController.subscribe(state => {
inspectionState = state;updateControls(session.state);
diff --git a/web/js/workspace/toast.mjs b/web/js/workspace/toast.mjs
new file mode 100644
index 0000000..f0cf510
--- /dev/null
+++ b/web/js/workspace/toast.mjs
@@ -0,0 +1,12 @@
+/** The shell's shared toast (`#workspace-toast`, role="status"), used by every workspace page. */
+const TOAST_MS = 6500;
+let toastTimer;
+
+/** Show one outcome in the shared workspace toast; it clears itself like the concept's. */
+export function showWorkspaceToast(message) {
+ const toast = document.getElementById('workspace-toast');
+ if (!toast) return;
+ toast.textContent = message;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
+}
diff --git a/web/js/workspace/toolbar.mjs b/web/js/workspace/toolbar.mjs
new file mode 100644
index 0000000..2eac2e7
--- /dev/null
+++ b/web/js/workspace/toolbar.mjs
@@ -0,0 +1,111 @@
+/**
+ * The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
+ * Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
+ * `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
+ * page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16).
+ */
+import { createButton } from '../shared/toolkit/core.js';
+import { createWorkspaceIcon } from './icons.mjs';
+
+/** The search waits this long after the last keystroke before it reports a change. */
+export const SEARCH_DELAY = 250;
+
+// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
+// other label is one of the page's appended options.
+const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
+
+const isText = value => typeof value === 'string';
+
+/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
+export function orderSorts(sorts) {
+ if (!Array.isArray(sorts) || sorts.length === 0 ||
+ sorts.some(pair => !Array.isArray(pair) || pair.length !== 2 || !isText(pair[0]) || !isText(pair[1]))) {
+ throw new TypeError('Sorts must be a non-empty list of [value, label] pairs');
+ }
+ const rank = ([, label]) => {
+ const index = CONCEPT_SORT_LABELS.indexOf(label);
+ return index === -1 ? CONCEPT_SORT_LABELS.length : index;
+ };
+ return sorts.map((pair, index) => ({ pair, index }))
+ .sort((a, b) => rank(a.pair) - rank(b.pair) || a.index - b.index)
+ .map(({ pair }) => [...pair]);
+}
+
+function option(value, label) {
+ const node = document.createElement('option');
+ node.value = value;
+ node.textContent = label;
+ return node;
+}
+
+function categoryOptions(categories) {
+ if (!Array.isArray(categories) || categories.some(value => !isText(value))) {
+ throw new TypeError('Categories must be a list of strings');
+ }
+ const values = [...new Set(categories.filter(Boolean))];
+ return [option('', 'All categories'), ...values.map(value => option(value, value))];
+}
+
+/**
+ * Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
+ * value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view".
+ * The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`.
+ */
+export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
+ if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
+ throw new TypeError('The toolbar needs the page scope');
+ }
+ if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
+ throw new TypeError('The toolbar needs onChange and onFilters');
+ }
+ const root = document.createElement('search');
+ root.className = 'toolbar';
+
+ const label = document.createElement('label');
+ label.className = 'search-field';
+ label.htmlFor = 'search';
+ const search = document.createElement('input');
+ search.id = 'search';
+ search.type = 'search';
+ search.placeholder = 'Search prompts, tags, filenames…';
+ // The listing API rejects longer search text (py/api/images.py _workspace_filters).
+ search.maxLength = 500;
+ search.setAttribute('aria-label', 'Search workspace');
+ label.append(createWorkspaceIcon('search'), search);
+
+ const category = document.createElement('select');
+ category.id = 'category-filter';
+ category.setAttribute('aria-label', 'Filter by category');
+ category.replaceChildren(...categoryOptions(categories));
+
+ const sort = document.createElement('select');
+ sort.id = 'sort';
+ sort.setAttribute('aria-label', 'Sort results');
+ sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
+
+ const filters = createButton('Filters');
+ root.append(label, category, sort, filters);
+
+ const read = () => ({ q: search.value, category: category.value, sort: sort.value });
+ function setValue(next) {
+ if (isText(next?.q)) search.value = next.q;
+ if (isText(next?.category)) category.value = next.category;
+ if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value;
+ }
+ function setCategories(next) {
+ const selected = category.value;
+ category.replaceChildren(...categoryOptions(next));
+ category.value = [...category.options].some(node => node.value === selected) ? selected : '';
+ }
+ setValue(value);
+
+ const report = () => onChange(read());
+ scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
+ scope.listen(category, 'change', report);
+ scope.listen(sort, 'change', report);
+ scope.listen(filters, 'click', () => onFilters(filters));
+ scope.cleanup(() => root.remove());
+
+ container.append(root);
+ return { element: root, value: read, setValue, setCategories };
+}
diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs
new file mode 100644
index 0000000..c238678
--- /dev/null
+++ b/web/js/workspace/workspace-dialogs.mjs
@@ -0,0 +1,259 @@
+/**
+ * The concept's shared page dialogs in its one modal shell (docs/concepts/workspace/index.html `dialog#modal`:
+ * `.dialog-header` > `h2#modal-title` + `×` "Close dialog", then `#modal-body`). Opening a dialog while one is open
+ * swaps its content, as the concept's `dialog()` does. Closing, by ×, Escape or an action, returns focus to the
+ * control that opened it.
+ *
+ * - "Refine this view" (a1348–1364): Source / Folder / Media / Tags, each optional per page; "Apply filters" and no
+ * mock-status note because every field is a real filter, and no "Prompt evidence" field because evidence is only
+ * known per file (D14). Clear also clears the toolbar category, as the concept's does; the page applies it.
+ * - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
+ * Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
+ * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
+ *
+ * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
+ */
+import { createButton } from '../shared/toolkit/core.js';
+
+const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
+const FIELD_ORDER = Object.keys(FIELD_LABELS);
+
+let shell = null;
+let session = null;
+
+function el(tag, text, className) {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+}
+
+function field(label, control, id) {
+ const wrapper = el('div', undefined, 'field');
+ const caption = el('label', label);
+ caption.htmlFor = id;
+ control.id = id;
+ wrapper.append(caption, control);
+ return wrapper;
+}
+
+function createShell() {
+ const dialog = el('dialog');
+ dialog.id = 'modal';
+ dialog.setAttribute('aria-labelledby', 'modal-title');
+ const header = el('div', undefined, 'dialog-header');
+ const title = el('h2');
+ title.id = 'modal-title';
+ const close = createButton('×');
+ close.id = 'modal-close';
+ close.setAttribute('aria-label', 'Close dialog');
+ const body = el('div');
+ body.id = 'modal-body';
+ header.append(title, close);
+ dialog.append(header, body);
+ close.addEventListener('click', () => finish());
+ dialog.addEventListener('cancel', event => {
+ event.preventDefault();
+ finish();
+ });
+ // A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it
+ // is ignored once this shell was reopened or replaced.
+ dialog.addEventListener('close', () => {
+ if (shell?.dialog === dialog && !dialog.open) finish();
+ });
+ return { dialog, title, body, close };
+}
+
+/** Close the open dialog, if any, and focus its trigger while that is still on the page. */
+function finish() {
+ if (!session) return;
+ const { trigger } = session;
+ session = null;
+ if (shell?.dialog.open) shell.dialog.close();
+ if (trigger?.isConnected) trigger.focus();
+}
+
+const boundScopes = new WeakSet();
+
+function requireScope(scope) {
+ if (typeof scope?.cleanup !== 'function') throw new TypeError('Workspace dialogs need the page scope');
+}
+
+function requireCallbacks(callbacks) {
+ for (const [name, callback] of Object.entries(callbacks)) {
+ if (typeof callback !== 'function') throw new TypeError(`The dialog needs ${name}`);
+ }
+}
+
+/** Disposing a page scope closes the shell unless a newer page's dialog is open in it. */
+function bindScope(scope) {
+ if (boundScopes.has(scope)) return;
+ boundScopes.add(scope);
+ scope.cleanup(() => {
+ boundScopes.delete(scope);
+ if (!session || session.scope === scope) closeWorkspaceDialog();
+ });
+}
+
+function show(heading, nodes, { scope, trigger, focusTarget }) {
+ shell ??= createShell();
+ if (!shell.dialog.isConnected) document.body.append(shell.dialog);
+ bindScope(scope);
+ session = { scope, trigger: trigger ?? session?.trigger ?? null };
+ shell.title.textContent = heading;
+ shell.body.replaceChildren(...nodes);
+ if (!shell.dialog.open) shell.dialog.showModal();
+ (focusTarget ?? shell.close).focus();
+}
+
+/** Close any open workspace dialog and remove the shell; a page calls this when it unmounts. */
+export function closeWorkspaceDialog() {
+ finish();
+ shell?.dialog.remove();
+ shell = null;
+}
+
+function fillSelect(select, key, options) {
+ if (!Array.isArray(options) || options.length === 0 ||
+ options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
+ throw new TypeError(`The ${key} filter needs [value, label] options`);
+ }
+ select.replaceChildren(...options.map(([value, label]) => {
+ const node = el('option', label);
+ node.value = value;
+ return node;
+ }));
+}
+
+function selectControl(key, options) {
+ const select = el('select');
+ fillSelect(select, key, options);
+ return select;
+}
+
+/** Show `value` when the select offers it, else its first (cleared) option. */
+function choose(select, value) {
+ const known = [...select.options].some(option => option.value === value);
+ select.value = known ? value : select.options[0].value;
+}
+
+/**
+ * "Refine this view". `fields` lists the page's filters in any subset of Source, Folder, Media and Tags, as
+ * `{key, options}` with `[value, label]` options for the selects (the first is the cleared value) and no options for
+ * Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
+ * folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
+ * reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
+ */
+export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
+ requireScope(scope);
+ requireCallbacks({ onApply, onClear });
+ if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) {
+ throw new TypeError('Refine fields are source, folder, media and tags');
+ }
+ const controls = FIELD_ORDER.flatMap(key => {
+ const item = fields.find(candidate => candidate.key === key);
+ if (!item) return [];
+ if (key === 'tags') {
+ const input = el('input');
+ input.placeholder = 'e.g. cinematic';
+ input.value = typeof values.tags === 'string' ? values.tags : '';
+ return [{ key, control: input, cleared: '' }];
+ }
+ const select = selectControl(key, item.options);
+ choose(select, values[key]);
+ return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
+ });
+ const source = controls.find(item => item.key === 'source');
+ const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
+ const followSource = () => {
+ const chosen = folder.control.value;
+ fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
+ choose(folder.control, chosen);
+ };
+ if (source && folder) {
+ followSource();
+ source.control.addEventListener('change', followSource);
+ }
+ const read = () => Object.fromEntries(controls.map(({ key, control }) =>
+ [key, key === 'tags' ? control.value.trim() : control.value]));
+
+ const clear = createButton('Clear filters');
+ const apply = createButton('Apply filters', 'ca-primary');
+ clear.addEventListener('click', () => {
+ // The cleared Source's folders first, so the cleared Folder value is one of them.
+ if (source && folder) { source.control.value = source.cleared; followSource(); }
+ for (const { control, cleared } of controls) control.value = cleared;
+ const reset = read();
+ finish();
+ onClear({ ...reset, category: '' });
+ });
+ apply.addEventListener('click', () => {
+ const applied = read();
+ finish();
+ onApply(applied);
+ });
+ const actions = el('div', undefined, 'dialog-actions');
+ actions.append(clear, apply);
+ show('Refine this view', [
+ ...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
+ actions,
+ ], { scope, trigger });
+}
+
+/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */
+function operation(title, description, label, onAction) {
+ const row = el('div', undefined, 'operation');
+ const text = el('div');
+ text.append(el('h3', title), el('p', description));
+ const actions = el('div', undefined, 'row-actions');
+ const action = createButton(label);
+ action.addEventListener('click', () => {
+ if (action.getAttribute('aria-disabled') === 'true') return;
+ finish();
+ onAction();
+ });
+ actions.append(action);
+ row.append(text, actions);
+ return { row, action };
+}
+
+const RESCAN_LABEL = 'Rescan';
+const RESCAN_BUSY_LABEL = 'Rescanning…';
+
+/**
+ * "Import & inspect" with the two real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
+ * aria-disabled, so a second press is visibly ignored. Returns `{setRescanBusy}` for the page to end that state.
+ */
+export function openImportDialog({ scope, onInspect, onRescan, trigger = null, rescanBusy = false }) {
+ requireScope(scope);
+ requireCallbacks({ onInspect, onRescan });
+ const list = el('div', undefined, 'operation-list');
+ const inspect = operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect);
+ const rescan = operation('Scan an output folder', 'Find files added or removed in the output folders.', RESCAN_LABEL, onRescan);
+ list.append(inspect.row, rescan.row);
+ function setRescanBusy(busy) {
+ rescan.action.textContent = busy ? RESCAN_BUSY_LABEL : RESCAN_LABEL;
+ if (busy) rescan.action.setAttribute('aria-disabled', 'true');
+ else rescan.action.removeAttribute('aria-disabled');
+ }
+ setRescanBusy(rescanBusy);
+ show('Import & inspect', [
+ el('p', 'Image inspection and output scanning have different effects. Choose the workflow you want.', 'small'),
+ list,
+ ], { scope, trigger });
+ return { setRescanBusy };
+}
+
+/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */
+export function openCopyFallback(text, { scope, trigger = null } = {}) {
+ requireScope(scope);
+ if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
+ const area = el('textarea');
+ area.readOnly = true;
+ area.value = text;
+ show('Copy prompt', [
+ field('Select and copy this text', area, 'copy-text'),
+ el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
+ ], { scope, trigger, focusTarget: area });
+ area.select();
+}
diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css
index d888b57..432ce51 100644
--- a/web/js/workspace/workspace.css
+++ b/web/js/workspace/workspace.css
@@ -54,20 +54,99 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; }
}
+/* Images (#249 C2). Production-only continuation states the concept never shows: a failed page and a load in flight. */
+.infinite-continuation [role="alert"] { color: var(--ca-error-text); }
+.infinite-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
+/* #249 D14: the scope chip names real sources and folders, which can be far longer than "All output folders". */
+.filter-row span.chip { max-width: 100%; min-width: 0; overflow-wrap: anywhere; }
+/* #249 D32: at extreme zoom the 390px shell is narrower than the concept's three layout buttons; they wrap instead of
+ scrolling the page sideways (they never wrap while they fit). */
+.segmented { flex-wrap: wrap; max-width: 100%; }
+/* #249 D32: likewise the continuation's text and Load more wrap instead of pushing the button out of the column. */
+.infinite-continuation { flex-wrap: wrap; }
+
+/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
+ device has no real hover, so a tap must not leave the zoom stuck. */
+@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
+/* #249 D8 (interim until the checkbox follow-up PR): without the concept's selection checkbox the star is the overlay's
+ only child; this keeps it at the concept's top-right. The follow-up that ships the checkbox deletes this rule. */
+.image-overlays > .star-button:only-child { margin-left: auto; /* D8 */ }
+
+/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
+ layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is
+ absolute. Columns and gap still come from the concept's `.image-grid` tracks. */
+.image-grid { position: relative; /* D3 */ }
+.image-grid > .media-card { position: absolute; }
+/* #249 D3: a list row's info keeps its own height so it can be measured; centred, it looks as the stretched concept row.
+ #249 D31: it may also shrink below a long nowrap title (the h3 ellipsis then applies, as in the grid). */
+.list-grid .media-info { align-self: center; min-width: 0; /* D3, D31 */ }
+/* #249 D31: at extreme zoom a list row is narrower than the concept's 160px art; the art yields so the info stays inside. */
+.list-grid .media-art { width: min(160px, 45%); /* D31 */ }
+/* #249 D31: at extreme zoom the subtitle wraps inside its column instead of running under the Inspect button. */
+.media-info p { overflow-wrap: anywhere; /* D31 */ }
+/* #249 D31: below the narrowest bounded card (120px, reached only at extreme zoom; images.mjs marks the grid) the info
+ row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
+.narrow-cards .media-info { flex-wrap: wrap; }
+.narrow-cards .media-info > div { flex: 1 0 100%; }
+/* A list row beside its art keeps room for Inspect. */
+.narrow-cards.list-grid .media-art { width: 40%; }
+.narrow-cards.list-grid .media-info { padding-inline: 4px; }
+
+/* Legacy video media (the mock does not drop it) takes the concept's image rules in each layout. */
+.image-open video { display: block; width: 100%; height: auto; aspect-ratio: 1.33; object-fit: cover; }
+.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
+.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
+
+/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
+ titles and states that the concept's one demo image never shows. */
+/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
+ concept's `calc(100% - 72px)` layout in a column, so a taller header shrinks the layout instead of pushing the toolbar
+ out of view; a one-line title gives the concept's boxes. */
+@media (min-width: 701px) {
+ .ca-tools #viewer-dialog[open] { display: flex; flex-direction: column; /* D34 */ }
+}
+/* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */
+.ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ }
+#viewer-dialog .dialog-header > div { min-width: 0; }
+#viewer-title { overflow-wrap: anywhere; /* D34 */ }
+#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; }
+#viewer-details { overscroll-behavior: contain; /* D34 */ }
+/* #249 D34: Viewer.js's stylesheet styles every `.viewer-toolbar` (`position: absolute; bottom: 0; left: 0; right: 0`),
+ which lifts the concept's toolbar out of `.viewer-left` to span the whole dialog over the details' last lines. It stays
+ in the column under the stage, where the concept's `.viewer-left` flex column and border place it. */
+#viewer-toolbar { position: static; /* D34 */ }
+/* #249 D34: Viewer.js's own toolbar stays hidden (`toolbar: false`); the concept's `.viewer-toolbar` rule would show it. */
+.viewer-container .viewer-hide { display: none; /* D34 */ }
+/* #249 D34: the stage is focusable for arrow-key navigation; a video plays with native controls, an image without
+ Viewer.js shows itself (the concept hides it under the viewer), and a load failure covers the stage with Retry. */
+#viewer-stage:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; }
+#viewer-stage > :is(video, img:not([hidden])) { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; visibility: visible; /* D34 */ }
+.media-failure { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; }
+/* #249 D34: Download stays the original file's link, drawn as the concept's toolbar button. */
+#viewer-toolbar > .media-download { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 4px 10px; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-control); color: var(--ca-text); font-size: 12px; white-space: nowrap; /* D34 */ }
+#viewer-toolbar > .media-download:hover:not([aria-disabled="true"]) { background: var(--ca-hover); }
+#viewer-toolbar > [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
+/* #249 D34: after Next → the position status and the unavailability reasons, each on its own centred line. */
+#viewer-toolbar > p { flex-basis: 100%; margin: 0; text-align: center; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
+@media (pointer: coarse) {
+ /* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "−" and "+" are narrower). */
+ #viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ }
+}
+@media (max-width: 700px) {
+ #viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
+}
+
/* 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 */
}