Compare commits

..
Author SHA1 Message Date
Vito 728db36ac3 ci: fix the frontend coverage timeout and the WebKit Load more race (#262)
* ci: give the frontend coverage job a 40-minute limit with measured headroom

The job runs every page workflow twice (chromium, then touch) on one
worker with V8 coverage, so it takes about twice a single-browser job.
Recent runs took 18m26s-24m57s and PR head 6359999 was cancelled at the
25-minute limit while still passing. Coverage, floors and commands are
unchanged.

* test(images): dispatch the production Load more press so the observer cannot pre-empt it

A pointer click scrolls the continuation row into view, which lets the
page's own infinite-scroll observer append the final batch and hide the
button before the click lands (WebKit, run 39512528039). Dispatch the
press as the concept side already does, after asserting the button is
visible and enabled; the count, card, toast and loading-row assertions
are unchanged.
2026-10-20 19:47:37 -07:00
Vito 5ded3cfd39 test(history): prove bounded close with events, not wall-clock bounds (#261)
The real-clock subtest of the bounded-close test asserted close(0.02)
returned in under 0.2 s; a slow macOS runner took 0.26 s. The failed
subtest then skipped its release and join, so its worker stayed blocked
holding a charge on the process-wide SHARED_CONTEXT_BUDGET, and the next
subtest saw 621 != 0. Not a production race: close(2) only returns True
once the worker has exited and released its leases.

Prove boundedness with events instead: close() must return False while
the in-flight write is still blocked (it has not left its fake write).
Keep the exact upper bound only under the manual clock, where it is
deterministic. Release and join each subtest's worker in a finally so a
failure cannot leak a charge into the next subtest. The commit-gate test
gets the same treatment: submit() and close(0) must return before the
blocked invalidation and commit finish, instead of a 0.1 s bound.
2026-10-20 18:01:34 -07:00
Vito 2b8126b7be feat(images): card selection checkbox and selection bar (Set category, Export metadata) (#259)
* feat(images): card selection checkbox and the concept selection bar

Every Images card's overlay starts with the concept's "Select <filename>"
checkbox before the star. Checked image keys form the page's selection,
kept across Load more, infinite scroll and bounded re-mounting and cleared
whenever the collection is replaced. The #bulkbar between the filter row
and the gallery shows "N selected" with Set category, Export metadata and
Clear (the first two are wired by the next tasks); Clear empties it.

The interim D8 star rule is removed, and the concept comparison now
measures the overlay with its checkbox and the bar with one card checked.

* feat(images): export the selected images' metadata as one JSON bundle

The selection bar's Export metadata reads each checked image's inspection,
at most four at a time, and downloads prompt-manager-images-metadata.json:
format, version 1, exported_at and, in listing order, the same envelope the
inspector's "Download metadata JSON" saves (one shared builder,
metadataEnvelope). The button is aria-disabled and ignores a second press
while it runs, the selection is kept, and the toast reports the exported
count, the images that could not be read, or that none could (no download).

Also: the bar and its count are built only on the Images page, the
Library-embedded gallery is asserted to have no checkbox or bar, a sort
change is asserted to clear the selection, the checkbox label test asserts
the toggle and an unchanged selection and inspector, and TESTING.md records
that a return to Images keeps the selection while a reload starts empty.

* feat(images): set the category of the selected images' linked prompts

The selection bar's Set category opens the concept's simpleInput dialog
("Categorize selected images", one Category field, Cancel / Apply) and
posts the selection's distinct linked positive ids to
/prompt_manager/bulk/category. Images have no category of their own; the
listing's category filter matches the linked positive's, so that is what
changes. With no linked image checked nothing is posted and a toast says
so; on success the toast counts the prompts and any skipped images, then
the categories and the listing reload (clearing the selection); a failure
keeps the selection. The button ignores a second press while it runs.

The bulk category route now rejects anything but a non-empty list of at
most 1000 positive integer ids (deduplicated in order) and validates the
category with validate_category; bad JSON or a non-object body is a 400.
The response shape and the legacy admin request are unchanged.

* test(images): zoomed card keyboard order follows the concept's checkbox before the star

The card's Tab order is image, Select checkbox, star, Inspect (concept app.js:398), both ways; List hides the overlays as the concept does, so there the image is followed by Inspect.

* fix(api): answer 400 for bulk category ids beyond SQLite's INTEGER range

An id of 2**63 or more passed the parser and made sqlite3 raise OverflowError, which the route turned into a 500.

* refactor(images): move the selection bar into images-selection.mjs and harden its actions

- images.mjs is back under 800 lines; the bar, its checked set, Export metadata and Set category live in images-selection.mjs.
- Busy flags release in finally; the dead guards are gone and the category targets are taken when the modal dialog opens.
- The category write no longer carries the page signal; leaving Images mid-write drops the saved visit so the return reloads.
- The categories and the listing reload together after Set category.
- The Category field stops at the server's 100 characters; the server's updated_count is a tested pure helper.
- New browser tests: leaving mid-export and mid-write, Blob URL revoke, updated_count 0 and a 400 rejection.
- Additive coverage baseline entries for images-category, images-export and images-selection.
- TESTING.md D8 records the dialog deviations and these rulings.
2026-10-20 16:04:15 -07:00
Vito 86d82186ee feat(images): concept Images page — toolbar, cards, star, viewer, compared against the mock (#258)
* feat(workspace): shared concept toolbar and dialogs

Add the concept's shared page toolbar (search.toolbar with the search
field, category filter, sort and Filters) and its dialogs in one modal
shell: "Refine this view" (Source / Folder / Media / Tags, Apply filters),
"Import & inspect" with only the real operations (Open inspector, Rescan),
and the "Copy prompt" select-to-copy fallback. The sort lists the
concept's three options first (D14); there is no Save view (D16). Every
dialog returns focus to its trigger on close or Escape.

Refs #249

* fix(workspace): bind shared dialogs to the page and its route

- Drop the invented Refine note; every field is real (D14).
- Ignore a late close event once the shell was reopened or replaced, so
  an action that opens the next dialog keeps it open.
- Dialog openers require the page scope; disposing it closes and removes
  the shell unless a newer page's dialog is open. Every route change
  closes the shared dialog before the next page mounts.
- Clear filters also reports category '' so the page clears the toolbar
  category, as the concept does.
- The toolbar requires its scope and falls back to the first sort for an
  unknown value; dialog callbacks are validated up front.

Refs #249

* feat(images): concept toolbar, filter row and caption

The Images page now renders the approved mock's heading, shared toolbar,
filter row, section caption and infinite continuation (#249, Task C2).

- Heading: "Your generations, their prompts, and the next idea." with
  Import (upload icon) before New prompt (primary).
- Toolbar (shared mountToolbar): search over filenames, linked prompt
  text, titles and tags; category (the Library's categories); sort
  Recently added / Name A–Z / Highest rated, then Oldest first and
  Name Z–A (D14).
- Filter row: Grid/List/Masonry segmented control, Thumbnail size,
  the scope chip ("All output folders" or "<source> · <folder>") and
  "Group by positive prompt →".
- Caption: "N of M images · K positive prompts" from positive_count; a
  plain cursor page keeps the first page's count and an unknown count
  hides the "· K positive prompts" part. The date span shows
  "<Today|Yesterday|weekday> · <Month D>" of the newest loaded result
  under a date sort and is hidden otherwise (D17).
- Continuation: "N of M images · more load as you scroll" with Load
  more, the concept's end copy, Retry for a failed page, and the
  concept's "K more images loaded. N of M." toast.
- The listing sends category, tags and sort=rating (PR B params).

Old control -> new control:
- "Search filenames" field -> toolbar search "Search workspace"
  (placeholder "Search prompts, tags, filenames…"), also matching
  prompt text and tags
- Source select -> Filters -> "Refine this view" Source
- Folder select -> Refine Folder (all sources' folders and ancestors)
- Media select -> Refine Media
- (new) Tags -> Refine Tags; Category -> toolbar category select
- Sort select (Newest/Oldest/Filename A–Z/Z–A) -> toolbar sort
  (Recently added/Oldest first/Name A–Z/Name Z–A, plus Highest rated)
- Layout buttons -> .segmented Grid/List/Masonry in the filter row
- Thumbnail size field -> label.small + select#image-size
- "Group by positive prompt" link -> button.chip "Group by positive
  prompt →" (#/library)
- Refresh -> Import -> "Scan an output folder" -> Rescan (D15)
- "Current gallery" link -> removed; Overview's tool cards link the
  legacy gallery (D29)
- "All images" h2 + count -> .section-caption #result-count
- .image-continuation -> .infinite-continuation (Retry kept)

Legacy behavior kept: video items, keyboard grid navigation, scroll and
filter restoration, bounded DOM, continuation retry, source-change
recovery (now disabling the toolbar, Filters and Import). The Filters
and Import dialogs close on route change and browser Back.

The shared toolbar (C1) called .some() on select.options, which is an
HTMLOptionsCollection in a browser (only the unit tests' fake DOM gave
an array); setValue and setCategories now spread it first. The layout
segmented control may wrap at extreme zoom (D31) instead of scrolling
the 390px page sideways.

The retired .image-filters, .image-view-controls, .image-toolbar,
.image-layouts, .image-collection, .image-continuation and duplicated
.image-workspace grid rules are gone from images.css.

* test(workspace): model select.options as a browser collection

The toolbar units' fake DOM returned select.options as an Array, so the
toolbar's .some() call passed there while it threw in every browser
(fixed in 12228e2). options is now an indexed, iterable, frozen
HTMLOptionsCollection-like object without Array methods; against the C1
toolbar three units fail with "sort.options.some is not a function".
The units also assert that an unknown sort falls back to the first
option and that a still-listed category survives a category refresh.

* fix(images): concept Load more focus, empty state and review rulings

Review of #249 C2:
- Load more and Retry focus the first appended image, as the concept
  does (app.js loadMoreImages). Focus moves without scrolling so the
  visible cards keep their place (bounded-DOM anchor), and only while
  focus is still on the pressed control: a viewer or a field the user
  moved to keeps it. When a last page adds nothing and the button hides,
  focus goes to the last image, never to the page; a failed page focuses
  Retry.
- The concept's empty state beside the grid: .empty "No matching
  images. Try another search or category."
- D17: the caption date shows only under Newest first (", YYYY" outside
  the current year) and is hidden under Oldest first and the name and
  rating sorts.
- en-US number grouping in the embedded count too.
- Refine "Folder" defaults to the concept's "All output folders". It is
  not narrowed by Source: the concept has no Source field, and the list
  holds every source's folders, so any pair can be chosen in one dialog.
- The shared toast moves to toast.mjs; library-editor re-exports it.
- The D32 overlay (extreme zoom) wraps the layout segmented control and
  the continuation row instead of pushing them out of the 390px column.
- The legacy gallery checks moved to Overview's "Open existing image
  gallery" card (D29).

Mapping additions: grid with no results -> .empty concept copy beside
the grid; Load more press -> focus the first new image (concept).

* feat(images): concept image cards; image opens the viewer

Cards are the concept media card: .media-art > button.image-open with the
image or video, and .media-info with the D10 title (linked title only when
the link is confirmed, else the filename; title attribute = path), the D11
subtitle "W x H · EXT · Seed N" (seed shown as the listing's string) and an
Inspect info button. The byte size and the Preview button are gone.

Activating the image (click, Enter, Space) opens the viewer and focus
returns to it on Escape; Info selects the image into the panel (D19).
Arrow, Home and End navigation, bounded rendering and scroll restoration
are kept.

Columns and gap now come from the concept grid's computed style
(gridMetrics) instead of a production breakpoint table, so the concept
breakpoints apply; a 120px minimum card bounds extreme zoom (D3, D31).
images.css card rules are retired into marked overlay rules. The scope
chip reads "All output folders" when no folder is chosen (D14).

* fix(images): pair-linked cards take the positive's title; concept Info, alt and size

- Pair links title the card, inspector and viewer from the linked positive's
  Library title or confirmed first line, never the pair title (the negative
  variant's name). The card now equals the concept card, title included.
- Info drops aria-pressed; the card's .selected carries selection, as in the
  concept.
- Card images take the card title as alt and the listing's width/height.
- One shared minimum card width for layout and the narrow-card class.
- A debounced search that lands after a sort change no longer reloads page 1.
- 200% zoom test also runs at an 800px viewport and device scale 2.

Refs #249

* feat(images): concept hover zoom

* feat(images): favorite images from the card

* feat(images): concept viewer shell

* fix(images): one favorite request per file across leaving and returning

The pending set and the receiver of each answer now live at module level
beside the saved visit. A press on a returned Images page waits for the
request the earlier page sent, and its success or rollback reaches the
card on screen, or the saved visit when no Images page is mounted.

* fix(images): mark a star busy while its favorite request is pending

The star carries aria-disabled="true" until the answer arrives, also on a
card rendered after returning to Images, so the ignored second press is
announced as unavailable instead of doing nothing silently.

* perf(images): repaint one card on a favorite toggle instead of the collection

stream.updateItem tells onChange which item it patched, and the Images
page then skips the full layout pass (computed style, grid metrics and
layout over every loaded item) because no card changed size or place.

* docs(images): say the lone-star overlay rule is interim until the checkbox PR

* test(images): concept comparison for the Images page

images-concept.spec.mjs compares the production Images page with the
immutable concept on the concept-shaped fixture: heading, toolbar, filter
row, caption, grid, the first six cards, continuation and panel box in
Grid, List and Masonry, at 1600x1100 and 390x844, dark and light, normal
and reduced motion; plus hover zoom, the viewer title, focus restore,
Info selection, search and Load more with its toast. mapImagesConcept
applies only recorded deviations (D3, D8 and D16 follow-up PRs, D9, D10,
D14, D15, D17, D29, D31, D32); removed row items flow again from the
concept's own boxes. A mutation check proves the comparison fails on a
.media-info padding change and on removed Inspect buttons.

Corrections the comparison demonstrated:
- the grid and continuation carry the concept's ids (image-grid,
  gallery-continuation);
- bounded rendering reserves the card footer at its fractional height:
  offsetHeight rounded a wrapped 67.5px row up, so at 390px every card
  was a pixel taller and the grid 6px taller than the concept.

Two mismatches wait for a ruling and are pinned on both sides: the
category order (concept prompt order, production alphabetical) and the
singular count line ("1 positive prompt").

* fix(images): ignore sub-pixel noise when the measured card footer grows

Under page zoom the fractional footer height (rect / scale) can carry float
noise above the stored value, which relaid the grid and scrolled for nothing.

* test(images): map D35-D37 and widen the Images concept comparison

- D35 (singular counts) and D36 (alphabetical categories) are mapped
  deviations instead of pinned values; a search for exactly one image
  checks both singular nouns, and a one-image Load more checks the toast
  and continuation.
- Grid, cards and panel at 1200x900 (2 columns, 275px panel) and
  1800x1100 (4 columns); Large and Compact join the layout loop.
- Masonry tops and heights compare unrounded within one layout unit per
  card above in the column (D3), not as rounded integers.
- The Save view and sample-tag re-layout is checked against the concept
  page with those items removed at runtime.
- The Load more row is compared before the press in both documents (D37).
- Two seedless fixture images (seedless_images on the concept reset)
  prove every row reserves the tallest footer (D3).
- D10 paths come from the fixture manifest; the D8 star margin is mapped;
  list-row derivations cite D3 and D9.

* fix(images): search never matches a pair's own variant title

Search covers the filename, the linked positive's text and Library title,
and its tags (#249 D12). The pair title names a negative variant, so a
variant name alone no longer admits the positive's images.

* fix(viewer): keep the Media controls group and drop a stale position description

The concept viewer toolbar is again a named group (#249 D34 #8), and
Previous/Next are described by the position line only while a single
item is loaded.

* docs(history): drop the remaining-polish notes the concept viewer and cards closed

The viewer now takes the concept stage and toolbar, and Images cards open
the viewer, select into the panel and favorite. Only the select checkbox
(#249 D8 follow-up) and the tag button (#249 D9) stay outside #209.

* test(images): the singular copy when scrolling loads one image; countCopy in the shared inventory helper

* refactor(images): retire the replaced images.css rules and lint the kept page sheets

The unused .image-facts rules go. The three prompt-block overrides on
concept selectors are marked #249 D22 interim until PR D deletes the
file, and every kept page stylesheet now passes the overlay lint.

* test(images): the extreme-zoom anchor probe reads the fractional footer measurement

Since the concept comparison (#249 C7) the grid measures footers with
getBoundingClientRect, so the probe that hooked offsetHeight never fired.

* test(images): cover the masonry fallback when a finite size underflows its ratio

* docs(images): concept Images page and recorded deviations

TESTING.md documents the Images page controls, the recorded #249
deviations and how the earlier workspace filters moved, and notes that
every kept page stylesheet is linted. The frontend coverage baseline
adds the toast, toolbar and workspace dialog modules.

* fix(images): show that Import → Rescan runs, and how it ended

The old Refresh button disabled itself while /gallery/rescan ran; Import →
Rescan closed its dialog and showed nothing. Now a toast says the rescan
started ("Rescanning output folders…") and how it ended ("Rescan finished.
N files in M output folders.", from the scan's own total and roots, or
"Rescan could not finish. Try again."). A reopened Import dialog shows
Rescan as "Rescanning…" and aria-disabled, and ignores a press on it.

Failing before: the held-rescan browser test saw no toast and no busy
state; the dialog unit test found no "Rescanning…" button.

* fix(images): rewrite the live status only when its text changes

A star press republishes the stream, and the textContent setter replaced
the continuation status, the count and the caption date with the same
text, so a polite role="status" region could announce it again. Text and
the role attribute are now written only when they change.

Failing before: a MutationObserver saw 10 records on those nodes after a
favorite toggle; now none.

* fix(images): Refine's Folder list follows the chosen Source

The workspace subfolder discovery returned only the union of every
source's folders, so "Source: temp" could be paired with a folder that
exists only under output and silently match nothing. In workspace mode
each root now lists its own folders, and "Refine this view" offers only
the chosen Source's folders: changing Source keeps a folder it also has
and otherwise returns Folder to "All output folders", as the old field
row did when it reloaded a Source's folders.

Failing before: the API test found no per-root folders (KeyError), the
dialog unit test and the browser test still offered output-only folders
under the input source.

* fix(images): an unchanged Refine apply keeps the loaded page

"Apply filters" (and "Clear filters" with nothing set) reset the stream,
refetched the first page and dropped the reader's scroll position even
when no filter changed. Like the toolbar's equality guard, an unchanged
refinement now does nothing.

Failing before: the browser test counted one listing fetch after an
unchanged Apply; now none, and the loaded cards stay in place.

* fix(images): one discovery alert when sources and categories both fail

With the server unreachable on load, the sources and the categories
status paragraphs each fired role="alert" back to back. The page now
keeps one alert, written once both first loads settled, naming whatever
failed ("Sources, folders and categories could not be loaded. Rescan
from Import to retry." when both did).

Failing before: getByRole('alert') resolved to two elements.

* fix(images): sort toolbar categories alphabetically on the client

D36 promises alphabetical real categories, which held only while the
server sorted them and a restored category was among its list. The page
now sorts them case-insensitively itself, a restored category included.

Failing before: the toolbar listed a server's [zeta, Alpha, beta] in
that order.

* refactor(db): drop the unused pair title from the link summary query

_link_summary has not read the pair's own title since the card took the
linked positive's Library title (D10), so the query no longer selects
it; the row indexes and the stale comment are updated. All values stay
bound.

Failing before: the summary SQL still selected pp.title.

* test(images): end negative checks on real signals, not fixed sleeps

The "no second request" checks waited a fixed 150 ms or 1000 ms and the
hover probe 450 ms, which can pass vacuously on a slow Firefox or WebKit
run. They now read the fetches the page's code started (the API client
calls fetch in the same task as the press), wait for a timer of the
toolbar's own debounce delay set after the debounced report, and wait
for the hover transition to finish.

Checked by mutation: without the favorite pending guard or the toolbar
equality guard, the rewritten checks fail.

* docs(testing): record the Rescan feedback, Source-bound folders and category sort

The Images contract now names the Rescan toasts and busy state (D15),
the Folder list that follows Source and the no-op unchanged refinement
(D14), and the page-side category sort (D36).

* test(viewer): escape every regex metacharacter in the filename match

* fix(viewer): title a linked card at once instead of waiting for its inspection

On a slow runner the viewer showed only the filename until the inspection
answered, while the concept titles it immediately. The card's linked
title is already loaded, so the header uses it on open.

The favorite test now waits for the first request to settle before the
Space press, which the one-request-per-file guard otherwise ignores.

* fix(images): cap the search at the listing API's 500 characters

A longer paste was sent as is, the API rejected it and the collection
showed "Images could not be loaded". The legacy search capped it.
2026-10-20 11:31:53 -07:00
Vito 6c82098207 feat(images): data for the concept cards — linked titles, seed, search and filters, counts, favorites (#257)
* feat(images): link summary on workspace image items

* fix(images): carry title source and cut first-line titles like the Library

Summaries gain title_source (stored or text). The inspector always shows a
stored title and confirms only a first-line title against the file, so a
stored title equal to the first line is no longer withheld. A link-title read
failure no longer marks a readable link unavailable. First-line titles over
80 characters end with an ellipsis, as the Library's title() does. The card
gate for unconfirmed first-line titles is pinned as an expected failure for B2.

* feat(images): unambiguous seed on workspace items

Workspace items gain `seed`, read with the shared generation-parameter
parser from the prompt/workflow records the header open already loaded
(each at most 2 MiB; no extra file reads, videos give None). A
placeholder or non-integer seed gives None. JSON integers beyond 2**53
are recovered exactly, so the card prints the digits the inspector shows.

The same parse confirms first-line card titles: a `text` title shows only
when the file's parsed positive gives the same title. One helper applies
that rule on the card and in the inspector, which now reads the positive
from its own inspection records with the same parser.

* fix(images): a deeply nested record no longer fails the workspace listing

The Python parser recurses over nested arrays where JavaScript String()
does not, so a crafted 2 KB PNG turned every workspace page holding it
(and its inspect route) into a 500. Card facts now treat RecursionError
like any record the parser cannot use: no seed and no confirmed title.

Also: Library previews skip the card parse they never show; the parse
runs after the file handle closes; the exact-seed read uses the one JSON
reader (parse_js_json(..., exact_integers=True)) and the public
number_string; record size is decided from the text length unless only
the UTF-8 byte count can tell.

* feat(images): search and filter workspace images by linked prompts

The workspace listing now takes category (exact), tags (comma list, all
required) and sort=rating, and q also matches the linked positive's text,
Library title, pair title and tag names. One bounded, parameterized SELECT
(get_image_link_matches / get_image_paths_matching) returns normalized
paths of unambiguously linked images; the listing is intersected with it.
Unlinked and ambiguous files are excluded by a category or tag filter and
sort last by rating, with a newest-first tie-break. The cursor scope covers
the new filters and sort; unknown values are still rejected.

The derived listing values and sort orders are cached per gallery scan so a
50,000-file listing with 20,000 links pages well under 150 ms at p95.

* test(images): count the filter budget everywhere, time it in one job

The 150 ms wall-clock budget failed under the CI coverage tracer, so it
is split. test_filter_work_is_bounded runs on every job and under
coverage: one filter SELECT per request, the ambiguity probe is an index
search (EXPLAIN QUERY PLAN), the per-scan values are built on the cold
request only, and a cursor page calls the sort key at most
ceil(log2 n)+3 times. test_filter_budget runs only with
PROMPTMANAGER_PERF_BUDGET=1 and no tracer, asserts the best of three
rounds' p95, and has its own non-required "perf budget" job. TESTING.md
records the convention.

* fix(images): rating sort works however many link rows the database holds

The filter SELECT counted every generated_images row against a 500,000
row cap and materialized all of them first, so a large history made
sort=rating answer 400 with nothing to narrow. The rows are now streamed
and only paths in the listing's normalized set are kept, so memory is
bounded by the listing; the cap and LinkFilterTooBroad are gone.

* fix(images): rebuild workspace listing values only where the scan changed

A TTL rescan that finds the same files reuses the previous values and
sort orders; one that finds a few changes rebuilds only those files.
Gallery invalidation and a gallery-roots change drop the derived values,
so old roots no longer hold memory. category is trimmed like tags.

* fix(images): a monitor invalidation rebuilds only the new files' values

The image monitor invalidates the gallery whenever it sees a new file,
and that dropped every derived workspace value, so the next request paid
a full cold build. Invalidation now keeps the per-file values and drops
only the sort orders; the rescan reuses every unchanged file and builds
only new or changed ones. A gallery-roots change still drops everything.

* feat(library): expose run count, last use and variant totals

* fix(library): an out-of-range last use reads as unknown, not a failed list

* perf(images): read positive counts only on a plain listing's first page

* feat(images): persist image favorites

Favorites are stored in image_favorites keyed by the gallery root label
and the relative path, set and cleared through PUT/DELETE
/prompt_manager/images/favorite with a {root_index, path} body validated
against the rooted listing. Workspace image cards carry favorite: bool.
The table is created by migration, travels with backups, is optional in
old backups and required after a restore's migration.

* fix(images): key favorites by the real root and case-normalized path

Folder names are not unique across gallery roots, so two roots shown as
"output" shared each other's stars. Favorites are now keyed by the
root's real, case-normalized path (server-side only) and a
case-normalized relative path, so a Windows case-only rename keeps its
star while distinct Linux files stay distinct. Toggles look the file up
in a per-scan dictionary instead of walking the listing.

* fix(images): favorites lookup follows monitor invalidation and the stored key bound

A monitor invalidation now also drops the favorites lookup, which held the
previous scan list until the next toggle. PUT refuses a favorite whose root
or relative key would exceed the schema's 4096-character bound, so the live
database keeps passing startup and restore validation.

* docs(testing): workspace data fields for the concept pages

* fix(images): card facts read the inspector's projected graphs

The inspector hands its browser parser only graphs the inspection
projection parsed (strict JSON, depth 64, node ceiling); a deeper record
is "limited" there, so the legacy fill never runs and no seed shows. The
card parsed the raw records instead and showed a seed the inspector did
not, e.g. for a 900-deep class_type beside a normal sampler.

project_graph() is now the one projection step, used by the inspection
response and by the card. The card runs the shared parser on the
projected graphs, the same values the browser receives: big integers
arrive as their exact digits, so the separate exact-integer re-parse and
the parse_js_json flag it needed are gone.

* fix(images): send the card seed as a decimal string

JSON.parse rounds a number past 2**53, so a listing seed such as
9223372036854788153 reached the page as 9223372036854788000 while the
inspector showed the file's digits. Every non-null card seed is now its
decimal string.

The Node parity harness now JSON.parses the real listing response and
compares String(seed) with the seed the inspector shows, including a
seed past 2**53. TESTING.md describes the string contract and drops the
removed exact re-read.

* fix(images): bound digit-string seeds and isolate each card's parse

A seed recorded as a 5000-digit string reached int(), which raises
ValueError past Python's integer string limit, and the listing answered
500. A digit-string seed now has at most 20 digits (2**64 fits), and the
conversion falls back to no seed on ValueError.

Each card's parse is also guarded in the page builder: an unexpected
error in one file's records logs a warning and gives that card no seed
and no first-line title, and the rest of the page still lists.

* fix(images): filename search survives a failed prompt-link read

On main a workspace q matched only file paths, so search worked with a
broken database. Since search also matches linked prompts, any q turned
a failed link read into a 500. A q without category, tags or the rating
sort now falls back to filename matches with a null positive_count and
logs the failure with its traceback; those linked filters still fail.

* fix(images): card reads PNG records with the inspector's reader

The card read PNG text through Pillow's header info, where a later
duplicate prompt chunk silently wins and text after IDAT is missed. The
inspector's bounded reader refuses conflicting duplicates and reads text
after IDAT, so a file with two different prompt chunks showed a card
seed the inspector refused.

PNG cards now read their records with read_inspection_file, the reader
the inspector uses. When it refuses a file the card keeps its
dimensions from Pillow and shows no seed. Other formats still use
Pillow's header info, as the inspector's header read does.

* perf(images): read each card's header facts once per gallery scan

Every page parsed each card's prompt and workflow records again, about
3.4 ms per 50 KB record set and 25 ms per 400 KB workflow, inside
ComfyUI's server process, on every scroll-back and refresh.

Dimensions, the seed and the parsed positive's display title are now
cached per root by (path, mtime), beside the scan's per-file values and
invalidated the same way: a rescan keeps unchanged files and prunes the
rest, a roots change drops them all, and only files of the current scan
are stored. An unreadable header is not stored. Links and favorites
are still read on every page.

* ci: run the perf budget on main pushes and on demand only

The wall-clock budget has under 2x margin on shared ubuntu runners, and
a red exact-head check blocks a merge that must never be rerun to green.
The job now runs on each push to main and on workflow_dispatch, not on
pull requests. TESTING.md says a red run on main means fix the code.

* fix(library): cut display titles at 80 UTF-16 units, never inside a pair

The server counted code points while the Library's JavaScript counts UTF-16
units, so a first line of emoji kept 80 emoji on cards but showed 40 in the
Library. Both now cut at 80 UTF-16 units and drop a character that would
straddle the limit instead of leaving half a surrogate pair.
2026-10-20 00:18:32 -07:00
Vito 9d398f9862 feat(workspace): concept shell — sidebar, topbar, heading and footer on the approved mock's CSS (#256)
* build(workspace): vendor the approved concept stylesheet with a drift check

* refactor(workspace): adopt the concept stylesheet as the workspace base

* feat(workspace): concept sidebar with real navigation counts

* feat(workspace): concept topbar with Theme and ComfyUI link

* refactor(workspace): one concept heading and footer for every route

* fix(workspace): keep the canvas link on the proxy mount

A resolver failure no longer stops the shell from mounting: the ComfyUI link falls back to the root read from the page's own mount path (never another origin). Tests cover a non-standard /ComfyBackendDirect mount, including that no request escapes it.

* test(workspace): concept fixture and shell comparison

* test(workspace): concept fixture carries the concept inspector facts

* fix(workspace): match the concept brand name and compare shell hover states

* test(workspace): shell comparison mutation check and docs

Add mutate() to the shared concept inventory and a shell mutation check: a
nav-row min-height rule and a removed .breadcrumbs each make the comparison
fail with the changed path. Harden the resolver-failure guard test with an
injection count, make the overlay lint fail when workspace.css is missing,
mark the library.css .row-actions leftovers for PR E, and document the
concept CSS base, the overlay lint, the comparison method and the recorded
shell deviations in TESTING.md.

* fix(workspace): focus the page title on a route change

A sidebar or history navigation now focuses #page-title, as the concept
does on every hashchange (app.js 1168), so a screen reader announces the
page heading instead of the main region. The first render leaves focus
alone and the skip link still focuses main. The heading module's comment
now describes what the shell does.

* fix(workspace): give the Overview nav entry its own home glyph

Overview reused the concept's Activity pulse byte for byte, so once the
Activity row lands two entries would share one icon. Overview now has an
outline home glyph. The shell comparison takes it from the D28 ruling
instead of the concept's activity path, and the icon test checks that
every approved glyph is distinct.

* test(workspace): accept only #249 decision markers and state the lint scope

The overlay lint accepted any D-number, so another issue's ruling such
as "D5 (#245)" could mark a concept override. A marker now has to be a
D-number with no other issue cited; the vocabulary rule that cited #245
reads "#245 decision 5". TESTING.md now states that the lint covers
workspace.css only, lists the unmarked overrides still in images.css,
and says PRs C and D run the lint over each page sheet they keep.

* perf(workspace): re-read only Library and Tags counts after a Library write

A confirmed Library or vocabulary write cannot change the Images count,
but it re-read all three, including the Images output listing. It now
re-reads only the Library and Tags counts. Each count has its own read,
so a write never cancels an Images read that is still in flight.
2026-10-19 23:04:40 -07:00
71 changed files with 9291 additions and 822 deletions
+27
View File
@@ -8,6 +8,7 @@ on:
branches: [main]
push:
branches: [main]
workflow_dispatch:
# Superseded PR pushes cancel their older run. Every main push gets its own
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
@@ -146,6 +147,32 @@ jobs:
python -m coverage combine
python -m coverage report --fail-under=85
perf-budget:
# The reference wall-clock measurement (TESTING.md "Performance budgets").
# Shared-runner timing varies, so it never gates a pull request: it runs on
# each push to main and on demand. A red run on main means fix the code.
name: perf budget
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Workspace filter budget
env:
PROMPTMANAGER_PERF_BUDGET: "1"
run: python -m unittest -v tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget
lint:
name: black and flake8
runs-on: ubuntu-latest
+6 -1
View File
@@ -26,7 +26,12 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 25
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
+337 -10
View File
@@ -125,6 +125,37 @@ not certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Performance budgets
A spec performance budget gets two tests:
- A structural test that always runs, on every OS/Python job and under the
coverage tracer. It asserts counts, never time: SELECTs per request, the
`EXPLAIN QUERY PLAN` of the hot query, cache builds on cold and warm requests,
and calls of the sort key per cursor page. Example:
`test_filter_work_is_bounded` in `tests/test_api_workspace_images.py`.
- A wall-clock test that runs only with `PROMPTMANAGER_PERF_BUDGET=1` and
without a tracer (`sys.gettrace()` or a `sys.monitoring` tool). It skips
otherwise. It warms up once, then measures three rounds and asserts the best
round's p95; the failure message lists every timing. Example:
`test_filter_budget` (Images filters, spec D12: p95 < 150 ms at 50,000 files
and 20,000 links).
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
requests, because shared-runner timing must not block a merge. Dispatch it on a
branch to measure before merging.
Shared Windows and macOS runners do not run them; the structural tests cover
portability. Run it locally with
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
and quote the printed p95 in the PR body.
A red `perf budget` run on `main` means the code got slower: fix the code (or
change the budget in the spec with a reason) in a follow-up PR. Never rerun it to
green. It is not a required check, and it cannot be one while it skips pull
requests (`.github/branch-protection.json`).
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -196,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
@@ -328,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
@@ -392,6 +430,295 @@ 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 "<title> · <filename>", 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 (the selection bar's real actions only),
D16 (follow-up PR: 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 "<source> · <folder>"), 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`): "<title> · <filename>", 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**: every Images card has the concept's select checkbox before the star; the
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
category, Export metadata, Clear). Add tags and Add to collection wait for image tags
and collections; "Remove links…" is dropped. The selection survives Load more and
bounded re-mounting and is cleared whenever the collection is replaced (search,
category, sort, Filters, source change, rescan, reload). A return to Images restores
the saved collection unreplaced and keeps its selection; a full page reload starts
empty. The Library-embedded gallery has no checkbox and no bar. Export metadata reads
each checked image's inspection (at most 4 at a time) and downloads one
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
inspector's own "Download metadata JSON" envelopes in listing order); the button is
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
the toast names the unread images (none read: no download); leaving Images aborts the
reads with no download and no toast. Set category opens the
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
the linked positive's). With no linked image checked it posts nothing and opens no
dialog; an empty value keeps the dialog open; the field stops at the server's 100
characters; success reloads the categories and the listing together (clearing the
selection, focus to the heading, and, as any replaced collection does, the inspector
moves to the first listed image); failure keeps the selection. The write carries no
page signal: leaving Images while it runs lets it finish without a toast, and the return
loads the collection afresh instead of the saved visit. Three deviations from the
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
nothing-linked check runs on the Set category press, so no dialog opens. The route
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
- **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 "<title> · <filename>"; 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
`mode=workspace`) and the legacy pages do not change, and existing workspace pages
ignore the new keys.
Workspace image items (`/prompt_manager/images/output?mode=workspace`, and Library
image galleries) gain three card facts. Library list previews carry none of them
and skip the parse.
- `linked: {status, positive_id, title, title_source, category, rating}`. One
`get_image_link_summaries` SELECT per 200 paths, matched exactly as the
inspection route matches (exact or normalized `file_path`, no basename
fallback). `status` is `linked`, `ambiguous` (several owners), `none`, or
`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 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
shows. A `text` title shows only when the file's own positive, parsed from its
records by the shared parser, gives the same display title. Otherwise the title
is null on the card and in the inspector header. Videos and unreadable files
have no parsed positive, so their `text` titles are withheld.
- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds
numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is
`extract_generation_params` from `utils/generation_params.py` (the inspector's
legacy parser, #251), run on the `prompt`/`workflow` records read with the
dimensions. A PNG is read by the inspector's own reader
(`utils.prompt_inspector.read_inspection_file`): text after IDAT counts, the
shared byte budgets apply, and a file with conflicting duplicate `prompt` or
`workflow` chunks gets no card seed, as the inspector refuses it (the card
keeps its dimensions). Other formats use Pillow's header `info`, as the
inspector's header read does. The records go through the inspection
projection first (`utils/inspection_response.project_graph`), which carries
integers past 2**53 as their exact digits, so the card and the inspector read
the same digits. The parse runs after the file handle closes. A record over
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
non-integral, boolean or non-finite seed gives null. A card never shows a seed
that differs from the inspector's
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
the listing in Node and compares `String(seed)` with the inspector's seed,
including one past 2**53). The card shows no seed for A1111 `parameters` files
or for a BasicGuider custom sampler, where the inspector does.
- Header facts are cached. Dimensions, `seed` and the parsed positive's display
title are read once per `(path, mtime)` of the current gallery scan and kept
beside the scan's per-file values. A monitor invalidation or TTL rescan keeps
the facts of unchanged files and drops the rest, and a roots change drops them
all. A header that could not be read is not stored. Links and favorites are
never cached (`test_warm_page_reuses_card_facts_per_scan`: a warm page reads
and parses no header). Measured cold cost is about 3.4 ms per 50 KB record set
and 25 ms per 400 KB workflow.
- `favorite: bool`. One favorites SELECT per root per page. A read failure logs a
warning and shows false.
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 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
positive.
- Ambiguous files never match a linked filter, and they rank as unlinked.
- `sort` is `newest | oldest | name | name-desc | rating`. `rating` orders by
rating 5 to 1, then linked but unrated, then unlinked and ambiguous; ties break
newest first. A signed cursor covers every filter and the sort, so reusing it
with a change is 400.
- One link read (`get_image_link_matches`, streamed and bounded to the listed
paths) serves the filters, the rating sort and the count.
`positive_count` is the number of distinct linked positives over the full matching
set, not the page:
- Present on every first page, and on every page of a filtered (`q`, `category`,
`tags`) or `sort=rating` listing.
- Absent on a plain listing's cursor pages, which read no links. The frontend
keeps the first page's value.
- Null when a plain first page's link read failed. The listing still returns 200,
and consumers then hide "· K positive prompts". A `q` search alone also
survives the failure: it matches filenames only, as before linked search, and
every page's count is null (logged with the traceback). A failure under
`category`, `tags` or the rating sort stays a safe 500.
Library parents (list, detail and command receipts) gain `run_count` (an int; the
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
string. A naive value reads as UTC, and an unreadable or out-of-range value is
null. The edit token ignores both. `variant_total` has two meanings. On the list
response it counts the active variants over all filtered parents (the same
snapshot as `total`, the same on every cursor page). On the detail response it
counts one positive's variants in the chosen archive scope. The Library caption
reads it from the list state.
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
`{root_index, path}`. Both are idempotent and answer
`{success, favorite, root_index, path}`. The boundary returns 400 for an unknown
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` (`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 `/`
separators. It is stored server-side only and never returned. Folder labels are
not unique across roots, so they are not used as keys.
- `relative_path` is `normcase(path)` with `/`. On Windows a case-only rename
keeps the star. On POSIX the key is the exact spelling, so `A.png` and `a.png`
stay separate (tests swap in `ntpath` through `py.api.images._path_flavor`).
Moving the install or changing a root's real path orphans its rows, which are
harmless and never shown.
- Schema validation (`image_favorites_schema_error`) runs at startup and on
restore. Old backups without the table restore, and backup plus restore keeps
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
page makes 1 link SELECT and a plain cursor page none, that every filter shape
makes exactly 1, the covering-index plan, and that the per-file values are built
once per scan and only for new files after a monitor invalidation. The opt-in
`test_filter_budget` (`PROMPTMANAGER_PERF_BUDGET=1`, the `perf budget` job) times
the D12 p95 < 150 ms budget; see "Performance budgets".
```sh
python -m unittest tests.test_api_workspace_images tests.test_card_seed \
tests.test_image_favorites tests.test_library tests.test_api_library \
tests.test_database_backup -q
```
Installing these fields needs a maintainer ComfyUI restart (Python, routes and
schema). Automated tests use disposable databases and output folders.
## Prompt sending and cross-pack compatibility
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
@@ -669,9 +996,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 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.
+139
View File
@@ -0,0 +1,139 @@
"""Persisted image favorites, keyed by gallery root + relative path.
The root key is the root's real, case-normalized path and the relative key is
case-normalized the same way (see ``py/api/images.py``); both stay
server-side. Folder names alone are not unique across roots, so a favorite
follows the directory itself when the configured roots are reordered.
Optional in old backups, required once a database is current (see
``library_schema.py``).
"""
import re
import sqlite3
from typing import Iterable
TABLE = "image_favorites"
MAX_KEY_LENGTH = 4096
_LOOKUP_CHUNK = 500
_DDL = (
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
)
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
_COLUMNS = (
("root_key", "TEXT", 1, 1),
("relative_path", "TEXT", 1, 2),
("created_at", "TEXT", 1, 0),
)
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def image_favorites_schema_error(
conn: sqlite3.Connection, *, allow_missing: bool = True
) -> str | None:
"""Reject unsupported favorites schemas and invalid persisted values.
Reads only. A missing table is acceptable for backups that predate it
unless ``allow_missing`` is false.
"""
objects = conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
"OR (type IN ('index','trigger') AND tbl_name=?)",
(TABLE, TABLE),
).fetchall()
if not objects:
return None if allow_missing else "Missing image favorites schema"
tables = [sql for kind, _name, sql in objects if kind == "table"]
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
return "Image favorites name is reserved by an unsupported schema object"
if _compact(tables[0] or "") != _compact(_DDL):
return "Image favorites has an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({TABLE})")
)
if columns != _COLUMNS:
return "Image favorites has invalid columns"
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
limit = MAX_KEY_LENGTH * 4
if conn.execute(
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' "
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
f"OR length(CAST(root_key AS BLOB)) > {limit} "
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
).fetchone():
return "Image favorites contains invalid values"
for root, path in conn.execute(
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
f"WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
):
for raw in (root, path):
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
return "Image favorites contains invalid values"
return None
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it."""
conn.execute("SAVEPOINT image_favorites_schema")
try:
error = image_favorites_schema_error(conn)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = image_favorites_schema_error(conn, allow_missing=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE image_favorites_schema")
except BaseException:
conn.execute("ROLLBACK TO image_favorites_schema")
conn.execute("RELEASE image_favorites_schema")
raise
class ImageFavoriteOperations:
"""PromptDatabase mixin: set, clear and look up image favorites."""
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
from .models import utc_now_iso # models imports this module's schema
with self.model.get_connection() as conn:
conn.execute(
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
(root_key, relative_path, utc_now_iso()),
)
return True
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
"""Remove one favorite; True when a row was removed."""
with self.model.get_connection() as conn:
cursor = conn.execute(
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
(root_key, relative_path),
)
return cursor.rowcount > 0
def get_image_favorites(
self, root_key: str, relative_paths: Iterable[str]
) -> set[str]:
"""The subset of *relative_paths* favorited under *root_key*."""
wanted = list(dict.fromkeys(relative_paths))
found: set[str] = set()
conn = self.model.get_connection()
for start in range(0, len(wanted), _LOOKUP_CHUNK):
chunk = wanted[start : start + _LOOKUP_CHUNK]
marks = ",".join("?" * len(chunk))
rows = conn.execute( # only "?" placeholders are interpolated
"SELECT relative_path FROM image_favorites WHERE root_key=? "
f"AND relative_path IN ({marks})", # nosec B608
(root_key, *chunk),
)
found.update(path for (path,) in rows)
return found
+88 -7
View File
@@ -1,5 +1,6 @@
"""Read-only positive-parent views over existing prompt and pair tables."""
import datetime
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
@@ -13,10 +14,79 @@ _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
# The characters JavaScript's String.prototype.trim() removes, so a title the
# display treats as blank also falls back to the prompt text for name sorting.
_BLANK = (
"char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,"
"8200,8201,8202,8232,8233,8239,8287,12288,65279)"
)
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
# The Library's display title: a stored title, else the prompt's first line.
DISPLAY_TITLE_LIMIT = 80
def _utf16_units(text: str) -> int:
"""JavaScript ``length``: characters beyond the BMP count as two units."""
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
def _utf16_prefix(text: str, units: int) -> str:
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
used = 0
for index, ch in enumerate(text):
used += 2 if ord(ch) > 0xFFFF else 1
if used > units:
return text[:index]
return text
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
"""Return ``(title, source)``: a stored title, else the text's first line.
``source`` is ``"stored"`` for the first non-blank stored title (blank as
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
and None when both are empty. The first line is trimmed and, past
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
``libraryTitle()`` (never inside a surrogate pair).
"""
for title in stored_titles:
if isinstance(title, str) and title.strip(_JS_TRIM):
return title, "stored"
if not isinstance(text, str):
return None, None
first = text.split("\n")[0].strip(_JS_TRIM)
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
return (first, "text") if first else (None, None)
def display_title(stored_titles: tuple, text: object) -> str | None:
"""The Library display title of ``titled``, without its source."""
return titled(stored_titles, text)[0]
def utc_iso(value: object) -> str | None:
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
form) is read as UTC, and an offset is converted to UTC. One bad row must
not fail a whole Library read, so a value past the datetime range after
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
the same on both, so only hand-edited values can differ by version.
"""
if not isinstance(value, str):
return None
text = value.strip()
if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00"
try:
stamp = datetime.datetime.fromisoformat(text)
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
return stamp.astimezone(datetime.timezone.utc).isoformat()
except (ValueError, OverflowError):
return None
_NAME = (
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
"THEN ls.title ELSE p.text END)"
@@ -145,6 +215,7 @@ class LibraryOperations:
) -> list[dict]:
rows = conn.execute(
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
"p.run_count,p.last_used_at,"
+ (f"{sort_expression} AS _sort," if sort_expression else "")
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
"AND v.archived=0) AS variant_count,"
@@ -165,6 +236,9 @@ class LibraryOperations:
):
by_id[row["prompt_id"]]["tags"].append(row["name"])
for row in result:
# Usage is shown, never compared: the token hashes owner fields only.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
row["last_used_at"] = utc_iso(row["last_used_at"])
row["edit_token"] = library_edit_token(epoch, row)
return result
@@ -196,9 +270,15 @@ class LibraryOperations:
ceiling = after["ceiling"]
where += " AND p.id<=?"
params.append(ceiling)
total = conn.execute(
"SELECT COUNT(*) FROM " + _FROM + " WHERE " + where, params
).fetchone()[0]
# One SELECT counts the filtered parents and their active variants.
total, variant_total = conn.execute(
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
+ _FROM
+ " WHERE "
+ where,
params,
).fetchone()
expression, direction = _ORDERS[sort]
if after is not None:
operator = ">" if direction == "ASC" else "<"
@@ -230,6 +310,7 @@ class LibraryOperations:
"library_epoch": epoch,
"items": rows,
"total": total,
"variant_total": variant_total,
"has_more": has_more,
"next_key": next_key,
}
+10
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Optional, Set, Tuple
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
@@ -298,6 +299,7 @@ class PromptModel:
self._migrate_prompt_pairs(conn)
migrate_execution_history(conn)
migrate_library_state(conn)
migrate_image_favorites(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
@@ -1016,6 +1018,9 @@ class PromptModel:
Args:
path: File to inspect
require_library_state: Also require the tables every current
database has (Library state and image favorites), as a
migrated restore stage must; old backups may lack them.
Returns:
(ok, reason): reason is "ok" when the file is acceptable, otherwise
@@ -1070,6 +1075,11 @@ class PromptModel:
)
if library_error:
return False, library_error
favorites_error = image_favorites_schema_error(
conn, allow_missing=not require_library_state
)
if favorites_error:
return False, favorites_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
+215 -2
View File
@@ -6,11 +6,12 @@ import sqlite3
import json
import datetime
import os
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Set, Tuple
from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations
from .library import LibraryOperations, titled
from .image_favorites import ImageFavoriteOperations
from .library_commands import LibraryCommandOperations
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
@@ -179,6 +180,122 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
"""
# One SELECT reads the link summaries of at most this many paths: three bound
# 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 (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, 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
LEFT JOIN prompt_pairs pp ON pp.id = (
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
LEFT JOIN prompts pt ON pt.id = pp.positive_prompt_id
LEFT JOIN prompt_library_state pls ON pls.prompt_id = pp.positive_prompt_id
WHERE gi.image_path IN ({marks}) OR gi.file_path IN ({marks}){fallback}"""
# One image row describes a card only when no other row claims the same file
# (as in get_image_link_summaries, several rows are ambiguous). The pair scope
# and the linked positive follow _LINK_SUMMARY_SQL; category, rating, tags and
# the searchable text and titles come from that positive (spec D12/D13). Every
# writer since the 3.2.4 migration stores file_path, so it is the path key.
# The prompt-level conditions are uncorrelated IN sets SQLite builds once, and
# the CASE evaluates the per-row ambiguity probe only for admitted rows.
_LINK_FILTER_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id
FROM generated_images gi
LEFT JOIN prompt_pairs pp ON pp.id = (
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
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 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 '\\'))"""
_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})"
)
def link_filter_query(
q: str, category: Optional[str], tags: List[str], *, every_link: bool = False
) -> Tuple[str, Dict[str, Any]]:
"""The workspace filter SELECT and its named parameters (all bound).
Only code constants and enumerate indices are formatted into the SQL; see
PromptDatabase.get_image_link_matches for the semantics.
"""
params: Dict[str, Any] = {}
hit = "1"
if q:
hit = _LINK_FILTER_HIT
params["pattern"] = f"%{escape_like(q)}%"
admitted = []
if category is not None:
admitted.append("p.category = :category")
params["category"] = category
for index, tag in enumerate(tags):
admitted.append(_LINK_FILTER_TAG.format(index=index))
params[f"tag{index}"] = tag
if q and not admitted and not every_link:
admitted.append(hit)
sql = _LINK_FILTER_SQL.format(hit=hit, admit=" AND ".join(admitted) or "1")
return sql, params
_ASCII_FOLD = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
def _like_suffix(value: object, suffix: str) -> bool:
"""Python form of SQLite's ``value LIKE '%' || suffix``: ASCII-only case."""
return isinstance(value, str) and value.translate(_ASCII_FOLD).endswith(
suffix.translate(_ASCII_FOLD)
)
def _empty_link_summary(status: str) -> Dict[str, Any]:
return {
"status": status,
"positive_id": None,
"title": None,
"title_source": None,
"category": None,
"rating": None,
}
def _link_summary(row: tuple) -> Dict[str, Any]:
if row[7] is not None:
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],)
title, source = titled(titles, text)
return {
"status": "linked",
"positive_id": positive,
"title": title,
"title_source": source,
"category": category,
"rating": rating,
}
class PromptDatabase(
PairOperations,
LibraryOperations,
@@ -188,6 +305,7 @@ class PromptDatabase(
ExecutionContextOperations,
ExecutionOutputOperations,
HistoryReadOperations,
ImageFavoriteOperations,
):
"""Database operations class for managing prompts."""
@@ -2160,6 +2278,101 @@ class PromptDatabase(
"parameters": row[8] if len(row) > 8 else None,
}
def get_image_link_summaries(
self, paths: List[str], allow_filename_fallback: bool = True
) -> Dict[str, Dict[str, Any]]:
"""Summarize each path's linked saved positive for an image card.
Matching equals get_image_prompt_info: exact image_path or normalized
file_path rows win; otherwise, with the fallback, rows whose image_path
ends with the basename. One match is linked, several are ambiguous.
One SELECT reads up to LINK_SUMMARY_BATCH paths.
"""
unique = list(dict.fromkeys(paths))
summaries: Dict[str, Dict[str, Any]] = {}
for start in range(0, len(unique), LINK_SUMMARY_BATCH):
batch = unique[start : start + LINK_SUMMARY_BATCH]
summaries.update(self._link_summary_batch(batch, allow_filename_fallback))
return summaries
def _link_summary_batch(
self, paths: List[str], allow_filename_fallback: bool
) -> Dict[str, Dict[str, Any]]:
normalized = {path: normalize_image_path(path) for path in paths}
basenames = {path: os.path.basename(path) for path in paths}
marks = ",".join("?" for _ in paths)
params: List[Any] = [*paths, *normalized.values()]
fallback = ""
if allow_filename_fallback:
fallback = "".join(" OR gi.image_path LIKE ? ESCAPE '\\'" for _ in paths)
params.extend(f"%{escape_like(basenames[path])}" for path in paths)
sql = _LINK_SUMMARY_SQL.format(marks=marks, fallback=fallback)
with self.model.get_connection() as conn:
rows = conn.execute(sql, params).fetchall()
summaries = {}
for path in paths:
exact = [
row for row in rows if row[0] == path or row[1] == normalized[path]
]
matches = exact or (
[row for row in rows if _like_suffix(row[0], basenames[path])]
if allow_filename_fallback
else []
)
if len(matches) == 1:
summaries[path] = _link_summary(matches[0])
else:
summaries[path] = _empty_link_summary(
"ambiguous" if matches else "none"
)
return summaries
def get_image_link_matches(
self,
q: str,
category: Optional[str],
tags: List[str],
*,
every_link: bool = False,
wanted: Optional[Set[str]] = None,
) -> Dict[str, Tuple[bool, Optional[int], int]]:
"""Map normalized paths of unambiguously linked images to
(hit, rating, positive id).
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; 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
are streamed; with *wanted* (the listing's normalized paths) only those
paths are kept, so memory is bounded by the listing, not the database.
"""
sql, params = link_filter_query(q, category, tags, every_link=every_link)
matches: Dict[str, Tuple[bool, Optional[int], int]] = {}
with self.model.get_connection() as conn:
for path, rating, matched, positive in conn.execute(sql, params):
if wanted is None or path in wanted:
matches[path] = (bool(matched), rating, positive)
return matches
def get_image_paths_matching(
self, q: str, category: Optional[str], tags: List[str]
) -> Set[str]:
"""Normalized paths whose linked positive matches *q*, *category* and *tags*.
See get_image_link_matches; an empty *q* matches every linked image
the category and tags admit.
"""
return {
path
for path, (hit, _rating, _positive) in self.get_image_link_matches(
q, category, tags
).items()
if hit or not q
}
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
"""Return the prompt_id linked to an image, or None."""
with self.model.get_connection() as conn:
+24 -1
View File
@@ -283,6 +283,16 @@ class PromptManagerAPI(
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
self._gallery_cache_ttl = 30 # Cache TTL in seconds
# Workspace listing values and sort orders derived from a cached scan
# (py/api/images.py _workspace_listed/_workspace_orders).
self._workspace_listed_cache = {}
self._workspace_order_cache = {}
# Card facts (dimensions, seed, positive title) per root and
# (path, mtime), kept and pruned with the per-file values
# (py/api/images.py _card_facts).
self._workspace_card_cache = {}
# Favorite keys of each root's cached scan (images._favorite_listing).
self._favorite_listing_cache = {}
# Single-flight locks for long jobs, keyed by job family; see
# _acquire_long_job(). Created lazily so no lock binds to an event
# loop before the server's loop exists.
@@ -348,9 +358,22 @@ class PromptManagerAPI(
def invalidate_gallery_cache(self):
"""Invalidate the cached gallery file listing.
Called by the image monitor when new files are detected.
Called by the image monitor when new files are detected. The workspace
keeps its per-file values and card facts, so the next request rebuilds
only new or changed files (see ImageRoutesMixin._workspace_listed and
_card_facts); its sort orders
are dropped, as the rescan replaces them anyway, and so is the
favorites lookup, which holds the old scan list.
"""
self._gallery_cache = {}
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache()
self._workspace_listed_cache = {}
self._workspace_card_cache = {}
def add_routes(self, routes):
"""Register all API routes with the ComfyUI server.
+1 -1
View File
@@ -897,7 +897,7 @@ class AdminRoutesMixin:
return False
GalleryConfig.MONITORING_DIRECTORIES = roots
self._cached_output_dir = None
self._gallery_cache = {}
self.reset_gallery_roots_cache()
return True
def _persist_settings(self):
+728 -123
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
self.add_library_negative
)
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
def _library_media(
self, records: list[dict], roots: list[Path], *, linked: bool = True
) -> list[dict]:
page = []
for record in records:
try:
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
break
except (OSError, ValueError, RuntimeError):
continue
return self._format_workspace_page(page)
return self._format_workspace_page(page, linked=linked)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
def previews() -> None:
for parent in result["items"]:
parent["previews"] = self._library_media(records[parent["id"]], roots)
# Previews belong to their parent; a per-parent link read is N+1.
parent["previews"] = self._library_media(
records[parent["id"]], roots, linked=False
)
await self._run_in_executor(previews)
return web.json_response({"success": True, **result})
+43 -5
View File
@@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000
# for one giant result set (and silently truncating large libraries).
EXPORT_PAGE_SIZE = 1000
# The most prompts one bulk category request may change.
BULK_CATEGORY_MAX_PROMPTS = 1000
# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError.
MAX_SQLITE_INT = 2**63 - 1
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
``cap`` distinct positive integers no larger than SQLite's INTEGER
(booleans are not integers here).
"""
if not isinstance(value, list) or not value:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value):
raise ValueError("prompt_ids must be positive integers")
ids = list(dict.fromkeys(value))
if len(ids) > cap:
raise ValueError(f"At most {cap} prompts can be changed at once")
return ids
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
@@ -1145,14 +1168,29 @@ class PromptRoutesMixin:
async def bulk_set_category(self, request):
"""Bulk set category for prompts."""
try:
data = await request.json()
prompt_ids = data.get("prompt_ids", [])
category = (data.get("category") or "").strip()
if not prompt_ids:
try:
data = await request.json()
except ValueError:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(data, dict):
return web.json_response(
{"success": False, "error": "Expected a JSON object"}, status=400
)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
)
category = data.get("category")
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
validate_category(category)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
category = (category or "").strip()
updated_count = await self._run_in_executor(
self.db.bulk_set_category, prompt_ids, category
+111 -4
View File
@@ -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,22 @@ 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, bulkbar, 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; `bulkbar` is the selection bar's place and, while
* anything is checked, its `.bulkbar` row. 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 +246,101 @@ 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');
function bulkbarOf(bar) {
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
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};
}
+133 -55
View File
@@ -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([]);
@@ -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' });
+618
View File
@@ -0,0 +1,618 @@
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: "<Today|Yesterday|weekday> · <Month D>" 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.top<b.top+b.height&&b.top<a.top+a.height;
const freeOf=line=>rowWidth-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: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/**
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
* measured from the border box as the inventory records them. The concept's border is one width on every side
* (`border: 1px solid`), so the right border stands for the left one.
*/
function withoutPaddedItems(row,isRemoved) {
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
const flowed=withoutItems(inner,isRemoved);
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
}
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
function mapBulkbar(bulkbar,rise) {
if(!bulkbar)return {bulkbar,shrink:0};
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
const shrink=bulkbar.bar.height-bar.height;
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
}
/**
* 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 x<y?-1:x>y?1:a<b?-1:a>b?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+$/,'')},
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);
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
}
/** 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','bulkbar','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<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page),loadingRow};
await page.evaluate(()=>{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);
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
const more=page.getByRole('button',{name:'Load more images',exact:true});
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
await more.dispatchEvent('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 "<title> · <filename>".
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');
}
});
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
// oracle for the re-laid-out bar.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
const check=async()=>{
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
};
await openConceptLoaded(page,theme,'no-preference');
const empty=await inventoryOf(page,'#main');
await check();
const reference=await inventoryOf(page,'#main');
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
const removed=(await inventoryOf(page,'#main')).bulkbar;
await restore();
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
await openProductionLoaded(page,theme,'no-preference');
const emptyActual=await inventoryOf(page,'#workspace-main');
await check();
const actual=await inventoryOf(page,'#workspace-main');
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
// The concept itself: an empty bar, then "1 selected" and its six actions.
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
await mutate(page,{css:'.bulkbar { padding: 4px }'});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
// 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);
});
+11 -10
View File
@@ -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);
});
+17 -5
View File
@@ -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})
+7 -7
View File
@@ -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(()=>{});});
File diff suppressed because it is too large Load Diff
@@ -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);
@@ -679,6 +680,9 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
+1 -1
View File
@@ -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']);
+64 -47
View File
@@ -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,30 @@ 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');
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
// The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides
// the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect.
const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
const overlays = layout === 'List' ? [] : [box, star];
if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); }
await longCard.locator('.image-open').focus();
for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); }
await page.keyboard.press('Tab');
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).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 +386,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 +413,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 +465,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 +490,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 +502,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 +593,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 +641,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);
}
});
+10 -2
View File
@@ -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);
+50 -5
View File
@@ -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,
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -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",
@@ -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",
+1 -1
View File
@@ -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;
+5 -3
View File
@@ -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();
+9 -2
View File
@@ -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');
+13
View File
@@ -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);
+39
View File
@@ -0,0 +1,39 @@
// The seed the workspace inspector shows for encoded inspection responses, through
// its own path: projectInspectionPayload with the server facts and the legacy
// parser fill (comfy-metadata.js, passed explicitly; Node has no window global).
// Used by tests/test_api_workspace_images.py to compare card and inspector seeds.
// stdin: {listing, inspections: [{name, body}]}; each body is the exact response
// JSON text, and listing (optional) is the exact workspace listing response text.
// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}.
// Inspector status is 'error' where the page cannot project the record. The card
// values come from JSON.parse of the listing, as the browser reads them, so a
// seed sent as a JSON number past 2**53 shows up rounded here.
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const web = path.join(here, '..', '..', '..', 'web', 'js');
const require = createRequire(import.meta.url);
const parser = require(path.join(web, 'comfy-metadata.js'));
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const inspector = {};
for (const { name, body } of inspections) {
const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null };
try {
const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser);
inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value };
} catch (error) {
// The page shows no record here, so no seed either.
inspector[name] = { status: 'error', value: String(error && error.name) };
}
}
const card = {};
for (const item of listing === null ? [] : JSON.parse(listing).images) {
card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed);
}
process.stdout.write(JSON.stringify({ inspector, card }));
@@ -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);
});
@@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/images-category.mjs');
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
const {categoryTargets} = await source;
const images = [
linked(7), linked(3), linked(7),
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
{path:'none.png'},
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
{path:'zero.png', linked:{status:'linked', positive_id:0}},
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
null,
];
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
});
test('the outcome toast counts the prompts and the skipped images', async () => {
const {categoryOutcome} = await source;
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
});
test('the dialog and failure copy is the plan\'s', async () => {
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
});
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
const {updatedCount} = await source;
assert.equal(updatedCount({updated_count:0}, 3), 0);
assert.equal(updatedCount({updated_count:2}, 3), 2);
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
}
});
test('the category field is limited to the server\'s 100 characters', async () => {
const {CATEGORY_MAX_LENGTH} = await source;
assert.equal(CATEGORY_MAX_LENGTH, 100);
});
+76
View File
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/images-export.mjs');
const inspector = import('../../web/js/workspace/file-inspector.mjs');
function record(path, status = 'available') {
return {identity:{kind:'rooted-file', key:`0:${path}`, root_index:0, path, root_scope:null}, file:{format:'PNG'},
prompts:{positive:{status:'resolved', text:path}}, generation:{}, library:{status:'unlinked'},
download:{filename:path.split('/').at(-1), url:`/prompt_manager/images/serve/${path}?root=0`},
metadata:{status, omitted_reason:null, raw:{prompt:'{}'}, record_formats:{prompt:'original-text'}}};
}
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => {resolve = a; reject = b;}); return {promise, resolve, reject}; };
test('the single-image download and the bundle share one envelope builder', async () => {
const {metadataEnvelope, inspectionDownloads} = await inspector;
const value = record('a/one.png');
assert.deepEqual(JSON.parse(inspectionDownloads(value).metadata.bodyText), metadataEnvelope(value));
assert.equal(metadataEnvelope(record('b.png', 'limited')), null);
assert.equal(metadataEnvelope({}), null);
});
test('the bundle holds the format, version, export time and envelopes in the given order', async () => {
const {metadataBundle, BUNDLE_FILENAME} = await source;
const envelopes = [{inspection_version:1, n:1}, {inspection_version:1, n:2}];
const bundle = metadataBundle(envelopes, '2026-10-20T00:00:00.000Z');
assert.deepEqual(bundle, {format:'prompt-manager-images-metadata', version:1, exported_at:'2026-10-20T00:00:00.000Z', images:envelopes});
assert.notEqual(bundle.images, envelopes);
assert.equal(BUNDLE_FILENAME, 'prompt-manager-images-metadata.json');
});
test('collection keeps listing order, runs at most four reads at once and counts failures', async () => {
const {collectImagesMetadata} = await source;
const {metadataEnvelope} = await inspector;
const images = Array.from({length:9}, (_, index) => ({path:`img-${index}.png`, root_index:0}));
const waits = new Map(images.map(image => [image.path, deferred()]));
let running = 0, peak = 0;
const done = collectImagesMetadata(images, async image => {
running += 1; peak = Math.max(peak, running);
try { return await waits.get(image.path).promise; } finally { running -= 1; }
});
await new Promise(resolve => setImmediate(resolve));
assert.equal(running, 4);
// Settle out of order: later images finish first, one read fails and one file's metadata is limited.
for (const image of [...images].reverse()) {
const wait = waits.get(image.path);
if (image.path === 'img-3.png') wait.reject(new Error('unreadable'));
else wait.resolve(record(image.path, image.path === 'img-5.png' ? 'limited' : 'available'));
await new Promise(resolve => setImmediate(resolve));
}
const {envelopes, failed} = await done;
assert.equal(peak, 4);
assert.equal(failed, 2);
assert.deepEqual(envelopes.map(envelope => envelope.identity.path),
['img-0.png', 'img-1.png', 'img-2.png', 'img-4.png', 'img-6.png', 'img-7.png', 'img-8.png']);
assert.deepEqual(envelopes[0], metadataEnvelope(record('img-0.png')));
});
test('a read that throws synchronously counts as a failure', async () => {
const {collectImagesMetadata} = await source;
const result = await collectImagesMetadata([{path:'a.png'}, {path:'b.png'}], image => {
if (image.path === 'a.png') throw new TypeError('Invalid inspection identity');
return record(image.path);
});
assert.equal(result.failed, 1);
assert.deepEqual(result.envelopes.map(envelope => envelope.identity.path), ['b.png']);
assert.deepEqual(await collectImagesMetadata([], () => assert.fail('no reads')), {envelopes:[], failed:0});
});
test('the outcome toasts name the exported and unread counts', async () => {
const {exportOutcome} = await source;
assert.equal(exportOutcome(1, 1), 'Exported metadata for 1 image.');
assert.equal(exportOutcome(2, 2), 'Exported metadata for 2 images.');
assert.equal(exportOutcome(1, 3), 'Exported metadata for 1 of 3 images. 2 could not be read.');
assert.equal(exportOutcome(2, 3), 'Exported metadata for 2 of 3 images. 1 could not be read.');
assert.equal(exportOutcome(0, 2), 'Metadata could not be read. Try again.');
});
+149
View File
@@ -183,3 +183,152 @@ 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);
});
test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => {
const { toggleSelection } = await source;
const empty = Object.freeze(new Set());
const one = toggleSelection(empty, '0:a', true);
assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0);
const two = toggleSelection(one, '1:a', true);
assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']);
assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set');
assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set');
assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']);
});
test('selectionLabel counts the selected images as the concept bar does', async () => {
const { selectionLabel } = await source;
assert.equal(selectionLabel(1), '1 selected');
assert.equal(selectionLabel(2), '2 selected');
assert.equal(selectionLabel(1234), '1,234 selected');
});
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({}));
test('library title counts UTF-16 units and never splits a surrogate pair', () => {
assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported');
assert.equal(libraryTitle(' short line \nsecond'), 'short line');
assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80));
assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`);
// 80 emoji are 160 UTF-16 units: 40 fit.
assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`);
// 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole.
assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`);
});
+61 -5
View File
@@ -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 "<title> · <filename>" 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(); }
});
+613
View File
@@ -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 = '<b>a castle</b>, "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');
});
+69 -1
View File
@@ -188,6 +188,10 @@ class TestLibraryAPI(ImageAPITestCase):
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
for index in range(200):
positive = self._save_prompt(f"Parent {index}", tags=[f"tag {index}"])
if index % 3 == 0:
self.api.db.save_prompt_pair(f"Parent {index}", f"Negative {index}")
if index % 4 == 0:
self.api.db.record_prompt_use(positive, times=index + 1)
make_png(self.output_dir / f"{index}.png")
self.api.db.link_image_to_prompt(
positive, str(self.output_dir / f"{index}.png")
@@ -232,6 +236,32 @@ class TestLibraryAPI(ImageAPITestCase):
for item in result["items"]
)
)
# Usage fields and the variant total ride on the same five SELECTs.
self.assertEqual(result["variant_total"], 67)
self.assertEqual(
result["variant_total"],
sum(item["variant_count"] for item in result["items"]),
)
used = {
int(item["text"].split()[-1]): item
for item in result["items"]
if item["last_used_at"] is not None
}
self.assertEqual(sorted(used), list(range(0, 200, 4)))
self.assertTrue(
all(
item["run_count"] == index + 1
and item["last_used_at"].endswith("+00:00")
for index, item in used.items()
)
)
self.assertTrue(
all(
type(item["run_count"]) is int
for item in result["items"]
if item["last_used_at"] is None
)
)
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
prefix = "猫" * 10_000
@@ -309,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))
@@ -343,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
@@ -438,6 +474,38 @@ class TestLibraryAPI(ImageAPITestCase):
)
return response, await response.json()
async def test_unconvertible_last_use_does_not_fail_list_detail_or_receipt(self):
positive = self._save_prompt("Cat")
other = self._save_prompt("Dog")
with self.api.db.model.get_connection() as connection:
connection.execute(
"UPDATE prompts SET last_used_at=? WHERE id=?",
("9999-12-31T23:59:59-01:00", positive),
)
connection.execute(
"UPDATE prompts SET last_used_at=? WHERE id=?",
("0001-01-01T00:00:00+01:00", other),
)
response, listed = await self.get()
self.assertEqual(response.status, 200, listed)
self.assertEqual(
[item["last_used_at"] for item in listed["items"]], [None, None]
)
response, detail = await self.get(f"/{positive}")
self.assertEqual(response.status, 200, detail)
self.assertIsNone(detail["positive"]["last_used_at"])
response, receipt = await self.patch_parent(
positive,
{
"expected_epoch": detail["library_epoch"],
"if_match": detail["positive"]["edit_token"],
"metadata": {"title": "Portrait"},
},
)
self.assertEqual(response.status, 200, receipt)
self.assertEqual(receipt["positive"]["title"], "Portrait")
self.assertIsNone(receipt["positive"]["last_used_at"])
async def test_native_parent_patch_strict_validation(self):
from database.execution_history_schema import rotate_history_epoch
+93
View File
@@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
import py.api.prompts as prompts_module # noqa: E402
from py.api.prompts import ( # noqa: E402
BULK_CATEGORY_MAX_PROMPTS,
MAX_PAGE_LIMIT,
MAX_PAGE_OFFSET,
safe_error_message,
@@ -909,6 +910,98 @@ class TestBulkRoutes(RouteCoverageCase):
self.assertEqual(ok_data["updated_count"], 2)
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
async def _set_category(self, body):
return await self._json("POST", "/prompt_manager/bulk/category", json=body)
async def test_bulk_set_category_rejects_malformed_prompt_ids(self):
pid = self._save_prompt("bulk cat strict")
cases = (
None,
"1",
{"id": pid},
[],
[pid, "2"],
[pid, 0],
[pid, -3],
[pid, 1.5],
[True],
[pid, None],
# Beyond SQLite's INTEGER range: binding it would raise OverflowError.
[pid, 2**63],
[2**64],
list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)),
)
for prompt_ids in cases:
with self.subTest(prompt_ids=str(prompt_ids)[:40]):
status, data = await self._set_category(
{"prompt_ids": prompt_ids, "category": "c"}
)
self.assertEqual(status, 400, data)
self.assertFalse(data["success"])
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_bulk_set_category_validates_the_category(self):
pid = self._save_prompt("bulk cat validate")
for category in ("x" * 101, "bad\x00name", 7, ["c"]):
with self.subTest(category=str(category)[:20]):
status, data = await self._set_category(
{"prompt_ids": [pid], "category": category}
)
self.assertEqual(status, 400, data)
self.assertFalse(data["success"])
for body in ([pid], "text"):
with self.subTest(body=body):
status, data = await self._set_category(body)
self.assertEqual(status, 400, data)
resp = await self.client.post(
"/prompt_manager/bulk/category",
data="{not json",
headers={"Content-Type": "application/json"},
)
self.assertEqual(resp.status, 400)
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_bulk_set_category_dedupes_and_accepts_the_cap(self):
ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)]
seen = []
original = self.api.db.bulk_set_category
def spy(prompt_ids, category):
seen.append((list(prompt_ids), category))
return original(prompt_ids, category)
self.api.db.bulk_set_category = spy
status, data = await self._set_category(
{"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["updated_count"], 2)
self.assertEqual(seen, [([ids[1], ids[0]], "Land")])
# The cap itself is accepted; unknown ids simply do not count.
capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2))
status, data = await self._set_category(
{"prompt_ids": capped, "category": "Sea"}
)
self.assertEqual(status, 200, data)
self.assertEqual(data["updated_count"], 2)
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea")
async def test_bulk_set_category_keeps_the_legacy_admin_request(self):
# web/js/admin.js posts parseInt ids and a trimmed, non-empty category.
ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)]
status, data = await self._set_category(
{"prompt_ids": ids, "category": "Portraits"}
)
self.assertEqual(status, 200, data)
self.assertEqual(
set(data), {"success", "message", "updated_count"}, "response shape"
)
self.assertTrue(data["success"])
self.assertEqual(data["updated_count"], 3)
self.assertEqual(data["message"], "Set category for 3 prompts")
for pid in ids:
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
"""Card facts from header records: the shared legacy parser, bounded and exact."""
import json
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.card_seed import ( # noqa: E402
MAX_RECORD_BYTES,
card_seed,
header_params,
parsed_positive,
)
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
extract_generation_params,
)
from utils.inspection_response import ( # noqa: E402
MAX_PROJECTION_DEPTH,
project_graph,
)
try:
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
def graph(*samplers, extra=None):
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
for index, sampler in enumerate(samplers):
nodes[str(10 + index)] = sampler
nodes.update(extra or {})
return {"prompt": json.dumps(nodes)}
def seeded(seed, **settings):
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
def nested_workflow(depth=900):
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
nested = "[" * depth + "1" + "]" * depth
return (
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
)
def info_seed(info):
return card_seed(header_params(info))
class CardSeed(unittest.TestCase):
def test_card_seed_single_sampler(self):
info = graph(seeded(42001))
self.assertEqual(info_seed(info), "42001")
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
def test_card_seed_two_different_seeds_follows_shared_parser(self):
# Ruling: no separate heuristic; the card shows what the legacy parser
# (and so the inspector) shows for several samplers.
info = graph(seeded(111), seeded(222))
expected = extract_from_records(info["prompt"], None)["seed"]
self.assertIn(expected, (111, 222))
self.assertEqual(info_seed(info), str(expected))
def test_card_seed_linked_input_follows_shared_parser(self):
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
info = graph(seeded(["20", 0]), extra={"20": primitive})
self.assertEqual(info_seed(info), "555")
dangling = graph(seeded(["99", 0]))
self.assertEqual(
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
)
self.assertIsNone(info_seed(dangling))
def test_card_seed_advanced_sampler_noise_seed(self):
advanced = seeded(None)
advanced["class_type"] = "KSamplerAdvanced"
del advanced["inputs"]["seed"]
advanced["inputs"]["noise_seed"] = 77
self.assertEqual(info_seed(graph(advanced)), "77")
def test_card_seed_oversized_record_is_none(self):
padding = "x" * (MAX_RECORD_BYTES + 1)
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
self.assertIsNone(header_params(big_prompt))
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
self.assertIsNone(header_params(big_workflow))
# Bytes, not characters: two-byte text over the budget is oversized.
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
self.assertEqual(info_seed(graph(seeded(1))), "1")
def test_records_past_the_inspection_projection_bound_give_nothing(self):
# The inspector hands the browser parser only graphs its projection
# parsed; a deeper graph is "limited" there, so the card skips it too.
self.assertEqual(
project_graph(nested_workflow(), "workflow")["status"], "limited"
)
self.assertIsNone(header_params({"workflow": nested_workflow()}))
# A limited workflow beside a parsed prompt leaves the prompt's facts.
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
self.assertEqual(card_seed(both), "1")
def test_depth_bound_is_the_inspectors(self):
def deep(levels):
nested = "[" * levels + "1" + "]" * levels
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
text = graph(seeded(3))["prompt"]
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
# root object, node object, inputs object: three levels before "v".
inside = MAX_PROJECTION_DEPTH - 3
self.assertEqual(info_seed(deep(inside)), "3")
self.assertIsNone(header_params(deep(inside + 1)))
def test_strict_projection_rules_apply(self):
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
self.assertIsNone(header_params({"prompt": duplicate}))
def test_record_size_is_utf8_bytes_at_both_bounds(self):
frame = '{"a":"%s"}'
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": ascii_at}))
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": four_byte}))
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
self.assertIsNone(header_params({"prompt": padded}))
def test_placeholder_and_non_integer_seeds_are_none(self):
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
with self.subTest(seed=value):
self.assertIsNone(info_seed(graph(seeded(value))))
self.assertIsNone(card_seed({"seed": "Unknown"}))
self.assertIsNone(card_seed({"seed": 7.0}))
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
def test_digit_string_seed_is_what_the_inspector_reads(self):
# The projection carries big integers as text and keeps text as text;
# the inspector shows a digit string as the seed, and so does the card.
self.assertEqual(info_seed(graph(seeded("123"))), "123")
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
# JSON.parse would round it; the projection carries the record's digits.
case = CASES["seed-beyond-double-precision"]
rounded = extract_from_records(case["prompt"], None)["seed"]
self.assertEqual(rounded, float(4974052037430000001))
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
def test_seed_is_always_a_decimal_string(self):
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
with self.subTest(seed=value):
self.assertEqual(card_seed({"seed": value}), str(value))
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
def test_digit_string_seed_is_bounded(self):
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
# must not reach int() (Python's int max-str-digits raises ValueError).
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
self.assertIsNone(card_seed({"seed": "1" * 21}))
self.assertIsNone(card_seed({"seed": "9" * 5000}))
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
def test_seed_conversion_error_gives_none(self):
class Huge(int):
def __str__(self):
raise ValueError("Exceeds the limit for integer string conversion")
self.assertIsNone(card_seed({"seed": Huge(5)}))
def test_workflow_only_record(self):
case = next(
case
for case in CASES.values()
if case["prompt"] is None
and case["workflow"] is not None
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
)
expected = extract_from_records(None, case["workflow"])["seed"]
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
def test_records_the_parser_cannot_use_give_nothing(self):
self.assertIsNone(header_params({}))
self.assertIsNone(header_params(None))
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
self.assertIsNone(header_params({"prompt": b"{}"}))
self.assertIsNone(card_seed(None))
self.assertIsNone(parsed_positive(None))
# The browser parser throws here; the card shows nothing.
thrown = CASES["null-workflow-node"]
self.assertIsNone(header_params(thrown))
unparsable = header_params(CASES["unparsable-records"])
self.assertIsNone(card_seed(unparsable))
self.assertIsNone(parsed_positive(unparsable))
def test_every_shared_case_reads_the_projected_graphs(self):
for name, case in CASES.items():
with self.subTest(case=name):
info = {k: v for k, v in case.items() if v is not None}
params = header_params(info)
graphs = {key: project_graph(text, key) for key, text in info.items()}
values = {
key: graph["value"]
for key, graph in graphs.items()
if graph["status"] == "parsed"
}
if not values:
self.assertIsNone(params)
continue
try:
expected = extract_generation_params(values)
except GenerationParamsError:
self.assertIsNone(params)
continue
self.assertEqual(params, expected)
# Prompt text is the same as from a plain parse of the records.
if len(values) == len(info):
raw = extract_from_records(case["prompt"], case["workflow"])
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -1432,3 +1432,201 @@ class TestImagePaging(DatabaseTestCase):
self.assertEqual(len(second), 2)
self.assertNotEqual({img["id"] for img in first}, {img["id"] for img in second})
self.assertEqual(len(self.db.search_images_by_prompt("paged")), 5)
class TestImageLinkSummaries(DatabaseTestCase):
"""Batched card summaries follow get_image_prompt_info's matching rules."""
def _statements(self, call):
statements = []
original = self.db.model.get_connection
def traced():
connection = original()
connection.set_trace_callback(statements.append)
return connection
try:
with patch.object(self.db.model, "get_connection", side_effect=traced):
result = call()
finally:
original().set_trace_callback(None)
selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")]
return result, selects
def test_summary_status_matches_image_prompt_info_for_every_rule(self):
owner = self._save("Owner prompt\nsecond line", category="Cat", rating=4)
rival = self._save("Rival prompt")
exact = os.path.join("out", "one", "same.png")
other = os.path.join("out", "two", "same.png")
unique = os.path.join("out", "three", "unique.png")
shared = os.path.join("out", "shared.png")
self.db.link_image_to_prompt(owner, exact)
self.db.link_image_to_prompt(rival, other)
self.db.link_image_to_prompt(owner, unique)
self.db.link_image_to_prompt(owner, shared)
self.db.link_image_to_prompt(rival, shared)
probes = [
exact,
os.path.join("out", "x", "..", "one", "same.png"),
os.path.join("elsewhere", "same.png"),
os.path.join("elsewhere", "unique.png"),
shared,
"missing.png",
]
for fallback in (True, False):
summaries = self.db.get_image_link_summaries(probes, fallback)
self.assertEqual(set(summaries), set(probes))
for probe in probes:
with self.subTest(probe=probe, fallback=fallback):
info = self.db.get_image_prompt_info(
probe, fallback, report_ambiguity=True
)
summary = summaries[probe]
if info is None:
expected = "none"
elif info.get("association_status") == "ambiguous":
expected = "ambiguous"
else:
expected = "linked"
self.assertEqual(summary["status"], expected)
if expected == "linked":
self.assertEqual(summary["positive_id"], info["prompt_id"])
else:
self.assertEqual(
summary,
{
"status": expected,
"positive_id": None,
"title": None,
"title_source": None,
"category": None,
"rating": None,
},
)
linked = self.db.get_image_link_summaries([unique])[unique]
self.assertEqual(
linked,
{
"status": "linked",
"positive_id": owner,
"title": "Owner prompt",
"title_source": "text",
"category": "Cat",
"rating": 4,
},
)
def test_summary_title_prefers_stored_title_then_first_line(self):
long_line = "word " * 30
titled = self._save("Untitled text")
plain = self._save(f" {long_line}\nsecond")
blank = self._save("Blank title text")
with self.db.model.get_connection() as conn:
conn.executemany(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
[(titled, "Stored title"), (blank, " \u3000 ")],
)
paths = {}
for name, prompt in (("titled", titled), ("plain", plain), ("blank", blank)):
paths[name] = os.path.join("out", f"{name}.png")
self.db.link_image_to_prompt(prompt, paths[name])
summaries = self.db.get_image_link_summaries(list(paths.values()))
sources = {
name: (summaries[path]["title"], summaries[path]["title_source"])
for name, path in paths.items()
}
self.assertEqual(sources["titled"], ("Stored title", "stored"))
# Cut like the Library's title(): 80 characters, then an ellipsis.
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
exact = "x" * 80
self.assertEqual(display_title((), exact), exact)
self.assertEqual(display_title((), exact + "y"), exact + "\u2026")
self.assertEqual(display_title((), " short \nnext"), "short")
self.assertIsNone(display_title((" ",), " \n"))
self.assertEqual(display_title(("Kept ",), "text"), "Kept ")
def test_summary_uses_one_select_per_200_paths(self):
prompt = self._save("Batch")
paths = [os.path.join("out", f"{index}.png") for index in range(201)]
for path in paths[:5]:
self.db.link_image_to_prompt(prompt, path)
result, selects = self._statements(
lambda: self.db.get_image_link_summaries(paths[:200])
)
self.assertEqual(len(selects), 1)
self.assertEqual(len(result), 200)
self.assertEqual(
[result[path]["status"] for path in paths[:6]], ["linked"] * 5 + ["none"]
)
_, selects = self._statements(lambda: self.db.get_image_link_summaries(paths))
self.assertEqual(len(selects), 2)
_, selects = self._statements(lambda: self.db.get_image_link_summaries([]))
self.assertEqual(selects, [])
def test_windows_style_paths_normalize(self):
import ntpath
def windows_normalize(path):
return ntpath.normcase(ntpath.normpath(path))
prompt = self._save("Windows prompt")
stored = r"C:\ComfyUI\Output\Sub\Image.PNG"
with patch("database.operations.normalize_image_path", windows_normalize):
self.db.link_image_to_prompt(prompt, stored)
probes = [
stored,
r"c:\comfyui\output\SUB\image.png",
r"C:\ComfyUI\Output\x\..\Sub\Image.PNG",
"C:/ComfyUI/Output/Sub/Image.PNG",
r"D:\ComfyUI\Output\Sub\Other.png",
]
summaries = self.db.get_image_link_summaries(probes, False)
for probe in probes:
with self.subTest(probe=probe):
info = self.db.get_image_prompt_info(probe, False)
expected = "linked" if info else "none"
self.assertEqual(summaries[probe]["status"], expected)
self.assertEqual(
[summaries[probe]["status"] for probe in probes],
["linked"] * 4 + ["none"],
)
+66
View File
@@ -428,6 +428,72 @@ class TestRestoreFromFile(BackupTestCase):
PromptModel.verify_database_file(dst, require_library_state=True)[0]
)
def test_restore_old_backup_without_favorites(self):
self.db.set_image_favorite("output", "live.png")
src = self._make_source_db(["predates favorites"])
with closing(sqlite3.connect(src)) as conn:
conn.execute("DROP TABLE image_favorites")
conn.commit()
self.assertTrue(PromptModel.verify_database_file(src)[0])
self.db.model.restore_from_file(src)
self.assertEqual(_texts(self.path), ["predates favorites"])
self.assertEqual(self.db.get_image_favorites("output", ["live.png"]), set())
self.assertTrue(self.db.set_image_favorite("output", "new.png"))
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
with closing(sqlite3.connect(src)) as conn: # the source is untouched
self.assertIsNone(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name='image_favorites'"
).fetchone()
)
def test_restore_preserves_favorites_and_closes_handles(self):
self.db.set_image_favorite("output", "live.png")
with tempfile.TemporaryDirectory() as directory:
src_path = os.path.join(directory, "source.db")
src = PromptDatabase(src_path)
try:
self._save(src, "with favorites")
src.set_image_favorite("output", "sub/kept.png")
src.set_image_favorite("extra/root", "kept.png")
finally:
src.close_all()
# The favorites still sit in the WAL; a backup must carry them.
backup = os.path.join(directory, "backup.db")
reopened = PromptDatabase(src_path)
try:
self.assertTrue(reopened.model.backup_database(backup))
finally:
reopened.close_all()
self.db.model.restore_from_file(backup)
self.assertEqual(
self.db.get_image_favorites("output", ["sub/kept.png", "live.png"]),
{"sub/kept.png"},
)
self.assertEqual(
self.db.get_image_favorites("extra/root", ["kept.png"]), {"kept.png"}
)
PromptModel.close_all_instances()
# Windows refuses to delete a file a restore left open.
assert_closed(self, directory)
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
self.assertEqual(len(backups), 1)
with closing(sqlite3.connect(str(backups[0]))) as conn:
self.assertEqual(
conn.execute(
"SELECT root_key, relative_path FROM image_favorites"
).fetchall(),
[("output", "live.png")],
)
assert_closed(self, str(backups[0]))
def test_restore_without_a_live_database_returns_empty_backup_path(self):
src = self._make_source_db(["fresh"])
self.db.close_all()
+85 -52
View File
@@ -24,6 +24,11 @@ except ImportError:
import test_execution_history_writer as fixtures
# Upper bound a blocked fake write waits for its release. Tests prove ordering
# with events, so this only caps a failing run and never decides a pass.
BLOCK_SECONDS = 10
class OutputWriterTests(fixtures.unittest.TestCase):
setUp = fixtures.HistoryWriterTests.setUp
cleanup_workers = fixtures.HistoryWriterTests.cleanup_workers
@@ -408,59 +413,81 @@ class OutputWriterTests(fixtures.unittest.TestCase):
def test_bounded_close_and_early_native_join_keep_inflight_combined_charge(self):
for early in (False, True):
with self.subTest(early_join=early):
writer = self.writer()
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
entered, release = self.block()
original = self.db.write_execution_outputs_for_finish
self.assert_bounded_close_keeps_inflight_charge(early)
def write(*args, **kwargs):
entered.set()
release.wait(3)
return original(*args, **kwargs)
def assert_bounded_close_keeps_inflight_charge(self, early):
# Every writer in this test charges the process-wide budget, so a
# failing subtest must not leave its worker blocked (and charged) for
# the next one: release and join it before the next subtest starts.
writer = self.writer()
self.assertEqual(writer.context_budget.charged_bytes, 0)
start = self.start_command(len(self.writers))
self.db.write_execution_start(start)
entered, release = self.block()
left = threading.Event()
original = self.db.write_execution_outputs_for_finish
with patch.object(self.db, "write_execution_outputs_for_finish", write):
self.finalization(writer, start)
self.assertTrue(entered.wait(1))
charge = writer.context_budget.charged_bytes
self.finalization(writer, start)
clock = fixtures.ManualClock()
native_join = writer._thread.join
def write(*args, **kwargs):
entered.set()
release.wait(BLOCK_SECONDS)
left.set()
return original(*args, **kwargs)
def return_early(timeout):
native_join(0)
clock.advance(min(timeout, 0.004))
try:
with patch.object(self.db, "write_execution_outputs_for_finish", write):
self.finalization(writer, start)
self.assertTrue(entered.wait(BLOCK_SECONDS))
charge = writer.context_budget.charged_bytes
self.finalization(writer, start)
clock = fixtures.ManualClock()
native_join = writer._thread.join
def sleep(delay):
clock.advance(delay)
def return_early(timeout):
native_join(0)
clock.advance(min(timeout, 0.004))
clock_patch = (
patch(
"utils.execution_history_writer.time",
SimpleNamespace(monotonic=clock, sleep=sleep),
)
if early
else nullcontext()
def sleep(delay):
clock.advance(delay)
clock_patch = (
patch(
"utils.execution_history_writer.time",
SimpleNamespace(monotonic=clock, sleep=sleep),
)
join_patch = (
patch.object(writer._thread, "join", return_early)
if early
else nullcontext()
)
with clock_patch, join_patch:
before = clock() if early else time.monotonic()
self.assertFalse(writer.close(0.02))
elapsed = (clock() if early else time.monotonic()) - before
self.assertGreaterEqual(elapsed, 0.02)
if early
else nullcontext()
)
join_patch = (
patch.object(writer._thread, "join", return_early)
if early
else nullcontext()
)
with clock_patch, join_patch:
before = clock() if early else time.monotonic()
self.assertFalse(writer.close(0.02))
elapsed = (clock() if early else time.monotonic()) - before
# close() waits until its own monotonic deadline, so the lower
# bound holds on any runner.
self.assertGreaterEqual(elapsed, 0.02)
if early:
# Manual clock: only the patched join/sleep advance it, so
# this exact bound proves early joins do not spin forever.
self.assertLess(elapsed, 0.2)
self.assertEqual(writer.context_budget.charged_bytes, charge)
replacement = HistoryWriter(lambda: self.db)
self.writers.append(replacement)
self.assertIs(replacement.context_budget, writer.context_budget)
self.assertIsNone(replacement.context_budget.reserve(8388608))
release.set()
self.assertTrue(writer.close(2))
self.assertEqual(writer.context_budget.charged_bytes, 0)
# Bounded, not unbounded: close() gave up while the in-flight
# write was still blocked, instead of waiting it out.
self.assertFalse(left.is_set())
self.assertTrue(writer._thread.is_alive())
self.assertEqual(writer.context_budget.charged_bytes, charge)
replacement = HistoryWriter(lambda: self.db)
self.writers.append(replacement)
self.assertIs(replacement.context_budget, writer.context_budget)
self.assertIsNone(replacement.context_budget.reserve(8388608))
release.set()
self.assertTrue(writer.close(2))
finally:
release.set()
writer.close(BLOCK_SECONDS)
self.assertEqual(writer.context_budget.charged_bytes, 0)
def test_pending_saturation_and_invalid_command_release_both_payloads(self):
writer = self.writer()
@@ -552,11 +579,13 @@ class OutputWriterTests(fixtures.unittest.TestCase):
self.db.write_execution_start(start)
entered, release = self.block()
invalidating, invalidated = threading.Event(), threading.Event()
committed = threading.Event()
original = outputs_db._capture
def capture(*args, **kwargs):
entered.set()
release.wait(3)
release.wait(BLOCK_SECONDS)
committed.set()
return original(*args, **kwargs)
def invalidate():
@@ -566,20 +595,24 @@ class OutputWriterTests(fixtures.unittest.TestCase):
with patch.object(outputs_db, "_capture", capture):
self.finalization(writer, start)
self.assertTrue(entered.wait(1))
self.assertTrue(entered.wait(BLOCK_SECONDS))
invalidator = threading.Thread(target=invalidate)
invalidator.start()
try:
self.assertTrue(invalidating.wait(1))
self.assertTrue(invalidating.wait(BLOCK_SECONDS))
self.assertFalse(invalidated.wait(0.03))
before = time.monotonic()
self.assertTrue(writer.submit(self.finish_command(start)))
self.assertFalse(writer.close(0))
self.assertLess(time.monotonic() - before, 0.1)
# submit() and close() returned while the commit still held the
# gate: the blocked invalidation has not finished and the
# commit is still waiting for its release. Had either call
# queued behind the gate, it could only return after both.
self.assertFalse(invalidated.is_set())
self.assertFalse(committed.is_set())
self.assertGreater(writer.context_budget.charged_bytes, 0)
finally:
release.set()
invalidator.join(3)
invalidator.join(BLOCK_SECONDS)
self.assertFalse(invalidator.is_alive())
self.assertTrue(writer.close(2))
self.assertEqual(
+383
View File
@@ -0,0 +1,383 @@
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
import json
import ntpath
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
try:
from test_api_images import ImageAPITestCase, make_png
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
from tests.open_handles import assert_closed
from database.image_favorites import (
MAX_KEY_LENGTH,
TABLE,
image_favorites_schema_error,
migrate_image_favorites,
)
from database.models import PromptModel
from database.operations import PromptDatabase
FAVORITE = "/prompt_manager/images/favorite"
class TestImageFavoriteApi(ImageAPITestCase):
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
async def send(self, method, body):
response = await self.client.request(method, FAVORITE, json=body)
return response, await response.json()
async def workspace(self, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
self.assertEqual(response.status, 200)
data = await response.json()
return {image["path"]: image for image in data["images"]}
async def test_favorite_round_trip_and_idempotent(self):
make_png(self.output_dir / "sub" / "a.png")
make_png(self.output_dir / "b.png")
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], False)
self.assertIs(items["b.png"]["favorite"], False)
body = {"root_index": 0, "path": "sub/a.png"}
for _ in range(2): # a repeated PUT is a no-op, not an error
response, data = await self.send("PUT", body)
self.assertEqual(response.status, 200)
self.assertEqual(
data,
{
"success": True,
"favorite": True,
"root_index": 0,
"path": "sub/a.png",
},
)
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], True)
self.assertIs(items["b.png"]["favorite"], False)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 1)
for _ in range(2):
response, data = await self.send("DELETE", body)
self.assertEqual(response.status, 200)
self.assertTrue(data["success"])
self.assertIs(data["favorite"], False)
items = await self.workspace()
self.assertIs(items["sub/a.png"]["favorite"], False)
def rows(self):
return [
tuple(row)
for row in self.api.db.model.get_connection().execute(
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
)
]
async def test_favorite_is_keyed_by_root_not_index(self):
second = self.comfy_dir / "second"
make_png(second / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
self.assertEqual(response.status, 200)
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
self.assertEqual(self.rows(), [(root_key, "a.png")])
# The same root at another index keeps its favorite; the other root
# with the same relative path does not inherit it.
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
self.api.invalidate_gallery_cache()
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
# Two roots under different parents, both shown as "output".
with tempfile.TemporaryDirectory() as other_parent:
other = Path(os.path.realpath(other_parent)) / "output"
make_png(other / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
labels = {
item["root_index"]: item["root_dir"]
for item in (await response.json())["images"]
}
self.assertEqual(labels, {0: "output", 1: "output"})
await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
await self.send("PUT", {"root_index": 1, "path": "a.png"})
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "a.png"}
)
self.assertEqual(response.status, 200)
self.assertNotIn(other_parent, json.dumps(data))
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
listing = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
self.assertNotIn(other_parent, await listing.text())
async def test_windows_case_only_rename_keeps_the_star(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
response, data = await self.send(
"PUT", {"root_index": 0, "path": "Sub/A.png"}
)
self.assertEqual(response.status, 200)
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
(self.output_dir / "Sub" / "A.png").rename(
self.output_dir / "Sub" / "a.png"
)
(self.output_dir / "Sub").rename(self.output_dir / "sub")
self.api.invalidate_gallery_cache()
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
)
self.assertEqual(response.status, 200)
self.assertEqual(data["path"], "sub/a.png")
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
self.assertEqual(self.rows(), [])
async def test_posix_case_distinct_files_keep_independent_stars(self):
make_png(self.output_dir / "A.png")
make_png(self.output_dir / "a.png")
if len(list(self.output_dir.iterdir())) != 2:
self.skipTest("case-insensitive filesystem")
await self.send("PUT", {"root_index": 0, "path": "A.png"})
items = await self.workspace()
self.assertIs(items["A.png"]["favorite"], True)
self.assertIs(items["a.png"]["favorite"], False)
async def test_favorite_rejects_traversal_and_unknown_root(self):
make_png(self.output_dir / "a.png")
make_png(self.comfy_dir / "outside.png")
(self.output_dir / "notes.txt").write_text("not media")
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
cases = [
({"root_index": 0, "path": "../outside.png"}, 403),
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
({"root_index": 0, "path": "/a.png"}, 403),
({"root_index": 0, "path": "C:/a.png"}, 403),
({"root_index": 0, "path": "a.png\0.png"}, 403),
({"root_index": 0, "path": "notes.txt"}, 403),
({"root_index": 1, "path": "a.png"}, 400),
({"root_index": -1, "path": "a.png"}, 400),
({"root_index": "0", "path": "a.png"}, 400),
({"root_index": True, "path": "a.png"}, 400),
({"root_index": 0}, 400),
({"root_index": 0, "path": ""}, 400),
({"root_index": 0, "path": 7}, 400),
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
({"root_index": 0, "path": "missing.png"}, 404),
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
]
for method in ("PUT", "DELETE"):
for body, status in cases:
if method == "DELETE" and status == 404:
continue # unlisting a stale favorite is allowed (below)
with self.subTest(method=method, body=str(body)[:60]):
response, data = await self.send(method, body)
self.assertEqual(response.status, status)
self.assertIs(data["success"], False)
self.assertNotIn(str(self.comfy_dir), data["error"])
response = await self.client.put(FAVORITE, data=b"[1]")
self.assertEqual(response.status, 400)
response = await self.client.put(FAVORITE, data=b"{not json")
self.assertEqual(response.status, 400)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 0)
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
target = make_png(self.output_dir / "gone.png")
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
target.unlink()
self.api.invalidate_gallery_cache()
response, data = await self.send(
"DELETE", {"root_index": 0, "path": "gone.png"}
)
self.assertEqual(response.status, 200)
self.assertIs(data["favorite"], False)
count = self.api.db.model.get_connection().execute(
f"SELECT COUNT(*) FROM {TABLE}"
)
self.assertEqual(count.fetchone()[0], 0)
async def test_legacy_listing_is_unchanged_by_favorites(self):
make_png(self.output_dir / "a.png")
await self.send("PUT", {"root_index": 0, "path": "a.png"})
response = await self.client.get("/prompt_manager/images/output")
data = await response.json()
self.assertEqual(len(data["images"]), 1)
self.assertNotIn("favorite", data["images"][0])
async def test_toggle_lookup_is_built_once_per_scan(self):
make_png(self.output_dir / "a.png")
files = await self.api._get_gallery_files(self.output_dir)
first = self.api._favorite_listing(self.output_dir, files)
self.assertEqual(first, {"a.png": "a.png"})
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
make_png(self.output_dir / "b.png")
self.api.invalidate_gallery_cache()
rescanned = await self.api._get_gallery_files(self.output_dir)
self.assertEqual(
self.api._favorite_listing(self.output_dir, rescanned),
{"a.png": "a.png", "b.png": "b.png"},
)
self.api.reset_gallery_roots_cache()
self.assertEqual(self.api._favorite_listing_cache, {})
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
make_png(self.output_dir / "a.png")
await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertNotEqual(self.api._favorite_listing_cache, {})
# The image monitor's invalidation must not keep the old scan alive.
self.api.invalidate_gallery_cache()
self.assertEqual(self.api._favorite_listing_cache, {})
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
make_png(self.output_dir / "a.png")
too_long = "/" + "r" * MAX_KEY_LENGTH
with patch("py.api.images._favorite_root_key", return_value=too_long):
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertEqual(response.status, 403)
self.assertFalse(data["success"])
self.assertNotIn(too_long, json.dumps(data))
self.assertEqual(self.rows(), [])
# The live database still validates (a stored over-long key would
# fail startup and restore).
conn = self.api.db.model.get_connection()
self.assertIsNone(image_favorites_schema_error(conn))
async def test_favorite_store_failure_keeps_the_listing(self):
make_png(self.output_dir / "a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_image_favorites = broken
items = await self.workspace()
self.assertIs(items["a.png"]["favorite"], False)
self.api.db.set_image_favorite = broken
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
self.assertEqual(response.status, 500)
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
class TestImageFavoriteSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "favorites.db")
def tearDown(self):
PromptModel.close_all_instances()
assert_closed(self, self.tmpdir)
for name in os.listdir(self.tmpdir):
os.unlink(os.path.join(self.tmpdir, name))
os.rmdir(self.tmpdir)
def test_store_round_trip_and_bounded_lookup(self):
db = PromptDatabase(self.path)
try:
self.assertTrue(db.set_image_favorite("output", "a.png"))
self.assertTrue(db.set_image_favorite("output", "a.png"))
db.set_image_favorite("other", "a.png")
self.assertEqual(
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
)
self.assertEqual(db.get_image_favorites("output", []), set())
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
self.assertTrue(db.clear_image_favorite("output", "a.png"))
self.assertFalse(db.clear_image_favorite("output", "a.png"))
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
created = db.model.get_connection().execute(
f"SELECT created_at FROM {TABLE}"
)
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
finally:
db.close_all()
def test_schema_rejects_unsupported_definitions_and_values(self):
variants = {
"missing": None,
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
"nullable key": (
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
),
}
for label, ddl in variants.items():
with self.subTest(label):
with closing(sqlite3.connect(":memory:")) as conn:
if ddl:
conn.execute(ddl)
error = image_favorites_schema_error(conn)
if ddl is None:
self.assertIsNone(error)
self.assertIsNotNone(
image_favorites_schema_error(conn, allow_missing=False)
)
else:
self.assertIsNotNone(error)
with self.assertRaises(sqlite3.DatabaseError):
migrate_image_favorites(conn)
with closing(sqlite3.connect(":memory:")) as conn:
migrate_image_favorites(conn)
migrate_image_favorites(conn) # idempotent
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
self.assertIsNone(image_favorites_schema_error(conn))
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
self.assertIsNotNone(image_favorites_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
self.assertIsNotNone(image_favorites_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE}")
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
self.assertIsNotNone(image_favorites_schema_error(conn))
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
db = PromptDatabase(self.path)
db.close_all()
PromptModel.close_all_instances()
with closing(sqlite3.connect(self.path)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
conn.commit()
ok, reason = PromptModel.verify_database_file(self.path)
self.assertFalse(ok)
self.assertIn("favorites", reason.lower())
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -487,6 +487,115 @@ class TestLibrary(unittest.TestCase):
rotate_history_epoch(self.db.model.get_connection())
self.assertNotEqual(token(), before)
def test_library_items_expose_run_count_and_last_used(self):
import datetime
used = self.save("Used")
never = self.save("Never")
self.assertTrue(self.db.record_prompt_use(used))
self.assertTrue(self.db.record_prompt_use(used, times=2))
legacy = self.save("Legacy naive")
offset = self.save("Offset")
broken = self.save("Broken")
zulu = self.save("Zulu")
# Parse but cannot convert to UTC: past the datetime range either way.
floor = self.save("Floor")
ceiling = self.save("Ceiling")
with self.db.model.get_connection() as connection:
for prompt_id, value in (
(legacy, "2026-10-07 18:39:06"),
(zulu, "2026-10-07T18:39:06Z"),
(floor, "0001-01-01T00:00:00+01:00"),
(ceiling, "9999-12-31T23:59:59-01:00"),
(offset, "2026-10-07T20:39:06.250+02:00"),
(broken, "yesterday"),
):
connection.execute(
"UPDATE prompts SET last_used_at=?,run_count=1 WHERE id=?",
(value, prompt_id),
)
items = {row["id"]: row for row in self.db.list_library()["items"]}
self.assertEqual(items[used]["run_count"], 3)
stamp = datetime.datetime.fromisoformat(items[used]["last_used_at"])
self.assertEqual(stamp.utcoffset(), datetime.timedelta(0))
self.assertLess(
abs(datetime.datetime.now(datetime.timezone.utc) - stamp),
datetime.timedelta(minutes=5),
)
self.assertEqual(items[never]["run_count"], 0)
self.assertIsNone(items[never]["last_used_at"])
self.assertEqual(items[legacy]["last_used_at"], "2026-10-07T18:39:06+00:00")
self.assertEqual(
items[offset]["last_used_at"], "2026-10-07T18:39:06.250000+00:00"
)
self.assertIsNone(items[broken]["last_used_at"])
self.assertEqual(items[zulu]["last_used_at"], "2026-10-07T18:39:06+00:00")
self.assertIsNone(items[floor]["last_used_at"])
self.assertIsNone(items[ceiling]["last_used_at"])
self.assertIsNone(
self.db.get_library_positive(floor)["positive"]["last_used_at"]
)
self.assertEqual(type(items[broken]["run_count"]), int)
detail = self.db.get_library_positive(used)["positive"]
self.assertEqual(detail["run_count"], 3)
self.assertEqual(detail["last_used_at"], items[used]["last_used_at"])
def test_variant_total_matches_filtered_cards(self):
archived = self.db.save_prompt_pair("Cat", "Blur", category="A")
self.db.save_prompt_pair("Cat", "Noise", category="A")
self.db.save_prompt_pair("Cat", "Grain", category="B")
self.db.archive_prompt_pair(archived)
self.db.save_prompt_pair("Dog", "Blur", category="B")
self.save("Legacy")
everything = self.db.list_library()
self.assertEqual(everything["variant_total"], 3)
self.assertEqual(
everything["variant_total"],
sum(row["variant_count"] for row in everything["items"]),
)
cats = self.db.list_library(text="Cat")
self.assertEqual(
(cats["total"], cats["variant_total"]),
(1, 2),
)
self.assertEqual(self.db.list_library(category="B")["variant_total"], 3)
self.assertEqual(self.db.list_library(text="nothing")["variant_total"], 0)
first = self.db.list_library(limit=1)
self.assertEqual(first["variant_total"], 3)
# A later parent is past the cursor's ceiling, like the total.
self.db.save_prompt_pair("Bird", "Blur")
second = self.db.list_library(limit=1, after=first["next_key"])
self.assertEqual(
(second["total"], second["variant_total"]),
(first["total"], 3),
)
def test_edit_token_unchanged_by_usage_fields(self):
positive = self.save("Cat", category="A", tags=["a"], rating=3)
def parent():
return self.db.get_library_positive(positive)["positive"]
before = parent()
listed = self.db.list_library()["items"][0]
self.assertEqual(listed["edit_token"], before["edit_token"])
self.db.record_prompt_use(positive, times=4)
with self.db.model.get_connection() as connection:
connection.execute(
"UPDATE prompts SET last_used_at='2001-01-01T00:00:00+00:00' "
"WHERE id=?",
(positive,),
)
after = parent()
self.assertNotEqual(
(after["run_count"], after["last_used_at"]),
(before["run_count"], before["last_used_at"]),
)
self.assertEqual(after["edit_token"], before["edit_token"])
self.assertEqual(
self.db.list_library()["items"][0]["edit_token"], before["edit_token"]
)
def test_library_snapshot_reads_epoch_and_ceiling_in_one_select_and_ends(self):
self.save("Cat")
connection = self.db.model.get_connection()
@@ -1584,3 +1693,16 @@ class TestLibrary(unittest.TestCase):
)
with self.assertRaisesRegex(ValueError, "does not belong to this positive"):
self.db.list_library_images(positive, pair_id=foreign, archive="all")
class DisplayTitleUnits(unittest.TestCase):
"""The first-line title is cut like the Library's JavaScript title()."""
def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self):
from database.library import display_title
self.assertEqual(display_title((), " short line \nsecond"), "short line")
self.assertEqual(display_title((), "a" * 80), "a" * 80)
self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…")
self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…")
self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…")
+16
View File
@@ -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/'))
+36
View File
@@ -197,6 +197,24 @@
"functions": 98,
"statements": 98
},
"workspace/images-category.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-export.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-selection.mjs": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"workspace/images-state.mjs": {
"lines": 98,
"branches": 98,
@@ -299,6 +317,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 +358,11 @@
"branches": 91,
"functions": 93,
"statements": 98
},
"workspace/workspace-dialogs.mjs": {
"lines": 97,
"branches": 95,
"functions": 98,
"statements": 97
}
}
+115
View File
@@ -0,0 +1,115 @@
"""Workspace card facts from the metadata records a header open already loaded.
The card shows what the inspector shows, so it reads exactly what the
inspector's browser parser reads: each ``prompt``/``workflow`` record through
the inspection projection (``project_graph``: strict JSON, the depth and node
bounds, big integers and decimals as text), and only graphs it parsed, through
the shared parser (``extract_generation_params``, the Python twin of the
browser parser the inspector fills from since #251). No separate seed
heuristic, no graph resolution and no extra file reads. One parse feeds both
the seed and the positive that confirms a first-line card title.
"""
import re
from collections.abc import Mapping
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
)
from .inspection_response import project_graph
__all__ = [
"MAX_RECORD_BYTES",
"MAX_SEED_DIGITS",
"card_seed",
"header_params",
"parsed_positive",
]
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
MAX_RECORD_BYTES = 2 * 1024 * 1024
_RECORD_KEYS = ("prompt", "workflow")
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
# is no seed, and never reaches int(), whose max-str-digits guard raises.
MAX_SEED_DIGITS = 20
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
def _utf8_size_over(text: str, limit: int) -> bool:
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
A code point takes one to four bytes, so most records are decided from
their length alone without copying them.
"""
if len(text) > limit:
return True
if len(text) * 4 <= limit:
return False
return len(text.encode("utf-8", "surrogatepass")) > limit
def header_params(info) -> dict | None:
"""The shared parser's result for the inspector's view of ``info``'s records.
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
A record the projection does not parse is left out, as the inspector
leaves it out. None when no record parses, a record is not text or is
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
"""
if not isinstance(info, Mapping):
return None
graphs = {}
for key in _RECORD_KEYS:
text = info.get(key)
if text is None:
continue
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
return None
graph = project_graph(text, key)
if graph["status"] == "parsed":
graphs[key] = graph["value"]
if not graphs:
return None
try:
return extract_generation_params(graphs)
except (GenerationParamsError, RecursionError):
# Defence in depth: a parser failure gives no card facts, never a 500.
return None
def card_seed(params: dict | None) -> str | None:
"""The parsed seed as a decimal string, else None.
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
ComfyUI's randomized seeds are often that large. The projection carries
integers beyond 2**53 as their exact digits, so a digit string is the
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
digits. A placeholder, other or longer text, a boolean or a decimal gives
None.
"""
if not isinstance(params, Mapping):
return None
value = params.get("seed")
if isinstance(value, bool):
return None
try:
if isinstance(value, int):
return str(value)
if isinstance(value, str) and _INTEGER.match(value):
return str(int(value))
except ValueError:
# Python's integer string conversion limit: no seed, never a 500.
return None
return None
def parsed_positive(params: dict | None) -> str | None:
"""The parsed positive prompt text, or None for the placeholder or blank."""
if not isinstance(params, Mapping):
return None
text = params.get("positivePrompt")
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
return None
return text if text.strip() else None
+22 -8
View File
@@ -161,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
return {"status": "parsed", "value": value}
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
"""One record's graph as the inspector projects it: ``{status, value}``.
``status`` is ``parsed`` (``value`` is the projected graph the browser
parser receives), ``limited`` (past the depth or node bound) or
``malformed``. Shared with the workspace card so both read one bound.
"""
try:
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
except _ProjectionLimit:
return {"status": "limited", "value": None}
except (ValueError, TypeError, KeyError, OverflowError):
return {"status": "malformed", "value": None}
def _metadata(inspection: "InspectionRead | None") -> dict:
formats = (
{}
@@ -213,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
graph["status"] = failed or "limited"
elif formats.get(key) == "invalid":
graph["status"] = "malformed"
elif carrier is None and formats.get(key) == "reserialized-json":
graph["status"] = "limited"
elif carrier is None:
graph = project_graph(raw.get(key), key)
else:
try:
if carrier is not None:
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
elif formats.get(key) == "reserialized-json":
raise _ProjectionLimit()
else:
value = _strict_projection(raw[key], kind=key)
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
graph = _graph(value, workflow=key == "workflow")
except _ProjectionLimit:
graph["status"] = "limited"
+2 -1
View File
@@ -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 }),
};
}
+20 -9
View File
@@ -22,15 +22,13 @@ function safeFilename(value) {
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
}
/** Export only the bounded public evidence, never normalized graphs or container carriers. */
export function inspectionDownloads(record) {
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
const stem = name.replace(/\.[^.]+$/, '') || 'file';
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
/**
* One file's metadata envelope: only the bounded public evidence, never normalized graphs or container carriers.
* Null when the inspection holds no full metadata. Shared by "Download metadata JSON" and the Images bulk export.
*/
export function metadataEnvelope(record) {
const metadata = record?.metadata;
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
if (!metadata || metadata.status === 'limited') return result;
if (!metadata || metadata.status === 'limited') return null;
const raw = {}, record_formats = {}, representations = {};
for (const key of RAW_KEYS) {
if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) {
@@ -39,10 +37,23 @@ export function inspectionDownloads(record) {
if (!TEXTLESS.has(record_formats[key]) && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key];
}
}
const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
return {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
generation:record.generation, library:record.library,
metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations,
scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}};
}
/** The inspector's metadata and workflow downloads, each with its filename or the reason it is unavailable. */
export function inspectionDownloads(record) {
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
const stem = name.replace(/\.[^.]+$/, '') || 'file';
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
const metadata = record?.metadata;
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
const envelope = metadataEnvelope(record);
if (!envelope) return result;
const {raw, record_formats} = envelope.metadata;
result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)};
if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW;
else if (record_formats.workflow === 'limited') result.workflow.reason = LIMITED_WORKFLOW;
+22 -15
View File
@@ -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) {
+42
View File
@@ -0,0 +1,42 @@
import { countOf, noun } from './plural.mjs';
/**
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
*/
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
export const CATEGORY_MAX_LENGTH = 100;
function linkedPositive(image) {
const link = image?.linked;
if (link?.status !== 'linked') return null;
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
}
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
export function categoryTargets(images) {
const ids = new Set();
let skipped = 0;
for (const image of images) {
const id = linkedPositive(image);
if (id === null) skipped += 1;
else ids.add(id);
}
return {promptIds:[...ids], skipped};
}
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
export function categoryOutcome(prompts, skipped) {
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
if (!skipped) return done;
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
}
/** The prompts the server reports changed; without a count, the number of prompts posted. */
export function updatedCount(result, posted) {
const count = result?.updated_count;
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
}
+40
View File
@@ -0,0 +1,40 @@
import { metadataEnvelope } from './file-inspector.mjs';
import { countOf } from './plural.mjs';
/** The Images selection bar's "Export metadata" (#249 D8): one JSON bundle of the inspector's envelopes. */
export const BUNDLE_FILENAME = 'prompt-manager-images-metadata.json';
const BUNDLE_FORMAT = 'prompt-manager-images-metadata';
const EXPORT_CONCURRENCY = 4;
export function metadataBundle(envelopes, exportedAt) {
return {format:BUNDLE_FORMAT, version:1, exported_at:exportedAt, images:[...envelopes]};
}
/**
* Read every image's inspection record, at most four at a time, and keep the envelopes in the images' order.
* A read that fails, or a record without full metadata, counts as one image that could not be read.
*/
export async function collectImagesMetadata(images, read) {
const envelopes = new Array(images.length).fill(null);
let next = 0;
async function worker() {
while (next < images.length) {
const index = next++;
try {
envelopes[index] = metadataEnvelope(await read(images[index]));
} catch {
// Counted below with the records that carry no full metadata.
}
}
}
await Promise.all(Array.from({length:Math.min(EXPORT_CONCURRENCY, images.length)}, worker));
const found = envelopes.filter(envelope => envelope !== null);
return {envelopes:found, failed:images.length - found.length};
}
/** The export's toast; with nothing exported there is no download. */
export function exportOutcome(exported, total) {
if (!exported) return 'Metadata could not be read. Try again.';
if (exported === total) return `Exported metadata for ${countOf(exported, 'image')}.`;
return `Exported metadata for ${exported} of ${countOf(total, 'image')}. ${total - exported} could not be read.`;
}
+148
View File
@@ -0,0 +1,148 @@
import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CATEGORY_URL = '/prompt_manager/bulk/category';
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
/**
* The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the
* bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while
* nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
*
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
let exporting = false, categorizing = false;
const element = el('div');
element.id = 'bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const setCategory = createButton('Set category');
scope.listen(setCategory, 'click', () => openCategory(setCategory));
const exportMetadata = createButton('Export metadata');
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
const clearButton = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
bulk.append(count, setCategory, exportMetadata, clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
if (bulk.parentNode !== element) element.append(bulk);
}
function clear() {
if (!checked.size) return;
checked = Object.freeze(new Set());
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
render();
}
function checkedImages() {
return getItems().filter(image => checked.has(imageKey(image)));
}
// Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects.
scope.listen(grid, 'change', event => {
const box = event.target;
if (!box.matches(CHECKBOX)) return;
checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked);
render();
});
/** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */
function busy(button) {
button.setAttribute('aria-disabled', 'true');
return () => button.removeAttribute('aria-disabled');
}
/**
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press
* while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast.
*/
async function exportChecked(button) {
if (exporting) return;
exporting = true;
const release = busy(button);
try {
const { envelopes, failed } = await collectImagesMetadata(checkedImages(),
async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image));
if (scope.signal.aborted) return;
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
} finally {
exporting = false;
release();
}
}
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
function downloadJson(filename, value) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
let timer, released = false;
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
scope.cleanup(release);
const anchor = el('a'); anchor.href = url; anchor.download = filename;
element.append(anchor); anchor.click(); anchor.remove();
timer = setTimeout(release, 1000);
}
/**
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
* a toast says so and no dialog opens.
*/
function openCategory(button) {
if (categorizing) return;
const targets = categoryTargets(checkedImages());
if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
}
/**
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
*/
async function applyCategory(button, { promptIds, skipped }, category) {
categorizing = true;
const release = busy(button);
let result = null;
try {
result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category });
} catch {
// Reported below; the selection stays as it was.
} finally {
categorizing = false;
release();
}
if (scope.signal.aborted) return;
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped));
const focused = document.activeElement === button;
onCategorySet();
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
if (focused) focusFallback.focus({ preventScroll: true });
}
render();
return {
element,
isChecked: key => checked.has(key),
checked: () => checked,
clear,
/** Whether a Set category write is still in flight. */
writing: () => categorizing,
};
}
+113 -8
View File
@@ -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,69 @@ 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, "<Today|Yesterday|weekday> · <Month D>" 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 "<source> · <folder>". */
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';
}
/** The card checkboxes' selection (#249 D8): a new frozen set with `key` added or removed; unchanged, the same set. */
export function toggleSelection(selected, key, on) {
if (selected.has(key) === on) return selected;
const next = new Set(selected);
if (on) next.add(key); else next.delete(key);
return Object.freeze(next);
}
/** The concept selection bar's count (app.js updateBulk), grouped like the page's other counts. */
export function selectionLabel(count) {
return `${count.toLocaleString('en-US')} selected`;
}
+6 -92
View File
@@ -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; }
}
+424 -134
View File
@@ -1,14 +1,18 @@
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 { mountSelectionBar } from './images-selection.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
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 +21,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 +99,57 @@ 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;
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), 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,60 +162,66 @@ 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.'));
body.append(collection, details);
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }});
if (bar) main.append(bar.element);
main.append(body);
const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender,
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 +230,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 +250,102 @@ 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));
// D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on
// Library-embedded media, which has no selection bar).
const overlays = el('div', undefined, 'image-overlays');
if (bar) {
const label = el('label'), box = el('input');
box.type = 'checkbox'; box.checked = bar.isChecked(key);
box.setAttribute('aria-label', `Select ${image.filename}`);
label.append(box);
overlays.append(label);
}
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 +376,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 +395,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 +407,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 +416,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 +431,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 +472,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 +516,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 +553,156 @@ 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;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
bar?.clear();
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);
+3
View File
@@ -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();
+3 -4
View File
@@ -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; }
+2 -11
View File
@@ -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);
+8 -1
View File
@@ -28,7 +28,14 @@ function el(tag,text,className) {
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
}
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;}
// Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches).
export function libraryTitle(text) {
const first=text.split('\n')[0].trim();
if (first.length<=80) return first;
const cut=first.slice(0,80);
return `${/[\uD800-\uDBFF]$/.test(cut)?cut.slice(0,-1):cut}…`;
}
const title=libraryTitle;
/** The full stored title; the first-line fallback only when it is blank. */
function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);}
function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}
+31 -25
View File
@@ -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 "<title> · <filename>" 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);
+12
View File
@@ -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);
}
+111
View File
@@ -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 };
}
+289
View File
@@ -0,0 +1,289 @@
/**
* 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).
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
* action; used by the Images selection bar's "Set category" (#249 D8).
*
* 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();
}
/**
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
* A positive `maxLength` limits the field.
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
requireScope(scope);
requireCallbacks({ onApply });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
const input = el('input');
input.value = typeof value === 'string' ? value : '';
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
const cancel = createButton('Cancel');
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());
apply.addEventListener('click', () => {
const entered = input.value.trim();
if (!entered) { input.focus(); return; }
finish();
onApply(entered);
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, apply);
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
}
+79 -3
View File
@@ -54,20 +54,96 @@ 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 */ } }
/* 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 */
}