Compare commits

..
Author SHA1 Message Date
Vito Sansevero 9f87a085cc test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query 2026-10-21 03:47:12 -07:00
Vito Sansevero ffd4b2caa3 fix(inspector): treat Unicode whitespace as a blank History recipe category 2026-10-21 03:47:11 -07:00
Vito Sansevero e39f6bfd59 test(inspector): assert the D39 fallback tag chips in their saved order 2026-10-21 03:08:38 -07:00
Vito Sansevero 45fab4b1cc fix(inspector): fall back to the positive's category and tags for recipe-linked images
Maintainer ruling D39 (2026-10-21): a recipe-linked image shows the recipe's
own category and tags when set, otherwise the linked positive prompt's, each
field on its own and without merging tag sets. Set category (#259) and the
Images category and Tags filters work on the positive, so the panel now agrees
with them.

The inspection library replaces metadata_scope with category_scope and
tags_scope so the category chip's hidden scope prefix (D21) and the tag chips'
filter-button rule (#260 D2) follow the value actually shown. History's media
inspector uses the same shared rule. The concept fixture's positive_links
workaround is gone: the comparison now matches the concept without it.
2026-10-21 02:59:19 -07:00
Vito 10a4847a6b feat(history): concept heading, negative variants and compare (#265)
* feat(history): concept heading and summary

The Prompt history page takes the approved mock's heading and summary
surface (docs/concepts/workspace/app.js renderHistory a591-598, #249 F1):

- Heading description is "Prompt history · one positive, one shared
  gallery"; the actions stay "← Prompt library", "Edit metadata" and
  button.send "Send to ComfyUI" (aria-label "Send prompt pair to ComfyUI:
  <title>"), on one row at 1600. Send is disabled only while the selected
  pair's roles are loading (D24).
- The summary is article.surface > span.chip category, the positive and
  selected negative prompt blocks, then .card-meta "N tracked runs · N
  images · N negative variants". Tracked runs come from the recorded
  history window's recorded_run_count (hidden until a read completes,
  kept while a refresh is in flight); images and variants from the
  saved positive's image_count and variant_count.
- The summary's copy/send actions are named after the prompt, as in the
  concept ("Copy positive prompt: <title>"); mountPromptBlocks gains
  setTitle so an in-place metadata edit renames them without a remount.
- mountRecordedHistory gains an optional onRecordedCount callback.

Selector mapping:
- .library-surface (history summary)  -> .library-history article.surface
- .library-surface .library-chip      -> article.surface > span.chip
- "Copy|Send <role> prompt[ to ComfyUI]" in "Saved prompt texts"
                                      -> "...: <title>"
- heading p.muted "One positive prompt. Reusable negative variants. One
  shared gallery." -> "Prompt history · one positive, one shared gallery"
- Unchanged: region "Saved prompt texts", .library-variants,
  .library-shared-gallery, .recorded-history (still .library-surface).

Tests: workspace-history "history heading actions stay on one row at 1600
and are enabled after load" and "summary meta uses recorded runs" (both
failed before), a prompt-blocks setTitle unit, and the mapped selectors in
workspace-visual, workspace-library-editor and the canonical comparison.

* feat(history): concept negative variants panel

The history's Negative variants panel is the concept's (app.js a598,
style.css 1059-1154): section.surface.timeline-panel with the panel
heading (h2, p.small "Choose a red dot to change the negative and filter
this gallery."), row actions Compare negatives (aria-pressed; its section
is Task F3), + Add negative and the approved Show archived; a .timeline of
.positive-lane and .variant-lane rows (--lane-offset 38 + 47i) with the
"−" .variant-dot, "<name> · N runs" and a .run-dot when the pair's
run_count > 1; and .timeline-lower with the legend and All images for
this positive.

D26: All images for this positive opens the concept dialog with the
embedded, bounded (Load more) media gallery instead of filtering the
shared gallery. The run dot opens Recorded run for the pair's newest
recorded execution, else a Repeat run dialog with the pair's newest
linked image. Backing data:
- library variants carry prompt_pairs.run_count;
- GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest
  reads the newest run in the positive's exact raw-text scope whose
  current-epoch saved-pair hint still resolves to that pair (the
  get_history_run hint rule), inside the fenced history snapshot.
- workspace-dialogs gains openContentDialog (onClose releases the body).
- mountRecordedHistory returns openRun.

D45 (new deviations):
- a dot is named "Select negative <title>" when no other loaded lane has
  that title, else "Select negative variant N", so names stay unique;
- the bounded "N of M saved negative variants" count stays a visually
  hidden status; "Saved combinations, not an execution timeline." is gone;
- archived lanes (#235) are concept lanes marked .library-variant-archived
  at .6 emphasis with their Restore button; coarse dots keep a 47px
  target; the heading, legend row and All images button wrap at 390px
  and 200% zoom.

Retired CSS: .library-branches, .library-positive-lane,
.library-variant-lane, .library-variant-dot, .library-variants,
.library-panel-heading, .library-variant-footer, .library-variant-summary.

Selector mapping:
- .library-variants -> .library-history .timeline-panel (region "Saved
  negative variants" unchanged)
- .library-branches -> .timeline; .library-positive-lane -> .positive-lane
- .library-variant-lane -> .variant-lane; .library-variant-dot -> .variant-dot
- .library-variant-archived and .library-variant-restore unchanged
- .library-variant-footer / button:last-child -> .timeline-lower / button
- .library-variants > p.muted -> .timeline-panel .panel-heading .small
- "Select negative variant N" -> "Select negative <title>" for titled
  pairs (history, native library, inspector, visual and real-host
  fixtures); untitled pairs unchanged
- lane text "· N linked image(s)" -> "· N run(s)"
- All images for this positive: aria-pressed filter -> dialog "All images
  for this positive"; tests that used it for the whole gallery select the
  lane linking the image, or read the dialog
- variant panel row actions gain Compare negatives first

Tests: new workspace-history "variants panel matches the concept", "run
dot appears only for pairs with more than one run and opens its run",
"all images dialog lists every image for the positive"; new
workspace-library-editor "show archived and restore still work" (all four
failed before the change). Python: variants carry run_count, newest pair
run (DB and route). Unit: content dialog. The history fixture counts two
canonical pairs and links one image to Clean geometry.

* feat(history): compare negative variants

Compare negatives now opens the concept's comparison (app.js a599-616,
style.css 1155-1179) as a section.surface right after the timeline panel:
h2 "Compare negative variants", p "Baseline compared with <name>. Choose
another red dot to change the comparison.", a .compare-grid of two
.compare-item negative blocks (baseline, selected), p.small "Added terms",
then span.diff-added "+ term" per added term or p.small "No added terms.".
Choosing another red dot updates the same section in place; pressing
Compare negatives again removes it.

D25: the baseline is the oldest active variant; with no selection the
selected side falls back to it, as the concept's v does. The pure
addedTerms(base, selected) in history-state.mjs is the concept's rule:
comma split, trim, drop empty terms, exact case-sensitive difference that
keeps the selected order and repeats.

D46 (new deviations):
- lanes are newest first, so the baseline is the lowest-id active variant,
  not the first lane; while the variant page has more, the oldest is not
  loaded and the section says "Load more negative variants to reach the
  baseline." instead of guessing;
- the two blocks are named "Baseline" and "Selected" (regions and copy/send
  actions), so no name repeats on the page;
- Compare negatives is disabled when there is no active variant.

Selector mapping: none changed; the section is new
(.library-history .timeline-panel + section.surface).

Tests: unit "added terms split, trim and exact-diff like the concept";
workspace-history "compare toggles, follows the selected dot and stacks at
390" and "compare waits for the page holding the oldest active negative"
(all failed before the change).

* test(history): guard the newest-pair route and record D45/D46

TESTING.md lists the Prompt history page's spec decisions (D24-D26) and
its new deviations D45 and D46 beside D23 and D40-D44, notes under D46
that production opens on the newest lane while the concept opens on its
baseline, adds GET /prompt_manager/library/{positive_id}/history/pairs/
{pair_id}/newest to the history route list with its guards, and documents
the detail variants' run_count.

Tests: the newest-pair read skips a stale reference_epoch row, stops at
16 stale-hint candidates (17 newer stale rows hide an older valid run and
report {run: null}), and rejects a negative-only or deleted positive; the
route answers 404 for an ineligible positive, 503 storage-unavailable for
storage failures without leaking details, and 409 history-changed for a
restore during the snapshot.

* fix(history): run-dot touch target, unique dot names and dialog cleanup

- D45: under pointer: coarse the 12px run dot gets a 44px ::after target,
  and while a run dot shows the lane text wraps within 139px before it.
- D45: dot names compare trimmed titles and never use a title that reads
  like a fallback "variant N", so names stay unique.
- All images and Repeat run share one dialog-scope slot: each open
  replaces the previous scope instead of adding a cleanup per open.
- A run-dot lookup that settles after the view went stale opens nothing.
- Retire the unused .library-main .library-chip rule and the stale
  "Saved combinations" comparison note.

Tests (workspace-history, Chromium and touch): the coarse run-dot target
and lane width; a failed newest lookup shows the toast and Repeat run;
Recorded run that cannot open (recorded history failed) falls back to
Repeat run; an archived pair's Repeat run reads archive=archived; Compare
negatives is disabled with no active variant; dot names stay unique.

* fix(workspace): one inert handle for aborted history and prompt-block mounts

An aborted parent no longer gets a separate never-called stub per method:
mountRecordedHistory returns its hoisted openRun (it answers false before
reading later state), and mountPromptBlocks returns one shared no-op for
update, setState, setTitle and dispose. This keeps the history.mjs and
prompt-blocks.mjs function-coverage floors (98) without lowering them.

Tests: test:coverage (Chromium and touch, 922 workflows) passes for all
61 files; it failed before on history.mjs 94.44 and prompt-blocks.mjs
93.75 function coverage.
2026-10-21 02:25:25 -07:00
Vito 3476f8ede9 feat(inspector): concept comparison, Show in folder and toast placement (#264)
* test(inspector): concept comparison for the panel and viewer details

Compare the Images side panel and the viewer's full details with the
concept (1600x1100 and 390x844, both themes): structure, tokens, geometry
and controls, plus copy, the acknowledged pair send, the tag chip filter
and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22;
a mutation test shows the comparison notices a changed role label and a
removed footer.

The comparison showed the role copy and send names lacked the concept's
": <title>" subject, so the inspector now passes its title to the prompt
blocks. It also showed that a recipe-linked image shows its recipe's own
category and tags (#224), which the concept fixture's recipes lack; that
is recorded as D38, and the fixture's new positive_links option links the
compared image to its positive alone.

* docs(inspector): renumber the recipe-category decision to D39

* feat(images): local-only Show in folder route

POST /prompt_manager/images/reveal takes the listing identity
{root_index, root_scope, path} and opens this computer's file manager at
that file. It answers 403 not-local unless the client is loopback, no
proxy header is present and Host/Origin name this computer; the scope is
checked with _gallery_scope_error (409) and the path resolves only inside
that root to a regular media file. Openers run as argv lists with an
absolute executable, no shell and DEVNULL streams (D38).

* feat(inspector): Show in folder after the File & provenance path

The viewer's File & provenance offers "Show in folder" right after the
Path value for a listed file only, POSTs its listing identity and toasts
the outcome (opened, not-local, unavailable or failed). The browser test
server stubs the opener; TESTING.md records the decision as D38.

* fix(images): keep the discovery alert clear of the Rescan toast

A rescan that leaves the Images discovery alert visible could show its
fixed bottom toast over the alert on a short window. The concept's toast
keeps its placement; the production-only alert now scrolls into view only
as far as needed, with a 96px block-end scroll margin (D42).

* docs(inspector): concept panel and viewer details

* fix(images): open Windows folders through the shell API, not an Explorer command line

Explorer splits its own command line on commas, so a gallery path such as
output\a,\Windows\System32\calc.exe,b\x.png escaped the roots through
[explorer, "/select,", path] (security review H1). Windows now selects the
file with ILCreateFromPathW and SHOpenFolderAndSelectItems, and on any shell
failure falls back to os.startfile on the folder. No process is started.

* fix(images): re-check, isolate and reap the Show in folder opener

- re-check that the real path is still a regular file inside its root
  right before launching; a changed target answers 404 (review L4)
- run openers on a dedicated single worker, not ComfyUI's default
  executor, and make dbus-send wait for the FileManager1 reply
  (--print-reply --reply-timeout=2000) so a missing service falls back
  to xdg-open (L1)
- reap launched processes on a daemon thread, give them a minimal
  environment with a fixed system PATH, and use /usr/bin/open on macOS
  and dbus-send/xdg-open resolved only on that PATH (L5)

* fix(images): accept Show in folder only as a same-origin JSON request, one at a time

- require an Origin equal to the request's own scheme://host:port and,
  when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style
  header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP
  and the rest) as remote (security review M1, L2)
- answer a non-JSON body with 415, so a cross-origin request always needs
  a CORS preflight (L2)
- single flight plus a 1 s cooldown per process: 429 code busy (L1)
- path resolution errors such as ENAMETOOLONG are a JSON 404, and neither
  that nor a start failure logs the client's path at error level (L3)

* fix(inspector): say the folder is already opening on a busy Show in folder

A 429 busy from the reveal route now toasts "The folder is already
opening." The browser workflow checks that the reveal POST carries the
page's own Origin and an application/json body, and TESTING.md D38 records
the tightened route, the toast table and the accepted residual risk of a
header-less local proxy.

* test(inspector): compare the viewer details under a coarse pointer and prove its mutations

The touch project measured production's D34 40px disclosure targets against
the concept's fine-pointer summaries. The concept's plain-chip copy now also
carries the recorded D34 coarse rule, so both documents are measured under the
same emulation. The open File & provenance is compared too (D38 Show in folder
and D20 Download original mapped), and the mutation check now removes the
viewer's pair actions and Show in folder.

* refactor(inspector): attach Show in folder to the Path value it follows

The button was placed on the second fact row by position; the Path dd is now
built by the caller and kept, so reordering the facts cannot move it.

* test(inspector): let the uncertain-file send check fail again

Inspector send buttons are named "... to ComfyUI: <title>", so the anchored
regex could never match and the no-send assertion always passed.

* fix(images): run the Windows folder fallback inside COM and keep desktop session variables

Show in folder's os.startfile fallback now runs inside the same
CoInitializeEx/CoUninitialize bracket as the shell selection, as ShellExecute
callers should. The Linux opener keeps XAUTHORITY, DESKTOP_SESSION,
XDG_SESSION_DESKTOP and XDG_CONFIG_HOME, so a file manager can reach the X11
display under GDM or SDDM and honour the user's mimeapps.list.

A Windows-only test calls the real ILCreateFromPathW and ILFree on a temp file
and pairs CoInitializeEx with CoUninitialize through the module's helper,
without SHOpenFolderAndSelectItems, so no window opens on CI.

* fix(images): scroll to the rescan's discovery alert only when the toast band covers it

D42 scrolled the alert into view after every rescan, so a user scrolled down
the grid was sent back to the top. It now scrolls only when the alert
intersects the toast's band (its scroll-margin-block-end).

* refactor(inspector): name prompt actions as PR #263 does

The title option keeps its name, default and output ("Copy positive prompt:
<title>") and now uses #263's actionName helper; the region-name arrow no
longer shadows the title option.

* test(inspector): the comparison opens Filters by its D44 active-count name

After a tag chip applies its filter, the shared toolbar's Filters button reads
"Filters · 1" (D44, #263), so the comparison opens it by that name.

* fix(images): POSIX opener paths stay absolute under Python 3.13 on Windows

Python 3.13 on Windows no longer counts "/usr/bin/x" as absolute, so the
faked-Linux reveal tests failed on windows-latest py3.13 (the Linux and macOS
openers are POSIX paths whatever host evaluates them). Use posixpath.isabs for
those lookups.
2026-10-21 00:53:04 -07:00
Vito 0a7b0238ca feat(library): concept prompt library cards, toolbar and caption (#263)
* feat(library): concept toolbar and caption

The Prompt library shelf uses the shared concept toolbar (search, category,
sort, Filters): sorts are Recently added / Name A-Z / Highest rated, then
Oldest first; the Tags filter moves to "Refine this view" (D14) and Refresh
library is removed (D15), with Retry kept and re-reading failed categories.
The caption is the concept's .section-caption, "N positive prompts · M
negative variants" over the filtered set from variant_total.

Library-embedded media cards keep the favorite star working: the Library
gallery read already reports favorite (one batched lookup per page, now
covered by a test), and star answers are delivered to every mounted embedded
gallery, so a star toggles, repaints and survives a reload. The checkbox and
selection bar stay Images-only.

* feat(library): concept prompt cards with real runs and last use

The Prompt library shelf renders the concept's article.prompt-card:
.card-art (up to two previews), .card-title with a title button and the
category chip, the positive prompt block, .card-meta ("− N negative
variants", "N runs", "N images"), .tags, and .card-footer with the rating
and last-use line and the positive send plus Explore history buttons.

D23: "N runs" is the parent's run_count; the footer reads Used today /
yesterday / N days ago / <Mon D> in the viewer's local calendar days, or
"Not used yet"; stars carry a visually hidden "Rated N out of 5" and an
unrated parent shows no stars. The positive is clamped to two lines while
copy and send use the full text. Long titles wrap inside the title row
and the category chip never breaks mid-word. Card actions are named after
the prompt ("Copy positive prompt: <title>") through a new title option on
the shared prompt blocks. The production .library-card CSS is removed and
the row estimates follow the clamped card height.

The shelf error now reads "Prompt library could not be loaded. Retry."
(Refresh is gone). TESTING.md records D23, the production-only shelf copy
(D40) and the concept search placeholder kept on the Library (D41).

* test(library): concept comparison for the shelf

library-concept.spec.mjs compares the Prompt library shelf with the
immutable concept on the concept-shaped fixture: heading, toolbar,
caption, grid and the six cards (art, title row, prompt block, meta,
tags, footer) at 1600x1100 and 390x844 in both themes and motion
settings, the 3-column grid at 1800x1100, and the behaviors in both
documents (title and Explore history open history, the footer send
carries the positive only, copy toasts "Prompt copied.", the "neon"
search and the Landscapes category). A mutation test proves the
comparison notices a footer padding change and a removed variants chip.

mapLibraryConcept applies only recorded deviations: D14, D16, D23 (title
min-width, singular variant chip, spoken rating, the two-line clamp the
concept's samples exceed at 390px), D35, D36, D40 and the new D42 (tag
chips in the store's alphabetical order). Each re-laid-out part is
checked against the concept page with that deviation applied at runtime.

Corrections the comparison demonstrated:
- the fixture creates the concept's prompts last to first, so Recently
  added lists them in the concept's order;
- the shelf no longer ends with the legacy "Current prompt library"
  link; Overview's legacy cards keep it, as with "Current gallery".

The shared inventory gains the Library section, and reflow/withoutItems
move into concept-inventory.mjs for both comparison specs.

* test(workspace): reach the legacy Library from Overview, not the shelf

The shelf no longer links the legacy Library (#249 E3). The shell
destinations test now expects no legacy link on Prompt library and
asserts Overview's "Open existing prompt library" card still links
/prompt_manager/admin and that the page loads.

* feat(library): Filters shows its active count; final review follow-ups

- D44: the shared toolbar's Filters button reads "Filters · N" while N
  "Refine this view" filters are active (Library: Tags; Images: Source,
  Folder, Media, Tags), so a Browse-set Tags filter is visible on the
  shelf. The text is the accessible name. Unit and browser tests cover
  Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts.
- Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's).
- The caption count is a role=status region written only when it changes
  and kept shown while the shelf reloads.
- usedLabel names the year for a use in another calendar year (D23).
- prompt-blocks: same `title` option API as #264 (`subject` suffix); the
  region-name helper's parameter no longer shadows it.
- Scope library.css .row-actions rules to the history panels; fix stale
  CSS and spec comments.
- Retry re-reading a failed category list and the no-op Apply/search
  paths are covered by a browser test.
- TESTING.md: D23 amendments (year, button vs link trade-off), D44, the
  legacy Library link wording.
2026-10-20 23:26:05 -07:00
Vito 00bda8f442 feat(inspector): concept prompt blocks, side panel and viewer details (#260)
* feat(workspace): concept prompt blocks

Shared prompt blocks (Images panel, viewer details, Library cards and
saved texts, recorded runs) now render the approved mock's markup
(app.js promptBlock a298-302, concept.css s550-588), #249 D22:

- section.prompt-section.<role>[aria-label="<Role> prompt"] >
  .prompt-heading (span.role-label "+ Positive prompt" / "- Negative
  prompt"; .prompt-actions: copy button.icon-btn "Copy <role> prompt",
  then send button.send.icon-btn "Send <role> prompt to ComfyUI") +
  p.prompt-text.
- No per-block source line; a file's evidence moves to the panel badge
  (D2) and "File & provenance" (D3). Recorded runs keep every field's
  source in "Frozen field provenance".
- Empty text reads "No negative prompt (explicitly empty)" and still
  sends an explicit clear. Absent, ambiguous and unresolved roles render
  p.prompt-text.muted with the existing status strings and no actions;
  unknown role names render nothing and cannot be sent or copied.
- Copy shows the shared toast "Prompt copied."; without a clipboard it
  opens the concept's "Copy prompt" select-to-copy dialog.
- The data-send-role / data-copy-role contract is unchanged.

The D22 interim overrides (26px actions, prompt-text margins, copy
status, source line) are retired so the concept's rules apply; the
action buttons measure the mock's 31x31. pre.prompt-text styling stays
only for the Library editor's Archive dialog and frozen field
provenance, which keep their own markup until their page rebuilds.

Test selector mapping:
  .prompt-block / .prompt-positive  -> .prompt-section(.positive)
  .prompt-block-heading h3          -> .prompt-heading span.role-label
  region pre / pre.prompt-text      -> p.prompt-text
  "Empty negative prompt"           -> "No negative prompt (explicitly empty)"
  "Send <role> prompt"              -> "Send <role> prompt to ComfyUI"
  .prompt-source / copy status line -> removed (toast or Copy prompt dialog)
  copy button 30x26                 -> 31x31 (measured in the mock)

* feat(inspector): concept side panel

The Images side panel is the concept's aside#image-inspector.detail-panel in the
order of app.js imageDetails: plain cover, heading with the format chip, filename,
the D21 evidence badge and category chip, prompt blocks, tag chips, the six facts
and the footer (Send to ComfyUI pair, Prompt history). Provenance, raw records and
downloads stay in the viewer's full details. Positive-scope tag chips set the
Images Tags filter to that tag.

* fix(inspector): mark the side panel's overlay rules with their D2 decision

The overlay lint (tests/vendor/concept-css.test.mjs) requires a /* D<n> */
marker on every workspace.css rule that overrides a concept selector; the
three .detail-panel rules from D2 lacked one.

* feat(inspector): full details in the viewer

The viewer's #viewer-details renders the concept's imageDetails(i, true):
the side panel content without the cover in a class-less wrapper, then
"File & provenance" and "Workflow & raw metadata" (pre[tabindex=0] > code
and the two downloads with their unavailable reasons), then the pair send
("Send to ComfyUI", labelled "Send prompt pair to ComfyUI: <title>") and
Prompt history buttons. One renderer now serves both variants.

Provenance wording (maintainer, 2026-10-20): the six per-fact
"<Fact>: Configuration evidence · <source>" lines and FACT_SOURCES are
gone; the concept's "Prompt evidence: <badge>" line carries the D21 badge
in lower case and is left out without a badge. The honest facts stay
(current-file note, Source, Path, Size, Dimensions, Format, Media type,
Modified, occurrence, association, Download original). An unverified
format reads "PNG extension", no stray leading dot.

inspector.css and images.css are deleted; only rules with a live
consumer moved to the overlay under #249 D3 (inspector wrapping, the
inert aria-disabled download, the narrow Images container, the Library
and recorded-run prompt blocks, the Archive negative's prompt-block, the
prompt sender) plus a D20 wrap rule for the viewer's buttons at 200% zoom.

D1 review follow-ups: library.css no longer restyles recorded-run
p.prompt-text.muted (m2); the copy fallback has a seam and unit tests for
a missing clipboard and a refused write (m3); a stale test title is
renamed (m4); the fallback trigger falls back to the same-role button
when the clicked one was detached by a re-render (m7); TESTING.md records
the D20 and D22 deviations (m1).

Selector mapping in tests: .file-inspector-inner -> .panel-heading,
.generation-facts -> .meta-grid (first), .file-facts -> details .meta-grid,
.file-name -> #viewer-details > div > p.small, .file-inspector-footer ->
#viewer-details, viewer "Send prompt pair" -> /^Send prompt pair to ComfyUI: /.

* test(comfyui): real-host specs read the concept prompt blocks

library-sender: an explicitly empty saved negative now reads the concept's
"No negative prompt (explicitly empty)" (D22). The prompt-sender spec's
panel and viewer selectors (complementary "Selected image details", the
card image opening the viewer, .meta-grid facts, the labelled pair send)
moved with the D3 commit.

* test(comfyui): the frozen run's prompt regions read the concept prompt text

* fix(inspector): coarse viewer-detail targets and Prompt history on the shown route

- Prompt history closes the viewer and, when its target is the route
  already shown, re-renders it as the concept's go() does (navigateTo
  plus a router refresh event), instead of doing nothing.
- The viewer details' disclosure summaries and "Download original" keep
  40px targets on coarse pointers (#249 D34), as TESTING.md promises.
- Drop the dead `.prompt-positive` rules and merge the two
  `pre.prompt-text` rules.
- TESTING.md records the side panel's D20/D21 deviations.
2026-10-20 20:15:21 -07:00
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
73 changed files with 5786 additions and 964 deletions
+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
+283 -9
View File
@@ -225,6 +225,19 @@ managed host with both renderer startup configurations.
The shared inline and collection inspector reads the current rooted file's supported
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
two downloads), then the pair send and Prompt history buttons. D22 words an empty
positive explicitly and makes confirmed prompt text focusable.
`tests/browser/inspector-concept.spec.mjs` compares both surfaces with the concept
(see the D20–D39 notes under Images).
`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. `viewer matches the concept shell and
@@ -365,13 +378,14 @@ leftovers such as `library.css` `.row-actions`, name the PR that retires them, b
convention.
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.
still kept (`library.css`; test `kept page stylesheets mark their concept-selector
overrides`). PR D deleted `images.css` and `inspector.css` (test `the retired shell,
vocabulary, editor, Images and inspector stylesheets stay deleted`); their few
production-only rules with a live consumer (inspector wrapping and the inert download,
the narrow Images container, the Library and recorded-run prompt blocks, the Archive
negative's `section.prompt-block`, the prompt sender) moved to the overlay under
`#249 D3`, and `the overlay keeps no retired Images page or inspector rules` keeps the
replaced card, filter, toolbar, preview, facts and inspector 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
@@ -565,6 +579,122 @@ Recorded deviations on this page (spec §8):
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.
- **D44** (#249 PR E): Filters reads "Filters · N" while N of Source, Folder, Media and
Tags are set in "Refine this view" (see the Prompt library deviations).
- **D20**: the side panel shows only the concept content; "File & provenance",
"Workflow & raw metadata" and the downloads are only in the viewer's details
(`#viewer-details`: the panel content without the cover in a class-less wrapper, the
two disclosures, then the pair send and Prompt history buttons). "File & provenance"
keeps the honest facts (current-file note, Source, Path, Size, Dimensions, Format,
Media type, Modified, the reported-location occurrence, the saved-association
sentence, Download original) and the concept's `Prompt evidence: <badge>` line in the
D21 badge's words in lower case, left out when there is no badge. Raw records keep
their names, representations and the two downloads with their unavailable reasons;
the viewer's pair send and history buttons may wrap at 200% zoom. Tag chips in the
viewer are plain (the concept's go to the Library). The details' disclosure
summaries and "Download original" keep 40px coarse targets (D34). Prompt history
closes the viewer and, when its target is the route already shown, re-renders it
(the concept's `go()`).
- **D20/D21 side panel**: the sticky panel scrolls inside the window
(`max-block-size` with `overflow: auto`, not the concept's `overflow: hidden`), so
long real prompts keep the footer reachable; an empty badge or tag `.tags` row is
left out (the concept always renders both); the footer, its pair send and Prompt
history appear only with resolved prompt roles or a saved link; a video file keeps
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
- **D38 Show in folder** (maintainer decision 2026-10-20, beyond the mock): in the
viewer's File & provenance the Path value stays text, followed in its `dd` by a
"Show in folder" button (`Show <filename> in folder`), only for a listed file
(`root_index`, `root_scope`, `path`), so never in the side panel or a recorded run.
It POSTs that identity as JSON to `/prompt_manager/images/reveal` and toasts:
| Result | Toast |
|---|---|
| Success | "Opened the folder on this computer." |
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
| 501 `unavailable` | "No file manager is available on this computer." |
| 429 `busy` | "The folder is already opening." |
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
The route is local-only. It answers 403 `not-local` unless all of these hold: no
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
`null`, another port or another scheme is refused; browsers send `Origin` on every
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
relative path; absolute, drive and `..` paths are 400), the scope goes through
`_gallery_scope_error` (409 `source-changed`), and the path resolves only inside
that root to a regular media file (403/404; a resolution `OSError` such as
`ENAMETOOLONG` is the same JSON 404, and the client's path is never logged at error
level). One reveal runs at a time per process with a 1 s cooldown after it
(`RevealGate`, 429 `busy`), on its own single worker (`reveal_executor`), never
ComfyUI's default executor. Just before launching, `utils/file_reveal.py` re-checks
that the real path is still a regular file inside the root (a swapped parent is
404). Windows starts no process: `ILCreateFromPathW` and
`SHOpenFolderAndSelectItems` select the file through the shell API (Explorer's own
command line splits on commas, so a path like `output\a,\Windows\...` must never
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
the shell API and `os.startfile`; the browser server's opener is a stub
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
closed disclosures as hidden, so the button needs no mapping there.
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
from a local client, and a non-browser client behind it can forge a matching
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
screen, at most once a second, and learn whether that file exists (which the
listing routes already tell it). Operators who expose ComfyUI that way accept this.
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
category when set, otherwise the linked positive prompt's, and its recipe's own tags
when it has any, otherwise the positive's; each field falls back on its own and the
two tag sets are never merged. Set category (#259) writes the positive's category and
the Images category and Tags filters match the positive's, so the panel now agrees
with them whenever the recipe adds nothing. The inspection response names each
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
they are the positive's (#260 D2). History's media inspector uses the same rule. The
concept shows the prompt's category and tags for every image and the concept
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
- **D42**: the concept never shows a toast and a page alert together. Production keeps
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
and, when an Import → Rescan toast leaves the Images discovery alert
(`.options-status`), scrolls that alert into view only as far as needed, with
`scroll-margin-block-end: 96px`, so it is never under the toast.
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
the toast's band at 1600 and 390 pixels and measures both boxes.
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
offered control's name and minimum height. Each role's copy and send are named after
the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads
all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
same "Copy prompt" dialog in both); the pair send previews both roles in the concept
and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
chip sets the Images Tags filter (D21: the concept searches the word, which also
matches the untagged forest prompt's text); Prompt history goes to
`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept`
applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
viewer's plain tag chips, laid out by the concept page itself with its chips made
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
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
@@ -574,10 +704,142 @@ saved file.
```sh
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
workspace-images.spec.mjs full-inspector.spec.mjs \
workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
### Prompt library cards and recorded deviations (#249 PR E)
The shelf uses the shared concept toolbar and caption (E1) and the concept's
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
the measured row heights on Chromium and touch.
Recorded deviations on this page:
- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
The meta chip reads "− N negative variants" (singular for one). The title and Explore
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
against the old links is that they offer no open in a new tab, middle-click or URL on
hover. The positive is clamped to two lines
(`.prompt-card .prompt-text`); copy and send use the full text. A long title wraps
inside the title row, and the category chip keeps whole words ("Uncategorized" for
none). A parent with one preview, or none, fills the art row; no preview reads "No
available image previews" (`.small`). Each card's actions add its title to their names
("Copy positive prompt: <title>", as the concept's do).
- **D40**: production-only shelf copy the concept never shows. A failed page reads
"Prompt library could not be loaded. Retry." beside "Retry prompt library" (no Refresh,
D15). A failed category list reads "Categories could not be loaded. Open the Prompt
library again to retry." (Retry re-reads it only when the shelf failed too). The
continuation keeps "Loading prompt library…", "No matching prompts. Try changing the
filters." (the concept: "No matching prompts."), "You’re all caught up.", "The saved
prompt is hidden by the current filters." and "Load more prompts".
- **D41**: the search keeps the concept's shared placeholder "Search prompts, tags,
filenames…" for parity with Images, although Library search matches positive text and
titles and saved negative text and variant titles, not filenames or tags (Tags live in
"Refine this view", D14).
- **D43**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`,
as everywhere tags are read); the concept lists each sample's tags in authored order.
- **D44**: the shared toolbar's Filters button reads "Filters · N" while N "Refine this
view" filters are active, and plain "Filters" otherwise; the text is its accessible
name, so the count is spoken. The concept has no indicator, and without one a Tags
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
not a Refine filter and is not counted. The caption's count is a `role="status"`
region, written only when it changes and kept shown while the shelf reloads, so
Load more and scrolling never re-announce it.
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
concept-shaped fixture, which creates the concept's prompts last to first so Recently
added lists them in the concept's order: heading, toolbar, caption, grid and the six
cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in
both themes and motion settings, and the grid and cards at 1800x1100 (3 columns).
Behaviors, in both documents: the title and Explore history open the prompt's history
(its `h1` is the title), the footer send carries the positive only (a bridge host page
records the roles production's preview asks for), copy writes the full positive with
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save
view; the toolbar flows again, checked against the concept without it), D23 (the
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
concept's samples exceed at 390px, checked against the concept with the clamp rule
applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
existing prompt library") and the sidebar still link `/prompt_manager/admin`
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
Manual check (quick), after a browser refresh on Prompt library: each card shows the
title with its category chip on one line, two lines of the positive with copy and send,
"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used
today" (or the real date) with send and "Explore history"; Filters → Tags still narrows
the shelf, and the button then reads "Filters · 1" (D44), also after a Browse from Tags &
categories.
```sh
npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304,
style.css 1059–1179). The summary is `article.surface` with the category chip, the
positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images
· N negative variants" (tracked runs from the recorded window's `recorded_run_count`).
The panel is `section.surface.timeline-panel` (region "Saved negative variants") with
Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane`
rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, "<name> · N runs" and a
`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and
All images for this positive. Recorded executions, frozen fields, send and archived
lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and
`workspace-library-editor.spec.mjs` cover them on Chromium and touch.
Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the
selected pair's roles load), **D25** (Compare negatives compares a baseline with the
selected red dot, and lists the selected side's added comma terms) and **D26** (All
images for this positive is a dialog with its own bounded gallery; the run dot opens
Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the
pair's newest linked image, reading the archived gallery for an archived pair).
Recorded deviations on this page:
- **D45**: a variant dot is named "Select negative <title>" when no other loaded lane
shares that title (compared trimmed), else "Select negative variant N"; a title that
itself reads "variant N" always uses the lane's own number, so no two names repeat.
The bounded "N of M saved negative variants" count stays a visually hidden status,
and production's "Saved combinations, not an execution timeline." is removed.
Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6
marker emphasis (their text stays at full contrast) with their Restore button. Under
`pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one
(`::after` overlays). While a run dot shows, the lane text wraps within 139px before
it. The panel heading, the legend row and All images wrap at 390px and 200% zoom.
- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's
baseline is the oldest (lowest-id) active variant, not the first lane; while the
variant page has more, the oldest is not loaded and the section reads "Load more
negative variants to reach the baseline." instead of guessing. The two blocks are
named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats.
Compare negatives is disabled while the positive has no active variant. The page
opens on the newest lane (existing behavior), while the concept opens on
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
<newest>" where the concept compares the baseline with itself. The history concept
comparison (PR F-b, F5) maps this.
The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest`
(see "Chronological Library history"), and each lane's "N runs" is the pair's
`run_count` (see "Workspace data fields").
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -676,7 +938,9 @@ 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.
reads it from the list state. Each detail variant (active and archived) also carries
its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes
show as "N runs" (#249 F2).
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
`{root_index, path}`. Both are idempotent and answer
@@ -901,10 +1165,20 @@ The versioned public reads are:
| Route | Meaning |
| --- | --- |
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
The newest-pair read takes no query. It answers 404 when the positive is not an
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
raw-text scope newest first and follows only a row whose `reference_epoch` is the
current epoch and whose saved-pair hint still resolves to that pair (the
`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints
report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside
the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a
storage failure 503 `storage-unavailable`.
Run and output reads accept the returned `epoch`; current-file routes require
the returned epoch, current scope and occurrence coordinates. Run integers are
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
+24 -11
View File
@@ -4,9 +4,17 @@ import os
import sqlite3
try:
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from ..utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
except ImportError:
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
from utils.inspection_response import (
MAX_LIBRARY_BYTES,
_library_projection,
library_metadata,
)
def _display_bytes(value: bytes | None) -> str | None:
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
return saved, _library_projection({"status": "limited"})
pair = pairs[0] if pairs else None
title = pair["title"] if pair is not None else None
category = pair["category"] if pair is not None else row["category"]
tags, limited = _tags(
conn,
pair["id"] if pair is not None else row["prompt_id"],
pair=pair is not None,
recipe_tags, limited = (
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
)
positive_tags = []
if not recipe_tags and not limited:
# D39: only read the positive's tags when they are what the panel shows.
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
metadata = library_metadata(
row["category"],
positive_tags,
(pair["category"], recipe_tags) if pair is not None else None,
)
limited = limited or any(
value is not None and len(value) > MAX_LIBRARY_BYTES
for value in (title, category)
for value in (title, metadata["category"])
)
if pair is not None:
saved["pair"] = {
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
"positive_id": row["prompt_id"],
"pair_id": pair["id"] if pair is not None else None,
"title": _display_bytes(title),
"category": _display_bytes(category),
"tags": tags,
"metadata_scope": "pair" if pair is not None else "positive",
**metadata,
"category": _display_bytes(metadata["category"]),
}
)
if limited:
+59 -1
View File
@@ -10,7 +10,7 @@ import re
import sqlite3
from typing import Iterator
from .execution_contexts import content_digest
from .execution_contexts import _hints, _values, content_digest
from .execution_history import HistoryEpochChanged
from .execution_outputs import _capture, _REFERENCE_COLUMNS
from .library import _ELIGIBLE
@@ -98,6 +98,8 @@ _ORDER = (
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
)
# Stale-hint rows a newest-pair lookup inspects before it reports none.
_PAIR_RUN_CANDIDATES = 16
_MATCHING = (
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
@@ -410,6 +412,62 @@ class HistoryReadOperations:
result["context_summaries"] = list(ordered.values())
return result
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
"""The newest matching run whose current saved-pair hint names `pair_id`.
None when the positive is not an eligible saved prompt or the pair is
not one of its pairs; ``{"run": None}`` when no recorded run names it.
Only a hint captured in this epoch and still resolving to the same pair
counts, as in ``get_history_run``.
"""
conn = self._history_connection()
saved = conn.execute(
"SELECT CASE WHEN typeof(p.text)='text' AND "
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
(positive_id,),
).fetchone()
if saved is None or (
conn.execute(
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
(pair_id, positive_id),
).fetchone()
is None
):
return None
if saved["text"] is None:
return {"run": None}
epoch = self.get_execution_epoch()
candidates = conn.execute(
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
"c.positive_snapshot_id,c.negative_snapshot_id "
"FROM execution_runs r JOIN execution_run_contexts rc "
"ON rc.run_id=r.run_id JOIN execution_contexts c "
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
"ON s.snapshot_id=c.positive_snapshot_id "
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
(
content_digest("text", (saved["text"],)),
pair_id,
epoch,
_PAIR_RUN_CANDIDATES,
),
).fetchall()
for row in candidates:
positive = _values(conn, row["positive_snapshot_id"])
negative = _values(conn, row["negative_snapshot_id"])
if positive is None or positive["raw"] != saved["text"]:
continue
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
if hints[2] == pair_id:
return {
"run": {
key: row[key] for key in ("run_id", "anchor_id", "context_id")
}
}
return {"run": None}
def get_history_run(self, run_id: str) -> dict | None:
conn = self._history_connection()
run = self.get_execution_run(run_id)
+3 -1
View File
@@ -388,7 +388,7 @@ class LibraryOperations:
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,pp.archived,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
@@ -404,6 +404,8 @@ class LibraryOperations:
row["tags"] = []
row["archived"] = bool(row["archived"])
row["explicit_empty"] = bool(row["explicit_empty"])
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
if by_id:
marks = ",".join("?" for _ in by_id)
for row in conn.execute(
+37
View File
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
routes.get("/prompt_manager/library/{positive_id}/history")(
self.get_library_history
)
routes.get(
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
)(self.get_newest_pair_run)
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
self.get_history_outputs
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
"""The pair's newest recorded run for the history run dot (#249 D26)."""
_query(request)
if request.query:
raise ValueError("Invalid newest pair run query")
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
fence = self._history_fence()
db = self.db
def project() -> bytes:
generation = fence.begin()
try:
with db.recorded_history_snapshot() as epoch:
result = db.get_newest_pair_run(positive_id, pair_id)
finally:
db.model.close()
if result is None:
fence.validate(epoch, generation)
raise HistoryReadError(404, "not-found")
encoded = _serialize_history(
{
"success": True,
"history_version": 1,
"history_epoch": epoch,
**result,
}
)
fence.validate(epoch, generation)
return encoded
return _success_response(await self._run_in_executor(project))
@_history_errors
async def get_history_run(self, request: web.Request) -> web.Response:
_query(request)
+191 -20
View File
@@ -36,16 +36,38 @@ try:
from ...database.models import normalize_image_path
from ...utils.card_seed import card_seed, header_params, parsed_positive
from ...utils.parallel import map_parallel
from ...utils.inspection_response import encode_inspection_response
from ...utils.inspection_response import (
encode_inspection_response,
library_metadata,
)
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
from ...utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
except ImportError:
from database.image_favorites import MAX_KEY_LENGTH
from database.library import display_title
from database.models import normalize_image_path
from utils.card_seed import card_seed, header_params, parsed_positive
from utils.parallel import map_parallel
from utils.inspection_response import encode_inspection_response
from utils.inspection_response import (
encode_inspection_response,
library_metadata,
)
from utils.prompt_inspector import MetadataReadError, read_inspection_file
from utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
reveal_in_file_manager,
)
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
@@ -522,6 +544,51 @@ def _favorite_target(data: Mapping, roots: list[Path]):
return (index, relative), None
def _reveal_target(data: Mapping):
"""``((index, scope, relative), None)`` for a reveal body, else an error.
Only the listing identity is accepted: an int root index, the root's
64-hex scope and a bounded root-relative path, never an absolute,
drive-qualified or ``..`` path.
"""
index, scope, relative = (
data.get("root_index"),
data.get("root_scope"),
data.get("path"),
)
if type(index) is not int or index < 0:
return None, bad_request("Invalid gallery root")
if not isinstance(scope, str) or re.fullmatch(r"[0-9a-f]{64}", scope) is None:
return None, bad_request("Invalid gallery root scope")
if (
not isinstance(relative, str)
or not relative
or len(relative) > MAX_FAVORITE_PATH
or "\0" in relative
or _has_traversal(relative)
or re.match(r"^[a-zA-Z]:", relative)
):
return None, bad_request("path must be a relative media path")
return (index, scope, relative), None
def _reveal_not_found():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
def _not_local():
return web.json_response(
{
"success": False,
"code": "not-local",
"error": "Show in folder works only on the computer running ComfyUI.",
},
status=403,
)
# The path module whose case rules key favorites (tests swap in ntpath).
_path_flavor = os.path
@@ -828,6 +895,11 @@ class ImageRoutesMixin:
async def clear_image_favorite_route(request):
return await self.update_image_favorite(request, favorite=False)
# POST, local-only: opens this computer's file manager (D38).
@routes.post("/prompt_manager/images/reveal")
async def reveal_image_route(request):
return await self.reveal_image(request)
# POST: drops the server-side listing cache and rewalks the output roots.
@routes.post("/prompt_manager/gallery/rescan")
async def rescan_gallery_route(request):
@@ -2156,6 +2228,108 @@ class ImageRoutesMixin:
}
)
# Swapped by tests and the disposable browser server; never a shell.
_reveal_opener = staticmethod(reveal_in_file_manager)
# One per process (a class attribute): single flight plus a cooldown.
_reveal_gate = RevealGate()
@staticmethod
def _reveal_path(relative: str, root: Path) -> Path | None:
"""The real path of a regular media file under *root*, else None."""
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
if resolved is None or _media_access_error(resolved, [root]) is not None:
return None
real = Path(os.path.realpath(resolved))
return real if real.is_file() and _is_within(real, root) else None
async def reveal_image(self, request):
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
Local-only (a same-origin browser request from a loopback client, no
forwarding header) and JSON-only: the browser must run on the
computer whose file manager opens. One reveal at a time (429 busy).
"""
if not is_local_request(request.remote, request.headers, request.scheme):
return _not_local()
if request.content_type != "application/json":
# A non-JSON POST is a CORS "simple" request with no preflight.
return web.json_response(
{
"success": False,
"code": "unsupported-media-type",
"error": "Send the request as application/json.",
},
status=415,
)
data, error = await _json_body_or_error(request)
if error is not None:
return error
target, error = _reveal_target(data)
if error is not None:
return error
index, scope, relative = target
roots = list(self._get_all_output_dirs())
error = self._gallery_scope_error(
{"root_scope": scope, "root": str(index)}, roots
)
if error is not None:
return error
root = roots[index]
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be shown")
gate = self._reveal_gate
if not gate.try_enter():
return web.json_response(
{
"success": False,
"code": "busy",
"error": "The folder is already opening.",
},
status=429,
)
try:
return await self._reveal(relative, root)
finally:
gate.leave()
async def _reveal(self, relative: str, root: Path):
"""Resolve and open, inside the reveal gate."""
try:
path = await self._run_in_executor(self._reveal_path, relative, root)
except (OSError, ValueError) as exc:
# ENAMETOOLONG and the like: the client's path is not logged.
self.logger.debug(
"Show in folder could not resolve: %s", type(exc).__name__
)
return _reveal_not_found()
if path is None:
return _reveal_not_found()
try:
await asyncio.get_running_loop().run_in_executor(
reveal_executor(), self._reveal_opener, path, root
)
except RevealTargetChanged:
return _reveal_not_found()
except OpenerUnavailable:
return web.json_response(
{
"success": False,
"code": "unavailable",
"error": "No file manager is available on this computer.",
},
status=501,
)
except Exception as exc:
# The exception text can hold the path: only its type at error
# level, the traceback at debug.
self.logger.error("Show in folder failed: %s", type(exc).__name__)
self.logger.debug("Show in folder failure", exc_info=True)
return web.json_response(
{"success": False, "error": "The folder could not be opened."},
status=500,
)
return web.json_response({"success": True})
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
"""``{relative key: listing POSIX spelling}`` for one root's scan.
@@ -2296,7 +2470,8 @@ class ImageRoutesMixin:
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
if not isinstance(saved, dict) or not saved:
return result
@@ -2304,23 +2479,19 @@ class ImageRoutesMixin:
result["status"] = "ambiguous"
return result
pair = saved.get("pair")
if isinstance(pair, dict):
result.update(
status="linked",
positive_id=pair.get("positive_prompt_id"),
pair_id=pair.get("id"),
category=pair.get("category"),
tags=pair.get("tags"),
metadata_scope="pair",
)
else:
result.update(
status="linked",
positive_id=saved.get("prompt_id"),
category=saved.get("category"),
tags=saved.get("tags"),
metadata_scope="positive",
)
recipe = isinstance(pair, dict)
result.update(
status="linked",
positive_id=(
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
),
pair_id=pair.get("id") if recipe else None,
**library_metadata(
saved.get("category"),
saved.get("tags"),
(pair.get("category"), pair.get("tags")) if recipe else None,
),
)
return result
async def get_image_prompt(self, request):
+214 -2
View File
@@ -15,6 +15,62 @@ export const MOTIONS=Object.freeze(['no-preference','reduce']);
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');
/**
* 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. */
export function withoutItems(row,isRemoved) {
return reflow(row,row.items.filter(item=>!isRemoved(item)));
}
/** 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});
@@ -95,7 +151,8 @@ export async function mutate(page,{css=null,remove=[]}={}) {
* 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.
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
* caption, grid, first six cards and whatever follows the grid; history stays null until PR F.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
@@ -333,6 +390,57 @@ export function measureInventory(scope) {
function bulkbarOf(bar) {
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
}
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
// so every Library box is found by descendant and placed relative to `main` or its own card.
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
function libraryCardOf(card,origin) {
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
children:[...card.children].map(classes),
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
...css(node,'objectFit')}))})),
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
buttons:[...group.children].map(node=>controlOf(node,group))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
}
function libraryOf() {
const grid=main?.querySelector('.library-grid');
if(!grid)return null;
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
const cards=[...grid.querySelectorAll('.prompt-card')];
// Whatever the page shows after the grid, in document order (the concept: nothing).
const trailing=[];
for(let node=grid;node&&node!==main;node=node.parentElement)
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
const style=getComputedStyle(grid);
return {toolbar:when(toolbar,rowOf),
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
top:relative(span,caption).top,height:relative(span,caption).height}))})),
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
@@ -342,5 +450,109 @@ export function measureInventory(scope) {
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};
library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
/**
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
*/
export async function inspectorOf(page) {
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
}
/** Runs in the page; self-contained. Every box is relative to the measured root. */
export function measureInspector(selector) {
const root=document.querySelector(selector);
if(!root)return null;
const px=value=>Math.round(value);
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
const rect=node=>node.getBoundingClientRect();
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
const classes=node=>[...node.classList].sort().join(' ');
const when=(node,measure)=>node?measure(node):null;
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
return copy.textContent.replace(/\s+/g,' ').trim();};
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
function iconOf(node) {
const svg=node.querySelector(':scope > svg.icon');
if(!svg)return null;
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
}
function controlOf(node) {
const style=getComputedStyle(node);
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
}
function chipOf(node) {
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
minHeight:getComputedStyle(node).minHeight};
}
function rowOf(row) {
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
}
function sectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
return {className:classes(section),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
}
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
const panel=root.classList.contains('detail-panel');
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
// `display: contents` is transparent: its sections count as the wrapper's own children.
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
const firstSection=parts.indexOf(sections[0]);
const rows=parts.filter(item=>item.classList.contains('tags'));
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
const filename=parts.find(item=>item.matches('p.small'));
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
return {
className:classes(root),label:root.getAttribute('aria-label'),
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
inner:when(inner,()=>({...edges(inner),...box(inner)})),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
};
}
+321 -80
View File
@@ -75,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: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_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);
@@ -89,7 +89,9 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
await expect(await request.post('/__test__/reset')).toBeOK();
});
const inspector=page=>page.locator('.image-details');
const inspector=page=>page.locator('.detail-panel');
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
const openSelected=page=>page.locator('.media-card.selected .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();
@@ -99,47 +101,257 @@ async function downloadText(page,action) {
const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
}
/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */
async function viewerDetails(page,name,root=0) {
await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;
}
/** Rewrites the next inspection reads; `null` passes them through. */
async function patchInspection(page) {
const state={patch:null};
await page.route('**/images/inspect/**',async route=>{
if (!state.patch) return route.continue();
const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data});
});
return state;
}
/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */
const panelStructure=panel=>panel.evaluate(node=>{
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
const inner=node.querySelector(':scope > .detail-inner');
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
});
test('panel matches the concept order and copy',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector');
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
const badges=panel.locator('.detail-inner > .tags').first();
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded');
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
await selectFile(page,'malformed.png');
await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.');
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
});
test('tag chip filters Images by that tag',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 expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK();
// workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches.
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0);
// #249 D44: the chip's Tags filter shows on the toolbar's Filters button.
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric');
});
test('pair send and history appear only with real roles and link',async({page,request})=>{
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png');
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
await expect(panel.locator('details,summary,pre,a')).toHaveCount(0);
await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0);
await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/);
// The viewer keeps them until its own full details (D3).
const details=await viewerDetails(page,'complete.png');
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await details.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible();
});
/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */
const viewerStructure=details=>details.evaluate(node=>{
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
const inner=node.querySelector(':scope > div');
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
});
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
// D38: the Path value stays text, with "Show in folder" right after it.
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible();
// D21 wording for execution and embedded parameters; an unverified format names its extension without a dot.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click();
await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible();
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
await page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click();
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link, no buttons after the details.
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
});
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
await fullFixture(page,request);
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
const details=await viewerDetails(page,'complete.png');
const provenance=details.locator('details').first();await provenance.locator('summary').click();
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
const show=path.locator('dd > button');
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
const toast=page.locator('#workspace-toast');
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
expect(sent.method()).toBe('POST');
// The route takes only a same-origin JSON request, as a real browser sends it.
const sentHeaders=await sent.allHeaders();
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
const body=sent.postDataJSON();
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
await expect(toast).toHaveText('Opened the folder on this computer.');
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
expect(await opened()).toEqual(['output/inspector/complete.png']);
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
await show.click();await expect(toast).toHaveText(message);
}
// A reveal already in progress answers 429 busy.
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
await show.click();await expect(toast).toHaveText('The folder is already opening.');
await page.unroute('**/prompt_manager/images/reveal');
// A proxied request counts as remote, so the real route answers 403 not-local.
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
expect(await opened()).toEqual(['output/inspector/complete.png']);
});
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: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');
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
}
await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
const details=await viewerDetails(page,'complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection');
await details.getByText('File & provenance',{exact:true}).click();
await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file');
await expect(details.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/);
await expect(details.getByRole('button',{name:'Prompt history',exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
}
await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
if (testInfo.project.name === 'chromium') {
await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded();
const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'});
}
await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
});
test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
try {
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
@@ -148,14 +360,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque
});
test('full inspector downloads actual bounded evidence',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
await page.keyboard.press('Escape');panel=await viewerDetails(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
await page.keyboard.press('Escape');panel=await viewerDetails(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
});
@@ -163,9 +375,9 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{
test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{
// The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph.
await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});});
await fullFixture(page,request);const panel=inspector(page);
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png');
await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible();
await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1);
const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true');
@@ -177,8 +389,8 @@ 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.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();
await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads;
await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.meta-grid 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');
@@ -190,15 +402,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);
const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).toContainText('historical bytes are not verified');
expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
});
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: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);
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('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',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');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`));
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
// The side panel's Prompt history does the same.
await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click();
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
});
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
@@ -230,12 +459,12 @@ 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-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid 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('.meta-grid 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();
for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
for(const value of await panel.locator('.meta-grid dd').all())await expect(value).toHaveAccessibleDescription(explanation);
await page.evaluate(async()=>{
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
@@ -244,7 +473,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio
window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
});
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0);
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
});
@@ -252,27 +481,31 @@ test.describe('full inspector coarse pointer',()=>{
test.use({hasTouch:true,viewport:{width:390,height:844}});
test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.');
const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000);
const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{name:'Prompt history',exact:true})]) {
await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
}
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText);
}
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog');
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
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);
}
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
// #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well.
const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};}));
expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']);
for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);}
});
});
@@ -280,7 +513,7 @@ 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('.media-position');
async function openCollection(page) {
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await openSelected(page);
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
}
@@ -381,14 +614,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
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.locator('#viewer-details > div > p.small')).toHaveText(loaded[159].filename);
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 image viewer',exact:true}).click();
await expect(opener).toBeFocused();
await page.getByRole('button',{name:'Filters',exact:true}).click();
// #249 D44: Source and Folder are active.
await page.getByRole('button',{name:'Filters · 2',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');
@@ -397,11 +631,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
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 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 page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',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 image viewer',exact:true}).click();
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
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:');
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
await expect(position(page)).toContainText('of 2 loaded:');
});
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
@@ -453,7 +691,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
},preferences);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
@@ -519,20 +757,22 @@ test('slideshow production query and route disposal cancel the active deadline',
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');
await selectFile(page,'complete.png');await openSelected(page);await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
// A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer.
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}}));
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
for(const action of [
()=>modal.locator('.prompt-positive pre').click(),
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).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();},
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
]) {
@@ -576,7 +816,7 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
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('.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};
pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null};
})});
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
else await expect(rail).toHaveCSS('overflow-y','visible');
@@ -586,19 +826,19 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
await selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993');
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
await capture(`inline-${label}`);
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 expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').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 image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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 image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page);
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 selectFile(page,'control-heavy.png');await openSelected(page);
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 image viewer',exact:true}).click();
@@ -610,8 +850,8 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
await fullFixture(page,request);let deny=true;const originals=[];
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 openSelected(page);const modal=mediaDialog(page);
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
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');
@@ -632,19 +872,20 @@ test('collection viewer preserves original download and rooted retries',async({p
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();
await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
});
test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const send=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
const text=modal.locator('.prompt-section.positive p.prompt-text');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('.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';});
// D19: focus returns to the opening image; the panel follows the file the viewer moved to.
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key);
await openSelected(page);await page.evaluate(()=>{location.hash='/overview';});
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
@@ -674,14 +915,14 @@ test('collection viewer matches approved responsive frame',async({page,request},
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=>{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 rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeInViewport();
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
}
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await sendPair.focus();await expect(sendPair).toBeInViewport();
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
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'});
@@ -765,7 +1006,7 @@ test('collection viewer refits a late wrapped title without replacing current me
expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
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');
@@ -10,7 +10,7 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Negative variants are saved combinations; Recorded runs list the chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
@@ -35,13 +35,13 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-block',
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane',
branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]',
positiveLabel: '.library-history .positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-section',
prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery',
heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small',
footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
@@ -106,7 +106,7 @@ export async function exerciseCurrentPairSend(page, roles) {
} });
});
const viewer = page.locator('dialog#viewer-dialog');
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });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' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
+7 -58
View File
@@ -1,7 +1,7 @@
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';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} 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,
@@ -30,62 +30,6 @@ function captionDay(seconds,now=new Date()) {
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}"]`));
@@ -410,7 +354,12 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
await openProductionLoaded(page,theme,motion,total);
const before=await page.locator(CARDS).count();
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
await page.getByRole('button',{name:'Load more images',exact:true}).click();
// 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);
+353
View File
@@ -0,0 +1,353 @@
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,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
const TITLE='Alpine observatory';
const FIXTURE_IMAGES=24;
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
const PANEL_INSET=40;
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
// browser lays out what follows the taller summaries; a fine pointer never matches it.
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
}`;
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
// the Path value, then D20's Download original.
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
function withFocusablePrompts(inventory) {
const controls=[...inventory.controls];
for(const section of inventory.sections) {
const send=section.actions.buttons.at(-1);
const index=controls.findIndex(control=>control.name===send.name);
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
}
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
}
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
function withCategoryScope(inventory) {
const [badge,category,...rest]=inventory.badges.chips;
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
* production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
* what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
return {
panel:withCategoryScope(withFocusablePrompts({...panel,
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
className:`${panel.className} file-inspector`,
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
fileControls:[...fileControls,...FILE_CONTROLS],
};
}
function compare(reference,actual,viewport) {
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertInspectorMatch(reference,actual,viewport) {
const diff=compare(reference,actual,viewport);
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition 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 panelOf=page=>page.locator('#image-inspector');
/**
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
*/
async function openConceptPanel(page,theme) {
await openConcept(page,'images',theme);await page.mouse.move(0,0);
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await settle(page);
}
async function openProductionPanel(page,theme) {
await openProduction(page,'images',theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
button.replaceWith(span);return [span,button];
});
});
await settle(page);
return async()=>{
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
await restoreTargets();
};
}
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
async function fileControlsOf(page) {
const disclosure=page.locator('#viewer-details details').first();
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
const {controls}=(await inspectorOf(page)).viewerDetails;
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
const names=controls.map(control=>control.name);
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
}
/**
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
const restoreMutation=mutation?await mutate(page,mutation):null;
if(mutation)await settle(page);
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
const {panel}=await inspectorOf(page);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
async function fakeHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
}});
});
return host;
}
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
async function copyOf(page) {
// An earlier toast (the concept's Load more) is not the copy's outcome.
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
return result;
}
/** The concept's send: its dialog previews both roles (app.js openSend). */
async function conceptSendOf(page) {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return previews;
}
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
async function productionSendOf(page,context) {
const host=await fakeHost(context);
try {
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender).toContainText('Sent to ComfyUI.');
const applied=await host.evaluate(()=>window.applied);
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
return applied;
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
}
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
await settle(page);
return {count:await page.locator('#result-count').textContent(),
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
}
const historyOf=async page=>{
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
return page.evaluate(()=>location.hash);
};
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
test.setTimeout(180_000);
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
const viewport={width,height};
await openConceptPanel(page,theme);
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
const reference=await inventory(page,true);
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
await openConceptPanel(page,theme);concept.history=await historyOf(page);
await openProductionPanel(page,theme);
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
const actual=await inventory(page);
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
production.tag=await tagOf(page);await listing;
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
await page.keyboard.press('Escape');
await openProductionPanel(page,theme);production.history=await historyOf(page);
const diff=compare(reference,actual,viewport);
const path=testInfo.outputPath('inspector-geometry.json');
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
for(const section of panel.sections) {
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
}
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
const [positive,negative]=concept.send;
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
});
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
test('inspector comparison detects mutations',async({page})=>{
test.setTimeout(120_000);
const viewport={width:1600,height:1100};
await page.setViewportSize(viewport);
await openConceptPanel(page,'dark');
const reference=await inventory(page,true);
await openProductionPanel(page,'dark');
const {viewerDetails,fileControls}=await inventory(page);
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
assertInspectorMatch(reference,await measure(),viewport);
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
const cssMutated=await measure();
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
await mutate(page,{remove:['#image-inspector > .detail-footer']});
const domMutated=await measure();
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
});
+345
View File
@@ -0,0 +1,345 @@
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,reflow,withoutItems} from './concept-inventory.mjs';
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.library-grid .prompt-card';
const CONCEPT_PROMPTS=6;
/** Recorded Library 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 mapLibraryConcept(reference) {
const library=reference.library;
// D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`).
const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view');
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
});
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
}
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
const CLAMP_LINES=2;
/**
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
* grid's rows then close up by their tallest card's loss.
*/
export function clampedCards({grid,cards}) {
const shrunk=cards.map(card=>{
const text=card.section.text;
if(text.lines<=CLAMP_LINES)return {card,loss:0};
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
return {loss,card:{...card,height:card.height-loss,
content:{...card.content,height:card.content.height-loss},
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
footer:{...card.footer,top:card.footer.top-loss}}};
});
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
const rowLoss=top=>{
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
};
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
}
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
export function sortedTags(tags) {
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
}
const SECTIONS=['heading','library','noPageScroll'];
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertLibraryMatch(reference,actual) {
const diff=compare(reference,actual);
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
}
// Two frames, then every running transition 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))]);
});
// Every card image decoded, so nothing moves while it is measured.
const decoded=page=>page.evaluate(async()=>{
await document.fonts.ready;
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
});
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
/**
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
* layout. The concept page is restored afterwards.
*/
async function conceptInventory(page) {
const reference=await inventoryOf(page,'#main');
await page.evaluate(({plural,singular})=>{
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
for(const chip of window.__conceptPlurals)chip.textContent=singular;
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
return {...reference,oracle:{meta}};
}
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
async function sortConceptTags(page) {
await page.evaluate(()=>{
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
});
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
await openProduction(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
// The category list is read once per visit; it is part of the toolbar inventory.
await expect(page.locator('#category-filter option')).toHaveCount(5);
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
async function filteredOf(page,apply,undo) {
const before=await captionOf(page);
await apply();
await expect.poll(()=>captionOf(page)).not.toBe(before);
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
const result={caption:await captionOf(page),titles:await titlesOf(page)};
await undo();
await expect.poll(()=>captionOf(page)).toBe(before);
return result;
}
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
async function historyFrom(page,control,back) {
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
await page.locator(CARDS).first().locator(control).click();
const h1=page.locator('h1#page-title');
await expect(h1).toHaveText(title);
const result={title,h1:(await h1.textContent()).trim()};
await back();
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
return result;
}
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
async function behaviorsOf(page,{back,sent}) {
const first=page.locator(CARDS).first();
const title=await historyFrom(page,'.card-title > button',back);
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
// The footer send opens the sender with the positive prompt only.
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
const send={positive,roles:await sent(page)};
// Copy writes the full positive and shows the toast.
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
await expect(page.locator('.toast')).not.toHaveText('');
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
return {title,explore,send,copy,search,category};
}
/** The concept's demo sender: the roles its preview lists, with their text. */
async function conceptSent(page) {
const modal=page.locator('dialog#modal[open]');
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
return roles;
}
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
function productionSent(host) {
return async page=>{
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
const roles=await host.evaluate(()=>window.previewed.pop());
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
return roles;
};
}
async function openHost(context) {
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
window.previewed=[];
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
listTargets:()=>[target],
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
apply:roles=>({targetId:target.id,values:roles}),
}});
});
return host;
}
test.beforeEach(async({page,request},testInfo)=>{
testInfo.manifest=await seedConcept(request);
// Both documents copy through the clipboard; the stub records what they wrote.
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
});
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
test.setTimeout(240_000);
await page.setViewportSize({width,height});
const narrow=width<=700;
const host=await openHost(context);
try {
const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
await openConceptLibrary(page,theme,motion);
const reference=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself without Save view (D16).
const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']});
const removed=(await inventoryOf(page,'#main')).library.toolbar;
await restore();
// ...and with the D23 clamp applied to its cards.
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
const clamped=(await inventoryOf(page,'#main')).library;
await restoreClamp();
// ...and with every card's tag chips put in alphabetical order (D43).
const restoreTags=await sortConceptTags(page);
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
await restoreTags();
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
await openProductionLibrary(page,theme,motion);
const actual=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
const {grid,cards,toolbar}=reference.library,card=cards[0];
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
const art=narrow?150:170;
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
const pad=narrow?'15px':'18px';
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]);
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
if(!concept)continue;
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
// the full positive with the concept's toast; search and category narrow the shelf alike.
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
// D35: the shelf caption uses the singular for one.
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
}
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
await openConceptLibrary(page,theme,'no-preference');
const reference=await conceptInventory(page);
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
await openProductionLibrary(page,theme,'no-preference');
const actual=await inventoryOf(page,'#workspace-main');
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
expect(reference.library.grid.columns,'concept columns').toBe(3);
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
test('library comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptLibrary(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const measure=()=>inventoryOf(page,'#workspace-main');
assertLibraryMatch(reference,await measure());
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
const cssMutated=await measure();
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
await restoreCss();await settle(page);
assertLibraryMatch(reference,await measure());
await mutate(page,{remove:['.card-meta .chip.red']});
const domMutated=await measure();
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
});
+1 -1
View File
@@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
await page.setViewportSize({width,height});const f=page.fixture;
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor');
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
+49 -30
View File
@@ -2,7 +2,7 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mj
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
const details=page=>page.getByRole('complementary',{name:'Selected 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:/^Inspect /}).click();
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
@@ -58,8 +58,8 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text);
await preview.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
} finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
@@ -67,17 +67,18 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
});
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
});
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
@@ -100,7 +101,7 @@ test('inspection failures retry and a delayed previous selection cannot replace
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
@@ -113,7 +114,7 @@ test('sender requires explicit destination, previews full changes and acknowledg
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
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 page.locator('.media-card.selected .image-open').click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
@@ -137,7 +138,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await link.click();await expect(dialog(page)).toHaveCount(0);
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
await link.click();
const sender=await chooseDestination(page);
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
@@ -157,7 +158,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
const host=await fakeHost(context,page,{rejectApply:true});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
@@ -167,9 +168,9 @@ test('host apply rejection retains the current text and requires a new preview',
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
await page.locator('.media-card.selected .image-open').click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
@@ -178,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
@@ -187,7 +188,7 @@ test('empty negative send clears only the negative destination and preserves its
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
@@ -203,12 +204,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
@@ -277,20 +279,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
});
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
test('prompt role headings contain the concept label and compact real actions without a source line',async({page})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
for (const name of ['Positive','Negative']) {
const block=role(details(page),name),heading=block.locator('.prompt-block-heading');
await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) {
const block=role(details(page),name),heading=block.locator('.prompt-heading');
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
await expect(heading.locator('span.role-label')).toHaveText(label);
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
}
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
await expect(details(page)).not.toContainText('Inferred from the saved workflow');
await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1);
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(role(details(page),'Negative').locator('.prompt-heading [data-copy-role]')).toHaveCount(1);
await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0);
await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.');
});
test('copy toast and fallback dialog',async({page})=>{
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
await expect(positive.getByRole('status')).toHaveCount(0);
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible();
const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true});
await expect(area).toHaveValue(text);await expect(area).toBeFocused();
expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]);
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
+50
View File
@@ -76,6 +76,53 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
def register_reveal_stub(routes, api, root: Path):
"""D38: Show in folder never opens a real file manager here.
The API's opener records the resolved path; ``POST /__test__/reveal``
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
"""
from utils.file_reveal import OpenerUnavailable, RevealGate
state = {"outcome": "opened", "opened": []}
def opener(path: Path, gallery_root: Path) -> None:
if state["outcome"] == "unavailable":
raise OpenerUnavailable()
if state["outcome"] == "fail":
raise OSError("stubbed start failure")
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
api._reveal_opener = opener
# Each click gets an answer here; the cooldown is a unit-tested default.
api._reveal_gate = RevealGate(cooldown=0)
@routes.post("/__test__/reveal")
async def set_reveal_outcome(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else None
if (
not isinstance(data, dict)
or set(data) != {"outcome"}
or data["outcome"] not in REVEAL_OUTCOMES
):
raise web.HTTPBadRequest()
state["outcome"] = data["outcome"]
return web.json_response({"success": True})
@routes.get("/__test__/reveal")
async def reveal_calls(request: web.Request) -> web.Response:
return web.json_response({"opened": state["opened"]})
def reset() -> None:
state["outcome"] = "opened"
state["opened"].clear()
return types.SimpleNamespace(reset=reset)
def create_app(root: Path) -> web.Application:
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
@@ -111,6 +158,7 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -311,6 +359,7 @@ def create_app(root: Path) -> web.Application:
history_fixture = manifest
return web.json_response({"success": True, **manifest})
history_commands.reset()
reveal_stub.reset()
workspace_visual = data.get("workspace_visual", False)
full_inspector = data.get("full_inspector", False)
extra = data.get("extra_images", 0)
@@ -408,6 +457,7 @@ def create_app(root: Path) -> web.Application:
raise web.HTTPBadRequest()
history_commands.reset()
library_editor_commands.reset()
reveal_stub.reset()
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
+3 -3
View File
@@ -220,13 +220,13 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openProduction(page,'images','dark');
// The first image is the concept's first sample, selected into the panel on first load (D19).
const facts=page.locator('#workspace-main .generation-facts');
const facts=page.locator('#workspace-main #image-inspector .meta-grid');
await expect(facts).toBeVisible();
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
// app.js:404-408: the concept's facts for alpine_00001.png.
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
// D21: execution evidence is the concept's "Execution recorded" badge.
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
});
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
+275 -35
View File
@@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
const exact=async locator=>locator.evaluate(node=>node.textContent);
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
try {
@@ -128,10 +129,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing
});
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original);
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
await open(page,fixture.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
await expect(marker(page,id)).toBeFocused();
}
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
});
@@ -199,18 +200,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
expect(await exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.raw);
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.effective);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t ');
// D22: the frozen block has no source line; each field's source stays in "Frozen field provenance".
await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · host_cache_output/);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
@@ -280,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
});
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click();
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
let writes=0,galleryReads=0,outputReads=0;
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
@@ -299,7 +302,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
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');
await expect(page.getByRole('button',{name:'Select negative Saved empty',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);
});
@@ -397,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
@@ -447,7 +450,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
});
try {
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
@@ -466,16 +469,16 @@ test('the inline current file names its prompts apart from the frozen run fields
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();
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'));
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
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.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');
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
@@ -562,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
});
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
@@ -573,8 +576,8 @@ test('history epoch recovery reports saved-parent read failures and retries with
});
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text'));
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
@@ -583,7 +586,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).toBe(original);
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
@@ -593,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -606,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
@@ -615,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
});
@@ -623,15 +626,15 @@ test('an authoritative save after a stale revalidation makes the saved view curr
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
return async()=>{
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
};
}
@@ -702,7 +705,7 @@ test('capture-only recovery uses the changed real output capture without re-read
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
});
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
@@ -719,3 +722,240 @@ test('completed page one stays bounded across mode changes until explicit output
await page.keyboard.press('Escape');await retained();
} finally {releaseNext();}
});
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
const historyHeading=page=>page.locator('#workspace-main > .heading');
const summary=page=>page.locator('#workspace-main .library-history article.surface');
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
try {
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
// D24: disabled only while the selected pair's roles are loading.
await expect(send).toBeDisabled();releaseDetail();
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
} finally {releaseDetail();}
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
const actions=heading.locator('.heading-actions > button');
await expect(actions).toHaveCount(3);
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
await expect(actions.nth(2)).toHaveClass('send');
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
expect(row).toBe(boxes[0].height);
});
test('summary meta uses recorded runs',async({page,request})=>{
const f=page.fixture;await open(page,f.positive_id);
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
await expect(surface).toHaveCount(1);
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
// The concept order: category chip, positive block, selected negative block, then the meta line.
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
});
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
test('variants panel matches the concept',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel).toHaveClass('surface timeline-panel');
const heading=panel.locator(':scope > .panel-heading');
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
const actions=heading.locator(':scope > .row-actions > button');
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
const timeline=panel.locator(':scope > .timeline');
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
for(const [index,name] of names.entries()) {
const lane=lanes.nth(index);
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
const dot=lane.locator(':scope > button.variant-dot');
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
}
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
const lower=panel.locator(':scope > .timeline-lower');
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
// The concept tokens reach the lanes (style.css 1096-1137).
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
});
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
}
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
const reach=await dot.evaluate(node=>{
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
});
expect(reach).toEqual(['dot','dot','dot','dot']);
}
// The baseline has a recorded execution: the dot opens Recorded run for it.
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
});
test('all images dialog lists every image for the positive',async({page,request})=>{
const f=page.fixture;await open(page,f.detail_id);
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
// Opening on the first lane, the shared gallery shows that lane only.
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
// Leaving the route closes an open dialog and its gallery.
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
});
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section).toHaveCount(0);
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
await expect(section).toHaveCount(1);
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
// D46: the two blocks are named apart, so no region or action name repeats on the page.
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await expect(section.locator('.diff-added')).toHaveCount(0);
// Another dot updates the same section in place.
const node=await section.elementHandle();
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
await expect(section.locator(':scope > p.small')).toHaveCount(1);
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
await page.setViewportSize({width:1600,height:1100});
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
await page.setViewportSize({width:390,height:844});
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
});
test('compare waits for the page holding the oldest active negative',async({page})=>{
const f=page.fixture;
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
const response=await route.fetch(),body=await response.json();
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
});
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
const section=page.locator('.library-history .timeline-panel + section.surface');
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
await expect(section.locator('.compare-grid')).toHaveCount(0);
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
});
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
const f=page.fixture;
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
await open(page,f.canonical_id);const panel=variantsPanel(page);
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
await expect(dialog(page)).toHaveCount(0);
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
await page.unroute(/\/history\/pairs\/\d+\/newest/);
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#workspace-toast')).toHaveText('');
});
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
const f=page.fixture;
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const panel=variantsPanel(page);
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
// Each open replaces the previous dialog and its gallery.
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
});
test('Compare negatives is disabled without an active variant',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
});
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
await open(page,f.canonical_id);
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
});
+97 -30
View File
@@ -6,15 +6,20 @@ const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
// Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing.
const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1';
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
const details = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
// The concept panel names no source (D20); its cover is the selected file's own rooted URL, root 0 output, 1 input.
const SOURCE_ROOTS = { output: 0, input: 1 };
const expectPanelSource = (page, source) => expect(details(page).locator('img.detail-cover')).toHaveAttribute('src', new RegExp(`[?&]root=${SOURCE_ROOTS[source]}(&|$)`));
// The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view".
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
const caption = page => page.locator('.section-caption');
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true });
// D44: the Filters button reads "Filters · N" while N Refine filters are active.
const filtersButton = page => page.locator('#workspace-main search.toolbar').getByRole('button', { name: /^Filters(?: · \d+)?$/ });
async function refine(page, values) {
await page.getByRole('button', { name: 'Filters', exact: true }).click();
await filtersButton(page).click();
const dialog = refineDialog(page);
for (const [label, value] of Object.entries(values)) {
const control = dialog.getByLabel(label, { exact: true });
@@ -57,16 +62,15 @@ test('native Images keeps colliding filenames distinct and inspects the selected
expect(new Set(originals).size).toBe(2);
await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expect(details(page)).toContainText('shared/same.png');
await expect(details(page)).toContainText(/input/i);
await expectPanelSource(page, 'input');
await capture('after-images-source-collision-input-1600');
await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText(/output/i);
await expectPanelSource(page, 'output');
await capture('after-images-source-collision-output-1600');
await page.setViewportSize({width:390,height:844});
for (const [index,source] of [[1,'input'],[0,'output']]) {
await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click();
await expect(details(page)).toContainText(new RegExp(source,'i'));
await expectPanelSource(page, source);
await details(page).scrollIntoViewIfNeeded();
await capture(`after-images-source-collision-${source}-390`);
}
@@ -92,17 +96,16 @@ test('image selection is shown on the selected card', async ({ page }) => {
await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0);
});
test('closing details preview restores its trigger focus', async ({ page }) => {
test('the side panel cover is the concept plain image, never a preview control', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true });
await selected.click();
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
await preview.click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expectPanelSource(page, 'input');
// D19/D20: the card image opens the viewer; the panel cover is decoration (`alt=""`) and not focusable.
await expect(details(page).getByRole('button', { name: 'Open full preview', exact: true })).toHaveCount(0);
await expect(details(page).locator('img.detail-cover')).toHaveAttribute('alt', '');
await details(page).locator('img.detail-cover').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(preview).toBeFocused();
});
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
@@ -226,7 +229,7 @@ test('layout changes retain selection and use one collection in Grid, List and M
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page)).toHaveCount(2);
await expect(details(page)).toContainText(/input/i);
await expectPanelSource(page, 'input');
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
@@ -265,8 +268,9 @@ test('returning to Images restores the filtered stream, layout, selection and sc
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
// Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view.
await page.getByRole('button', { name: 'Filters', exact: true }).click();
// Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. The restored Source
// still counts (D44).
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0');
await page.keyboard.press('Escape');
await expect(refineDialog(page)).toBeHidden();
@@ -520,7 +524,7 @@ async function staleSourceRecovery(page, request, roots) {
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
const filters = page.getByRole('button', { name: 'Filters', exact: true });
const filters = filtersButton(page);
await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape');
await refine(page, { Source: '0', Folder: 'bulk' });
const configured = page.waitForResponse(response => {
@@ -565,7 +569,9 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');
for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
for (const name of ['Filters', 'Import']) await expect(page.getByRole('button', { name, exact: true })).toBeDisabled();
// D44: recovery clears Source and Folder; the Media filter stays and still counts.
await expect(recovery.filters).toHaveText('Filters · 1');await expect(recovery.filters).toBeDisabled();
await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeDisabled();
await expect(search(page)).toBeDisabled();
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change');
@@ -611,7 +617,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await expect(details(page)).toContainText(/input/i);
await expectPanelSource(page, 'input');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await expect(search(page)).toBeVisible();
@@ -747,10 +753,9 @@ test('Images uses approved geometry while keeping real controls', async ({page,r
expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2);
expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y);
await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/);
const cover=details(page).getByRole('button',{name:'Open full preview',exact:true});
await expect(cover).toHaveCSS('height','150px');
await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px');
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true);
const cover=details(page).locator('img.detail-cover');
await expect(cover).toBeVisible();await expect(cover).toHaveCSS('height','150px');
expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='IMG')).toBe(true);
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0);
await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0);
@@ -830,10 +835,13 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('');
await expect(cards(page)).toHaveCount(2);
await expect(filtersButton(page)).toHaveText('Filters');
await refine(page, { Source: '1', Folder: 'shared', Media: 'image' });
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`);
// D44: Source, Folder and Media each count once; the toolbar category is not a Refine filter.
await expect(filtersButton(page)).toHaveText('Filters · 3');
await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(0);
await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' });
@@ -842,11 +850,13 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await expect(row.locator(':scope > span.chip')).toHaveText('All output folders');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(0);
await page.getByRole('button', { name: 'Filters', exact: true }).click();
await expect(filtersButton(page)).toHaveText('Filters · 1');
await page.getByRole('button', { name: 'Filters · 1', exact: true }).click();
await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats');
await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click();
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue('');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('button', { name: 'Filters', exact: true })).toBeFocused();
const rescans = [];
page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); });
@@ -1099,7 +1109,7 @@ test('image click opens the viewer and Info selects the panel', async ({ page })
await expect(cards(page)).toHaveCount(2);
// D19: the first load still selects the first image into the panel.
await expect(cards(page).first()).toHaveClass(/selected/);
await expect(details(page)).toContainText(/output/i);
await expectPanelSource(page, 'output');
const viewer = page.locator('dialog#viewer-dialog');
const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true });
await image.click();
@@ -1122,10 +1132,10 @@ test('image click opens the viewer and Info selects the panel', async ({ page })
await expect(cards(page).nth(1)).toHaveClass(/selected/);
await expect(cards(page).first()).not.toHaveClass(/selected/);
await expect(info).not.toHaveAttribute('aria-pressed');
await expect(details(page)).toContainText(/input/i);
await expectPanelSource(page, 'input');
await image.focus(); await page.keyboard.press('ArrowLeft');
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(details(page)).toContainText(/input/i);
await expectPanelSource(page, 'input');
});
test('grid columns follow concept breakpoints', async ({ page, request }) => {
@@ -1495,8 +1505,8 @@ test('Refine offers only the chosen source folders', async ({ page, request }) =
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
// Reopened on that Source, the dialog again lists only its folders.
await page.getByRole('button', { name: 'Filters', exact: true }).click();
// Reopened on that Source, the dialog again lists only its folders. Source and Folder count (D44).
await page.getByRole('button', { name: 'Filters · 2', exact: true }).click();
expect(await values()).toEqual(['', 'shared']);
});
@@ -1559,6 +1569,63 @@ test('unreachable sources and categories announce one error', async ({ page }) =
await expect(page.getByRole('alert')).toHaveCount(0);
});
// PR D-b Task 3 (D42): the shared toast keeps the concept's fixed bottom placement, so a discovery alert that a rescan
// leaves visible must not stay under it. The window ends just below the alert, inside the toast's band, as on a short
// window or a phone with its browser bars shown.
const boxOf = locator => locator.evaluate(node => {
const { top, right, bottom, left } = node.getBoundingClientRect();
return { top, right, bottom, left };
});
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
for (const width of [1600, 390]) {
test(`Rescan toast does not cover the discovery alert it leaves at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
const failed = 'Categories could not be loaded. Rescan from Import to retry.';
await expect(alert).toHaveText(failed);
const toastInset = width > 700 ? 22 : 12;
await page.setViewportSize({ width, height: Math.ceil((await boxOf(alert)).bottom) + toastInset + 16 });
await rescan(page);
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toHaveText(failed);
await expect.poll(async () => overlaps(await boxOf(alert), await boxOf(toast(page)))).toBe(false);
const [shown, placed] = [await boxOf(alert), await boxOf(toast(page))];
expect(shown.top).toBeGreaterThanOrEqual(0);
expect(shown.bottom).toBeLessThanOrEqual(placed.top);
// The concept's toast stays where the concept puts it: centred, its inset from the bottom.
const viewport = page.viewportSize();
expect(Math.round(viewport.height - placed.bottom)).toBe(toastInset);
expect(Math.abs((placed.left + placed.right) / 2 - viewport.width / 2)).toBeLessThan(1);
});
}
// Final review m3: D42 scrolls only when the alert meets the toast's band, never back up to one above the window.
test('Rescan does not scroll back to a discovery alert above the window', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.route('**/prompt_manager/library/options*', route => route.abort('failed'));
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const alert = page.getByRole('alert');
await expect(alert).toHaveText('Categories could not be loaded. Rescan from Import to retry.');
await page.setViewportSize({ width: 1600, height: 260 });
// Import is at the top; the page is scrolled down to the last card while its dialog is open.
await page.getByRole('button', { name: 'Import', exact: true }).click();
await cards(page).last().evaluate(node => node.scrollIntoView({ block: 'end' }));
expect((await boxOf(alert)).bottom).toBeLessThan(0);
// The reload itself may move the page; what D42 must not do is scroll the alert back into view.
await alert.evaluate(node => {
window.alertScrolls = 0;
node.scrollIntoView = (...args) => { window.alertScrolls += 1; Element.prototype.scrollIntoView.apply(node, args); };
});
await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click();
await expect(toast(page)).toHaveText(/^Rescan finished\. /);
await expect(alert).toBeAttached();
expect(await page.evaluate(() => window.alertScrolls)).toBe(0);
});
// #249 final review m8: D36's alphabetical categories hold whatever order the server sends.
test('toolbar categories are alphabetical regardless of server order', async ({ page }) => {
await page.route('**/prompt_manager/library/options*', route => route.fulfill({
+53 -28
View File
@@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const toast=page=>page.locator('#workspace-toast');
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
async function openHistory(page,id) {
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
}
@@ -450,14 +450,14 @@ async function leaveAndReturn(page,id) {
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
// One linked image is counted in the singular.
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
// One image is counted in the singular.
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
@@ -472,7 +472,7 @@ test('library heading offers Export then + New prompt, and New opens the saved p
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
});
@@ -497,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
@@ -509,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
expect(page.writes).toHaveLength(0);
});
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await selectPair(page,'Select negative variant 2');
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await leaveAndReturn(page,f.positive_id);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
});
@@ -531,8 +533,8 @@ test('add negative re-renders history with the saved or reused pair selected',as
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
@@ -550,9 +552,9 @@ test('reused pair outside first forty remains selected without loading every pag
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
});
@@ -565,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(toast(page)).toHaveText('Saved.');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
@@ -639,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
});
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
test('show archived and restore still work',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const timeline=variantsPanel(page).locator(':scope > .timeline');
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
const writes=page.writes.length;
await lane.getByRole('button',{name:/^Restore /}).click();
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
});
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
let release;const held=new Promise(resolve=>{release=resolve;});
@@ -668,17 +693,17 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
expect(await evidence()).toEqual(before);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
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 lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).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);
@@ -690,7 +715,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
await lane.getByRole('button',{name:/^Restore/}).click();
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
@@ -698,14 +723,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
});
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
@@ -728,7 +753,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
});
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
@@ -739,8 +764,8 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
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.archived_negative);
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -748,7 +773,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
@@ -809,7 +834,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
});
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
@@ -873,7 +898,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
'js/workspace/library.css','js/workspace/workspace.css'];
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
await page.setViewportSize({width,height});const f=page.fixture;const references={};
for(const mode of Object.keys(conceptModes)) {
+338 -75
View File
@@ -7,13 +7,32 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
const media=page=>gallery(page).locator('article[data-image-key]');
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
async function applyTags(page,tags) {
// D44: Filters reads "Filters · 1" while a Tags filter is active.
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click();
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags);
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
}
/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */
async function loadShelfPages(page,more,pages) {
await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false');
for(let index=0;index<pages;index++) {
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
}
}
async function openHistory(page,text='Cat portrait') {
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
await expect(history(page)).toBeVisible();
}
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
async function selectVariant(page,index) {
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
const choice=variantDot(page,index);
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
}
@@ -23,7 +42,8 @@ test.beforeEach(async({page,request,browserName})=>{
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
@@ -31,7 +51,8 @@ test('library groups reusable negatives under one positive without exposing nega
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
// D23: "N runs" is the parent's own run_count, never a recorded-execution count.
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
@@ -39,31 +60,34 @@ test('history displays the selected full negative beneath its positive and filte
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
await page.goto(route);await openHistory(page);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
await expect(neg).toContainText(negative||'Empty negative prompt');
await expect(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)');
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
await expect(positive).toContainText('Cat portrait');
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
expect(order&4).toBeTruthy();
}
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
});
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('');
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0);
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
});
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
@@ -83,10 +107,34 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
const firstId=await cards(page).first().getAttribute('data-prompt-id');
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true});
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
});
// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf.
test('Retry re-reads a failed category list with the shelf',async({page})=>{
let fail=true;const reads=[];
await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{
reads.push(new URL(request.request().url()).pathname);
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
await request.continue().catch(()=>{});
});
await page.goto(route);
const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible();
const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'});
await expect(categoriesFailed).toBeVisible();
fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3);
await expect(categoriesFailed).toBeHidden();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1);
expect(reads).toContain('/prompt_manager/library/options');
// Apply with the same Tags, and a search that lands on the values already shown, reload nothing.
reads.length=0;
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/library?*',async request=>{
@@ -97,11 +145,82 @@ test('failed library reads retry while late search responses cannot overwrite ne
});
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name');
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted;
});
test('caption counts equal the filtered cards',async({page,request})=>{
const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];};
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const caption=page.locator('#workspace-main .section-caption');
for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) {
if(act)await act();
const [count,variants,total]=await totals(query);expect(variants).toBe(total);
await expect(cards(page)).toHaveCount(count);
await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']);
}
});
// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it.
test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true});
const count=page.locator('#workspace-main .section-caption > span').first();
await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/);
await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});});
await loadShelfPages(page,more,1);
expect(await page.evaluate(()=>window.__countWrites)).toBe(0);
// A filter change keeps the last count shown while the next page loads, then writes the new one.
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();});
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/);
release();await expect(count).not.toHaveText(/^203 /);
expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0);
});
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
await page.goto(route);await openHistory(page);
// The unassigned file is reached through the All images dialog (#249 D26).
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT');
await star.click();expect((await saved).ok()).toBe(true);
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
});
test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
@@ -111,31 +230,32 @@ test('refresh atomically discards old row geometry and renders changed parent da
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
});
try {
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
// D15: no Refresh; any filter change restarts the stream the same way.
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
await page.setViewportSize({width:390,height:844});await page.goto(route);
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals');
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
await page.setViewportSize({width:1600,height:300});await page.goto(route);
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive');
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
@@ -143,17 +263,19 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('No matching saved positive');
await expect(cards(page)).toHaveCount(0);
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape');
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1);
expect(await page.evaluate(()=>scrollY)).toBe(0);
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
// A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing
// follows the shelf's continuation since the legacy link moved to Overview, #249 E3).
await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape');
});
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
@@ -163,7 +285,7 @@ test('history detail failures retry and returning to the list ignores late detai
await hold;await route.continue().catch(()=>{});
});
try {
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
@@ -187,7 +309,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
@@ -198,10 +320,12 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
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:'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']);
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
// D20: provenance and raw records are the viewer's full details, opened from the card image (D19).
await expect(inspector.locator('summary')).toHaveCount(0);
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
@@ -210,11 +334,11 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
await page.goto(route);await openHistory(page);
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
});
async function observeLibrary(page, hold=false) {
@@ -231,7 +355,7 @@ async function observeLibrary(page, hold=false) {
record.native=new NativeResizeObserver(entries=>{
const ready=[];
for(const entry of entries) {
if(entry.target.matches('.library-card')) {
if(entry.target.matches('.prompt-card')) {
if(!entry.target.isConnected)unmounted++;
record.latest.set(entry.target,entry);
if(holding) {record.entries.set(entry.target,entry);continue;}
@@ -245,7 +369,7 @@ async function observeLibrary(page, hold=false) {
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
};
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
window.libraryObservation={
hold(){holding=true;},
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
@@ -273,37 +397,40 @@ async function retainedParent(page, anchor) {
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
}
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
test('Library cards clamp long prompts and keep real content inside the card',async({page,request})=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
const data=await(await request.get('/prompt_manager/library')).json();
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.');
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
for(const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
}
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
await expect(page.locator('.card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
const long=cards(page).filter({hasText:'A study in very long creative context'});
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
// D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card.
expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true});
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560);
for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) {
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).toBe(true);
}
await prompt.focus();await expect(prompt).toBeFocused();
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
await expect(long.getByRole('button',{name:'Explore history',exact:true})).toBeVisible();
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
const footer=card.locator('.card-footer > span');
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
}
@@ -317,23 +444,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn
try {
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
{const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
expect(await cards(page).count()).toBeLessThan(80);
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
for(const changedWidth of [false,true]) {
const before=await firstVisibleParent(page);
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
expect(await cards(page).count()).toBeLessThan(80);
@@ -347,11 +473,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
await settleLibrary(page);await page.mouse.move(1100,550);
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
try {
// The same queued-frame wheel sequence is stable without changed measurements.
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
@@ -362,7 +487,7 @@ test('queued row growth preserves newer native wheel movement and correction eve
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='45px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
@@ -378,10 +503,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
// The correction's own native scroll event must not become new user motion.
const corrected=await firstVisibleParent(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='65px';});
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
@@ -411,8 +536,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
await loadShelfPages(page,more,3);await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
@@ -422,7 +546,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
@@ -436,12 +560,151 @@ test('saved choices retain curved staggered lanes and the full negative immediat
await page.goto(route);await openHistory(page);await selectVariant(page,1);
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
const selected=details.variants[1].negative_text;
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected);
});
/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */
async function patchShelf(page,patch) {
await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{
const response=await route.fetch();const body=await response.json();
body.items=body.items.map(item=>({...item,...patch(item)}));
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but its visually hidden rating sentence. */
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
test('card matches the concept and never wraps the category mid-word',async({page,request})=>{
const before=await(await request.get('/prompt_manager/library')).json();
const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait');
const dual=before.items.find(item=>item.text==='Dual role portrait');
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK();
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals');
await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600');
const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1);
await expect(card.locator('section.prompt-section.negative')).toHaveCount(0);
await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1);
await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1);
await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']);
await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1);
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
const footer=card.locator('.card-footer');
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
// Unrated and never used: the used label alone; no negatives still reads as a count.
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]);
for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) {
const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text);
expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1);
expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
});
test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{
const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait');
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{});
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target={id:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']};
window.cardApplied=[];
window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card ComfyUI',adapter:{
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive}},snapshot:roles}),
apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};},
}});
});
try {
for(const [width,height] of [[1600,1100],[390,844]]) {
await page.setViewportSize({width,height});await page.goto(route);
const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text');
await expect(text).toHaveText(longPrompt);
const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};});
expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'});
}
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click();
await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]);
await card.locator('.card-footer .row-actions > button.send').click();
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]);
await sender.getByRole('button',{name:'Close',exact:true}).click();
} finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();}
});
test('title and Explore history are buttons that open history',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
await expect(card.locator('a')).toHaveCount(0);
const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button');
await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true});
await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
// The card's navigation buttons keep the shelf's Home/End and arrow movement between cards.
const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End');
await expect(cards(page).last().locator('.card-title > button')).toBeFocused();
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
});
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`}));
// Two columns on a desktop window; the touch project's phone gives the one-column shelf.
{
if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
await loadShelfPages(page,more,3);
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page);
const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{
const box=node.getBoundingClientRect();
return {row:node.classList.contains('library-row'),top:box.top,height:box.height,
cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))};
}));
const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60);
for(const row of rows) {
expect(row.cards.length).toBeGreaterThan(0);
for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);}
}
// Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one.
for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1);
const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor);
}
});
+36 -26
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', '.media-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', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => {
const node = document.querySelector(selector);
if (!node) return [selector, null];
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
@@ -94,23 +94,24 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true });
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
const actual = await historyGeometry(page, true);
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
const title = await page.locator('#workspace-main > .heading h1').textContent();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('#workspace-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-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('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
await page.locator('.library-shared-gallery .media-card.selected .image-open').click();
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']]) {
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
}
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
@@ -125,11 +126,15 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
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();
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); }
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
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();
@@ -242,9 +247,10 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
await text.scrollIntoViewIfNeeded();
expect((await text.textContent()).length).toBeGreaterThan(1500);
await text.focus(); await expect(text).toBeFocused();
// #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows.
expect(await text.evaluate(node => {
const scroller = node.closest('.library-card-prompts');
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
const content = node.closest('.card-content');
return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1;
})).toBe(true);
await noOverflow(page);
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
@@ -346,8 +352,9 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
expect(filename).toMatch(/layered-context-.*\.png/);
// 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 expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
// D19: the card image, not the panel, opens the viewer.
const fullPreview = longCard.locator('.image-open');
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
await expect(page.locator('#viewer-title')).toHaveText(filename);
@@ -453,7 +460,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2);
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
await expect(selected.locator('.image-open')).toBeFocused();
await expect(page.locator('.image-details h2')).toHaveText(filename);
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
const mutations = await grid.evaluate(async node => {
let changes = 0;
const observer = new MutationObserver(records => { changes += records.length; });
@@ -533,9 +540,9 @@ test('Library visual evidence keeps natural cards and returns from real history
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false);
await noOverflow(page);
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
@@ -602,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
for (const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('.media-card .image-card-actions button').first().click();
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
await expect(page.locator('.detail-panel .panel-heading 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('.media-card').first()).toHaveCSS('height','97px');
@@ -614,33 +621,36 @@ test('Images visual layouts retain real delayed media long filenames and loaded
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 expect(page.locator('.detail-panel .panel-heading 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('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.detail-panel,.viewer-container,dialog[open]')).toHaveCount(0);
});
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
await request.post('/__test__/reset',{data:{workspace_visual:true}});
for (const width of [1600,390]) {
await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`);
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('h1')).not.toHaveText('Prompt library');
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(listing.locator('article[data-image-key]').first()).toBeVisible();
for (const layout of ['List','Masonry','Grid']) {
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
await expect(page.locator('h1')).toHaveText(title);
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0);
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
}
});
+36 -9
View File
@@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) {
await expect(page).toHaveURL(/#\/library$/);
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
const select=page.getByRole('combobox',{name:'Category',exact:true});
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
const select=page.getByRole('combobox',{name:'Filter by category',exact:true});
await expect(select).toHaveValue(category);
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`));
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
// Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44).
const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true});
await expect(filters).toBeVisible();await filters.click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0);
}
let manifest;
@@ -85,9 +90,31 @@ test('prompt counts equal Library Browse results',async({page})=>{
}
});
// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf.
test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
const toolbar=page.locator('#workspace-main search.toolbar');
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
await page.goto(route);
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
await expectLibrary(page,{tags:'cinematic',count:2});
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(shelf).toHaveCount(total);
// The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters).
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
});
test('category Browse sets only the category constraint',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
@@ -401,8 +428,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
await page.goto('/prompt_manager/workspace#/library');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
});
test('cancel and escape write nothing and restore focus',async({page,request})=>{
@@ -486,7 +513,7 @@ test('lost response is outcome-unknown',async({page,request})=>{
// Concept nav links end in their real `.nav-count` once it loads.
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
const chips=(page,name)=>main(page).locator('article[data-prompt-id] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)});
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
async function heldRename(page) {
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
@@ -798,7 +825,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
'js/workspace/library.css','js/workspace/workspace.css'];
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
await page.setViewportSize({width,height});
+6 -2
View File
@@ -13,7 +13,8 @@ const routes = [
['overview', 'Overview', null],
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
['images', 'Images', null],
['library', 'Prompt library', '/prompt_manager/admin'],
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
['library', 'Prompt library', null],
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
@@ -81,8 +82,11 @@ 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.
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
await page.goto(`${workspace}#/overview`);
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
await expect(await request.get('/prompt_manager/admin')).toBeOK();
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();
+5 -3
View File
@@ -354,7 +354,9 @@ def seed_workspace_concept(
db = api.db
epoch = db.get_execution_epoch()
prompts = []
for prompt in CONCEPT_PROMPTS:
# The concept lists its prompts most recent first (renderLibrary under Recently
# added keeps the array order), so they are created last to first.
for prompt in reversed(CONCEPT_PROMPTS):
first_name, first_text = prompt.negatives[0]
receipt = db.create_library_recipe(
prompt.positive,
@@ -374,8 +376,8 @@ def seed_workspace_concept(
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
# Real queue-hook counting (D23): four runs, the last one now.
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
prompts.append(
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
prompts.insert(
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
)
folder = root / "output" / "concepts"
+10 -1
View File
@@ -479,8 +479,14 @@ def seed_workspace_history(
canonical_positive, canonical_negative, title="Quality baseline"
)
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
clean_pair = api.db.save_prompt_pair(
canonical_positive, "flat light", title="Clean geometry"
)
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
api.db.record_prompt_pair_use(canonical_pair, 2)
api.db.record_prompt_pair_use(clean_pair, 2)
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
path = folder / name
_write_art(path, number, canonical_positive, canonical_negative)
@@ -500,6 +506,8 @@ def seed_workspace_history(
os.utime(path, (stamp, stamp))
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
if number:
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
canonical_run = run(
(
RecipeContext(
@@ -529,6 +537,7 @@ def seed_workspace_history(
"detail_image_id": current,
"canonical_id": canonical_id,
"canonical_pair": canonical_pair,
"clean_pair": clean_pair,
"canonical_run": canonical_run,
"canonical_positive": canonical_positive,
"canonical_negative": canonical_negative,
+1 -1
View File
@@ -243,7 +243,7 @@ test.describe('public chronological history with the actual acknowledged host br
const frozen = attempts[0].context;
for (const mode of ['Raw', 'Effective']) {
await run.getByRole('button', { name: mode, exact: true }).click();
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').evaluate(node => node.textContent)).toBe(value);
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
try {
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
+4 -4
View File
@@ -22,10 +22,10 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace#/library');
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
// The pair send lives in the history heading, as in the approved concept.
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
@@ -67,7 +67,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)');
const evidence=async()=>{
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
+5 -5
View File
@@ -46,12 +46,12 @@ test('native image inspector previews and sends a pair into real visible widgets
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:`Inspect ${filename}`,exact:true}).click();
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
const inspector=workspace.getByRole('complementary',{name:'Selected 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();
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
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 expect(viewer.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
@@ -59,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
await expect(viewer).toBeVisible();await expect(viewer.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
}
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
await expect(hosts.locator('option')).toHaveCount(2);
+135 -15
View File
@@ -5,7 +5,7 @@ import * as module from '../../web/js/workspace/prompt-inspector.mjs';
function dom() {
class Element {
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};this.className='';}
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
replaceChildren(...children){this.children=[];this.append(...children);}
setAttribute(key,value){this.attributes[key]=value;}
@@ -13,9 +13,9 @@ function dom() {
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
click(target){this.listeners.get('click')?.({target});}
}
const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
return {Element,walk,restore:()=>{global.document=previous;}};
return {Element,walk,toast,restore:()=>{global.document=previous;}};
}
const record=text=>({status:'resolved',text,source:'library'});
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
@@ -27,7 +27,7 @@ test('shared prompt blocks send complete selected library values and explicit em
view.update({positive:record('Complete positive'),negative:record('')});
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,0,'no per-block source line');
view.dispose();container.click(pair);assert.equal(sent.length,1);
}finally{scope.dispose();f.restore();}
});
@@ -51,19 +51,19 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
}finally{scope.dispose();f.restore();}
});
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
test('frozen fields keep null, empty and whitespace actions distinct without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
assert.equal(f.walk(container,node=>node.textContent?.includes('runtime_return')).length,0,'no per-block source line');
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
assert.equal(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length,0);
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent===' \n\t ').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
assert.deepEqual(sent[1],{negative:' \n\t '});
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
@@ -71,19 +71,19 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
}finally{scope.dispose();f.restore();}
});
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
test('a zeroed conditioning negative shows as an explicit empty negative without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,0);
assert.equal(f.walk(container,node=>node.className==='prompt-text'&&node.textContent==='No negative prompt (explicitly empty)').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
test('unsupported node status names the class',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
@@ -101,7 +101,7 @@ test('runtime and composite statuses',()=>{
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
assert.deepEqual(statusLines(f,container),[composite]);
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent==='kept').length,1);
}finally{scope.dispose();f.restore();}
});
test('limit status',()=>{
@@ -136,10 +136,130 @@ test('a context label names nested prompt regions and actions apart from the fro
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt to ComfyUI','Copy current file negative prompt','Send current file negative prompt to ComfyUI']) assert.ok(labels.includes(name),name);
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
assert.deepEqual(f.walk(container,node=>node.className==='role-label').map(node=>node.textContent),['+ Positive prompt','\u2212 Negative prompt']);
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
}finally{scope.dispose();f.restore();}
});
test('a title names each role action after its prompt as the concept does',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
const nested=new f.Element('div');
module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',roles:['positive'],prompts:{positive:record('p')}});
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']);
}finally{scope.dispose();f.restore();}
});
test('setTitle renames the actions in place and keeps the last prompts and state',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}});
view.setState({prompts:{positive:record('kept')},actionsEnabled:false});
const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render');
view.setTitle('New name');
assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']);
assert.ok(f.walk(container,node=>node.textContent==='kept').length);
assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true));
view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']);
scope.dispose();view.setTitle('After dispose');
}finally{scope.dispose();f.restore();}
});
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
test('prompt block emits concept markup without a source line',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div');
try{
module.mountPromptBlocks(container,{parentScope:scope,includePairAction:false,sender:{open:()=>{}},prompts:{positive:{...record('a lighthouse'),evidence:{provenance:'recorded',source:'runtime_return'}},negative:record('blurry')}});
const section=(role,label,text)=>({tag:'section',className:`prompt-section ${role}`,label:`${role[0].toUpperCase()}${role.slice(1)} prompt`,children:[
{tag:'div',className:'prompt-heading',children:[{tag:'span',className:'role-label',text:label},{tag:'div',className:'prompt-actions',children:[
{tag:'button',className:'icon-btn',label:`Copy ${role} prompt`,children:[]},{tag:'button',className:'send icon-btn',label:`Send ${role} prompt to ComfyUI`,children:[]}]}]},
{tag:'p',className:'prompt-text',text}]});
assert.deepEqual(container.children.map(shape),[section('positive','+ Positive prompt','a lighthouse'),section('negative','\u2212 Negative prompt','blurry')]);
for(const button of f.walk(container,node=>node.tagName==='button')) assert.equal(button.children.filter(child=>child.tagName==='svg').length,1,'one icon per action');
}finally{scope.dispose();f.restore();}
});
test('empty negative uses the concept copy and still sends an explicit clear',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('positive'),negative:record('')}});
const negative=container.children[1];
assert.equal(negative.children[1].className,'prompt-text');assert.equal(negative.children[1].textContent,'No negative prompt (explicitly empty)');
container.click(f.walk(negative,node=>node.dataset.sendRole==='negative')[0]);
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
assert.deepEqual(sent,[{negative:''},{positive:'positive',negative:''}]);
}finally{scope.dispose();f.restore();}
});
test('unknown roles never offer copy or send',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
try{
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive','negative','style'],sender:{open:roles=>sent.push(roles)},
prompts:{positive:{status:'absent'},negative:{status:'ambiguous',text:'candidate'},style:record('not a role')}});
assert.deepEqual(container.children.map(node=>node.className),['prompt-section positive','prompt-section negative'],'an unknown role name renders nothing');
assert.deepEqual(statusLines(f,container),['Not recorded in this file.','Multiple possible prompts. Source could not be determined.']);
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined||node.className==='prompt-actions').length,0);
const forged=new f.Element('button');forged.dataset.sendRole='style';container.append(forged);container.click(forged);
assert.equal(sent.length,0,'a forged action for an unknown role sends nothing');
}finally{scope.dispose();f.restore();}
});
test('copy shows the shared toast "Prompt copied." with the exact role text',async()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),copied=[];
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied.push(text);}}}});
try{
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record(' exact\ntext '),negative:record('')}});
await container.listeners.get('click')({target:f.walk(container,node=>node.dataset.copyRole==='positive')[0]});
assert.deepEqual(copied,[' exact\ntext ']);assert.equal(f.toast.textContent,'Prompt copied.');
assert.equal(f.walk(container,node=>node.attributes.role==='status').length,0,'no per-block copy status line');
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
});
/** Runs one copy click with `clipboard` as `navigator.clipboard`, recording what the fallback seam receives. */
async function copyWithClipboard(clipboard,{detach=false}={}) {
const f=dom(),scope=createScope(),container=new f.Element('div'),fallbacks=[];
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
Object.defineProperty(globalThis,'navigator',{configurable:true,value:clipboard===undefined?{}:{clipboard}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('exact text'),negative:record('')},
copyFallback:(text,options)=>fallbacks.push({text,...options})});
const copy=f.walk(container,node=>node.dataset.copyRole==='positive')[0];
let current=copy;
if(detach) {
// The blocks re-render while the clipboard is pending: the clicked button is detached, its successor is not.
copy.isConnected=false;container.querySelector=selector=>selector==='[data-copy-role="positive"]'?current:null;
}
const pending=container.listeners.get('click')({target:copy});
if(detach) {view.update({positive:record('exact text'),negative:record('')});current=f.walk(container,node=>node.dataset.copyRole==='positive')[0];}
await pending;
return {fallbacks,toast:f.toast.textContent,copy,current,scope};
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
}
test('copy without a clipboard opens the select-to-copy fallback and shows no toast',async()=>{
const {fallbacks,toast,copy,scope}=await copyWithClipboard(undefined);
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.equal(fallbacks[0].trigger,copy);assert.ok(fallbacks[0].scope);
assert.notEqual(toast,'Prompt copied.');assert.equal(scope.signal.aborted,true);
});
test('a refused clipboard write opens the fallback instead of the toast',async()=>{
const {fallbacks,toast}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}});
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.notEqual(toast,'Prompt copied.');
});
test('a fallback after a re-render returns focus to the same-role copy button, not the detached one',async()=>{
const {fallbacks,copy,current}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}},{detach:true});
assert.equal(fallbacks.length,1);assert.notEqual(current,copy);assert.equal(fallbacks[0].trigger,current);
});
+22 -1
View File
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
const load=()=>import(modulePath);
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
function record() {
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
}
test('metadata export retains literal raw and scoped evidence',async()=>{
@@ -65,3 +65,24 @@ test('limited workflow record exports its representation and disables workflow d
assert.equal(downloads.workflow.available,false);assert.equal(downloads.workflow.bodyText,null);
assert.equal(downloads.workflow.reason,'Workflow exceeds inspection limits. Download the original file to inspect it externally.');
});
test('Show in folder needs the full listing identity (D38)',async()=>{
const {revealTarget}=await load(),scope='a'.repeat(64);
assert.deepEqual(revealTarget(record().identity),{root_index:2,root_scope:scope,path:'a.png'});
for(const identity of [
{kind:'rooted-file',root_index:2,path:'a.png',root_scope:null},
{kind:'rooted-file',root_index:'2',path:'a.png',root_scope:scope},
{kind:'rooted-file',root_index:2,path:'',root_scope:scope},
{kind:'reported-location',path:'folder/a.png',current_scope:scope},
null,
])assert.equal(revealTarget(identity),null);
});
test('Show in folder outcomes use the D38 toasts',async()=>{
const {revealOutcome}=await load();
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
});
+1 -1
View File
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
+14
View File
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
}
});
test('added terms split, trim and exact-diff like the concept',()=>{
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
// Duplicates in the selected negative stay, as the concept lists every added term in order.
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
// Case and inner whitespace are exact.
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
});
+9
View File
@@ -248,6 +248,15 @@ test('the scope chip summarizes source and folder', async () => {
assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
});
test('the Filters count names each active Refine filter once (D44)', async () => {
const {activeRefinements}=await source;
assert.equal(activeRefinements({root:'all',subfolder:'',media:'all',tags:[]}),0);
assert.equal(activeRefinements({root:'',subfolder:'',media:'all',tags:[]}),0);
assert.equal(activeRefinements({root:'1',subfolder:'',media:'all',tags:[]}),1);
assert.equal(activeRefinements({root:'all',subfolder:'bulk',media:'video',tags:['a','b']}),3);
assert.equal(activeRefinements({root:'0',subfolder:'bulk',media:'image',tags:['a']}),4);
});
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}});
+5 -1
View File
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
workflow_graph: { status: 'absent', value: null }, parameters: null,
},
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
};
}
function pendingApi() {
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
data => { data.resolution.sampler_id = {}; },
data => { data.library.tags_scope = 'recipe'; },
data => { data.library.category_scope = 'recipe'; },
data => { delete data.library.category_scope; },
data => { delete data.library.tags_scope; },
]) {
const data = envelope(); mutate(data);
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
+54
View File
@@ -0,0 +1,54 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/library-layout.mjs');
/** Runs `body` with the process time zone set to `zone`, restoring the previous one. */
async function inZone(zone, body) {
const previous = process.env.TZ;
process.env.TZ = zone;
try { await body(); } finally {
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
}
}
test('used label boundaries in UTC and local time', async () => {
const { usedLabel } = await source;
await inZone('UTC', () => {
const now = new Date('2026-10-20T12:00:00Z');
assert.equal(usedLabel('2026-10-20T00:00:00Z', now), 'Used today');
assert.equal(usedLabel('2026-10-20T11:59:59Z', now), 'Used today');
assert.equal(usedLabel('2026-10-19T23:59:59Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-19T00:00:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-18T23:59:59Z', now), 'Used 2 days ago');
assert.equal(usedLabel('2026-10-14T00:00:00Z', now), 'Used 6 days ago');
assert.equal(usedLabel('2026-10-13T23:59:59Z', now), 'Used Oct 13');
assert.equal(usedLabel('2026-01-02T08:00:00Z', now), 'Used Jan 2');
// D23: a use in another calendar year names its year, so last year's Oct 21 never reads as this year's.
assert.equal(usedLabel('2025-10-21T08:00:00Z', now), 'Used Oct 21, 2025');
assert.equal(usedLabel('2025-12-31T23:59:59Z', now), 'Used Dec 31, 2025');
// A clock ahead of this browser is not a future use: it reads as today.
assert.equal(usedLabel('2026-10-21T09:00:00Z', now), 'Used today');
});
// The day boundary is the viewer's local midnight, not UTC's (PDT is UTC-7 in October).
await inZone('America/Los_Angeles', () => {
const now = new Date('2026-10-20T07:30:00Z');
assert.equal(usedLabel('2026-10-20T07:10:00Z', now), 'Used today');
assert.equal(usedLabel('2026-10-20T06:30:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-14T07:00:00Z', now), 'Used 6 days ago');
assert.equal(usedLabel('2026-10-14T06:59:59Z', now), 'Used Oct 13');
});
// Across the November DST change a calendar day is still one day.
await inZone('America/New_York', () => {
const now = new Date('2026-11-02T05:30:00Z');
assert.equal(usedLabel('2026-11-01T04:30:00Z', now), 'Used yesterday');
assert.equal(usedLabel('2026-10-31T05:30:00Z', now), 'Used 2 days ago');
});
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
});
test('rating label omits unrated', async () => {
const { ratingLabel } = await source;
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
});
+11 -2
View File
@@ -2,12 +2,21 @@ const test=require('node:test');
const assert=require('node:assert/strict');
const source=import('../../web/js/workspace/library-state.mjs');
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
});
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null);
await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7);
const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose();
for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();}
s.dispose();
});
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
@@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}}));
});
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
+5 -3
View File
@@ -40,8 +40,10 @@ function mountedDom() {
set textContent(value){this._text=String(value);this.children=[];}
get childNodes(){return this.children;}
get isConnected(){return this.root || !!this.parent?.isConnected;}
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
// As in the DOM, a string argument becomes a text node.
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
setAttribute(key,value){this.attributes[key]=String(value);}
@@ -117,7 +119,7 @@ function currentInspectionEnvelope(target) {
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
}
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
+13
View File
@@ -147,3 +147,16 @@ test('a Library or vocabulary write re-reads only the Library and Tags counts',
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
});
test('navigating to the current route re-renders it, as the concept go() does', async () => {
const {mountRouter,navigateTo}=await source;
const win=new EventTarget();win.location={hash:'#/library/12'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
navigateTo('#/library/12',win);
assert.deepEqual(seen,[['library',12],['library',12]]);
// A different route only sets the hash; its hashchange renders it once.
navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2);
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
});
+68
View File
@@ -565,6 +565,47 @@ test('an action may open the next dialog without the late close event closing it
}
});
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const trigger = new dom.Element('button');
dom.body.append(trigger);
let closed = 0;
const grid = new dom.Element('div');
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
assert.equal(modal(dom).open, true);
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
assert.equal(closed, 1);
assert.equal(document.activeElement, trigger);
await tick();
assert.equal(closed, 1);
// Another dialog replacing the body releases it too, exactly once.
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
openCopyFallback('swap', { scope });
assert.equal(closed, 2);
closeWorkspaceDialog();
assert.equal(closed, 2);
// Disposing the page scope closes it.
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
scope.dispose();
assert.equal(closed, 3);
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
const other = (await coreModule).createScope();
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
other.dispose();
} 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;
@@ -611,3 +652,30 @@ test('every route change closes the shared workspace dialog', async () => {
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
});
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
test('the Filters button shows the active refinement count', 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');
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: 2, onChange() {}, onFilters() {} });
const filtersButton = container.children[0].children[3];
// The visible text is the accessible name, so the count is spoken too.
assert.equal(filtersButton.textContent, 'Filters · 2');
toolbar.setActiveFilters(1);
assert.equal(filtersButton.textContent, 'Filters · 1');
toolbar.setActiveFilters(0);
assert.equal(filtersButton.textContent, 'Filters');
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
assert.equal(filtersButton.textContent, 'Filters');
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
activeFilters: -1, onChange() {}, onFilters() {} }), TypeError);
} finally {
scope.dispose();
dom.restore();
}
});
+80
View File
@@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase):
response, body, 409, "history-changed", {"action": "refresh-history"}
)
async def test_newest_pair_run_route_names_the_run_or_none(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
quiet = self.api.db.save_prompt_pair("positive", "quiet")
foreign = self.api.db.save_prompt_pair("elsewhere", "negative")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
self.capture(0, 100)
newest = self.capture(1, 200)
base = f"/prompt_manager/library/{positive}/history/pairs"
response = await self.client.get(f"{base}/{pair}/newest")
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
self.assertEqual(body["history_version"], 1)
self.assertEqual(body["history_epoch"], newest.storage_epoch)
self.assertEqual(body["run"]["run_id"], newest.run_id)
self.assertEqual(body["run"]["anchor_id"], "4")
response = await self.client.get(f"{base}/{quiet}/newest")
self.assertEqual((await response.json())["run"], None)
response = await self.client.get(f"{base}/{foreign}/newest")
self.assert_error(response, await response.json(), 404, "not-found")
response = await self.client.get(
f"/prompt_manager/library/999999/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"):
response = await self.client.get(path)
self.assert_error(response, await response.json(), 400, "invalid-request")
response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"})
self.assert_error(response, await response.json(), 400, "invalid-request")
async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self):
pair = self.api.db.save_prompt_pair("positive", "negative")
saved = self.api.db.get_prompt_pair(pair)
self.capture(0, 100)
url = (
f"/prompt_manager/library/{saved['positive_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
# A prompt saved only as a negative is not a Library positive.
response = await self.client.get(
f"/prompt_manager/library/{saved['negative_prompt_id']}"
f"/history/pairs/{pair}/newest"
)
self.assert_error(response, await response.json(), 404, "not-found")
private = "SECRET_PROMPT /private/data/secret.db"
for exc in (sqlite3.OperationalError("busy " + private), OSError(private)):
with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc):
with self.assertLogs("prompt_manager.api", "WARNING") as logs:
response = await self.client.get(url)
body = await response.json()
self.assert_error(
response, body, 503, "storage-unavailable", {"action": "retry-read"}
)
self.assertNotIn(private, json.dumps(body) + " ".join(logs.output))
source = str(self.comfy_dir / "source.db")
self.assertTrue(self.api.db.model.backup_database(source))
writer = PromptDatabase(self.api.db.model.db_path)
original = self.api.db.get_newest_pair_run
def during_snapshot(*args, **kwargs):
result = original(*args, **kwargs)
with patch("database.execution_history.notify_history_restore"):
writer.model.restore_from_file(source)
return result
try:
with patch.object(
self.api.db, "get_newest_pair_run", side_effect=during_snapshot
):
response = await self.client.get(url)
self.assert_error(
response,
await response.json(),
409,
"history-changed",
{"action": "refresh-history"},
)
finally:
writer.close_all()
async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self):
start = make_start(
SESSION,
+44 -1
View File
@@ -375,12 +375,55 @@ class TestLibraryAPI(ImageAPITestCase):
result["image"],
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
)
self.assertEqual(result["library"]["metadata_scope"], "pair")
# D39: the recipe has no category or tags, so both fall back.
self.assertEqual(result["library"]["category_scope"], "positive")
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["pair_id"], pair)
self.assertEqual(result["library"]["positive_id"], positive)
# D10/D20: the linked positive's title; "Pair title" names the variant.
self.assertEqual(result["library"]["title"], "Positive title")
async def test_library_gallery_reports_favorites_with_one_lookup_per_page(self):
# The Library-embedded media cards carry the Images star (#249 PR E):
# favorites come from the store keyed by root and relative path.
pair = self.api.db.save_prompt_pair("Cat", "Noise")
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
for index in range(5):
path = make_png(self.output_dir / "sub" / f"{index}.png")
image = self.api.db.link_image_to_prompt(positive, str(path))
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
response = await self.client.put(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "sub/3.png"},
)
self.assertEqual(response.status, 200)
lookups = []
original = self.api.db.get_image_favorites
def counted(root_key, paths):
lookups.append(list(paths))
return original(root_key, paths)
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
stars = {image["path"]: image["favorite"] for image in gallery["images"]}
self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)})
# One batched read per page, never one per card.
self.assertEqual([len(paths) for paths in lookups], [5])
# Shelf previews show no star and read no favorites.
lookups.clear()
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
_, listing = await self.get()
self.assertEqual(lookups, [])
self.assertNotIn("favorite", listing["items"][0]["previews"][0])
response = await self.client.delete(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "sub/3.png"},
)
self.assertEqual(response.status, 200)
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
self.assertFalse(any(image["favorite"] for image in gallery["images"]))
async def test_epoch_scoped_all_library_cursors(self):
from database.execution_history_schema import rotate_history_epoch
+62 -2
View File
@@ -1302,6 +1302,64 @@ class TestPromptInspector(ImageAPITestCase):
)
roots.assert_called_once_with()
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
self,
):
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
# otherwise each field falls back to the linked positive's, separately, and
# the scopes say where each value came from (no merging of tag sets).
cases = (
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
(None, [], "Positive cat", "positive", ["positive"], "positive"),
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
)
for index, case in enumerate(cases):
recipe_category, recipe_tags = case[:2]
category, category_scope, tags, tags_scope = case[2:]
with self.subTest(case=index):
text = f"Fallback owner {index}"
positive = self._save_prompt(
text, category="Positive cat", tags=["positive"]
)
pair = self.api.db.save_prompt_pair(
text,
f"Fallback noise {index}",
category=recipe_category,
tags=recipe_tags,
)
self.assertEqual(
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
)
filename = f"fallback-{index}.png"
path = png(self.output_dir / filename)
image = self.api.db.link_image_to_prompt(positive, str(path))
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
response, result = await self.inspect(filename)
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["status"], "linked")
self.assertEqual(library["pair_id"], pair)
self.assertEqual(library["category"], category)
self.assertEqual(library["category_scope"], category_scope)
self.assertEqual(library["tags"], tags)
self.assertEqual(library["tags_scope"], tags_scope)
self.assertNotIn("metadata_scope", library)
async def test_positive_only_link_keeps_positive_scopes(self):
positive = self._save_prompt("Solo owner", tags=["solo"])
path = png(self.output_dir / "solo.png")
self.api.db.link_image_to_prompt(positive, str(path))
response, result = await self.inspect("solo.png")
self.assertEqual(response.status, 200)
library = result["library"]
self.assertEqual(library["pair_id"], None)
self.assertEqual(library["category"], None)
self.assertEqual(library["category_scope"], "positive")
self.assertEqual(library["tags"], ["solo"])
self.assertEqual(library["tags_scope"], "positive")
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
self,
):
@@ -1340,8 +1398,9 @@ class TestPromptInspector(ImageAPITestCase):
if count == 1:
self.assertEqual(result["library"]["positive_id"], positive)
self.assertEqual(
result["library"]["metadata_scope"], "positive"
result["library"]["category_scope"], "positive"
)
self.assertEqual(result["library"]["tags_scope"], "positive")
self.assertEqual(result["library"]["category"], "Category 1")
self.assertEqual(result["library"]["tags"], ["owner-1"])
if not readable:
@@ -1361,7 +1420,8 @@ class TestPromptInspector(ImageAPITestCase):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
for field in (
+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(
+88
View File
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
from database.operations import PromptDatabase
from py.config import GalleryConfig
from utils.execution_outputs import snapshot_output_roots
from utils.hashing import generate_prompt_hash
class CurrentLocationTests(unittest.TestCase):
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
# D39 ruling (2026-10-21), shared with the Images inspector.
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "gallery.db"))
self.addCleanup(db.close_all)
cases = (
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
)
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
with self.subTest(case=index):
text = f"owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(
text, "noise", category=recipe_category, tags=recipe_tags
)
path = make_png(self.output / f"fallback-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
library = result[locator]["library"]
self.assertEqual(library["pair_id"], pair)
self.assertEqual(
(
library["category"],
library["category_scope"],
library["tags"],
library["tags_scope"],
),
expected,
)
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
mod = self.module("database.history_gallery")
db = PromptDatabase(str(self.base / "blank.db"))
self.addCleanup(db.close_all)
cases = (
(" ", ["recipe"], 0),
("\u3000", [], 1),
("\u00a0 ", ["recipe"], 0),
)
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
with self.subTest(case=index):
text = f"blank owner {index}"
positive = db.save_prompt(
text,
prompt_hash=generate_prompt_hash(text),
category="Positive cat",
)
db.set_prompt_tags(positive, ["positive"])
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
path = make_png(self.output / f"blank-{index}.png")
locator = os.path.normcase(str(path))
image = db.link_image_to_prompt(positive, str(path))
db.link_image_to_pair(pair, image, {"source": "fixture"})
conn = db.model.get_connection()
# Legacy rows can hold a blank-looking category the writers now trim.
conn.execute(
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
)
conn.commit()
statements = []
conn.set_trace_callback(statements.append)
self.addCleanup(conn.set_trace_callback, None)
with db.recorded_history_snapshot():
result = mod.read_history_gallery(conn, (locator,))
conn.set_trace_callback(None)
library = result[locator]["library"]
self.assertEqual(library["category"], "Positive cat")
self.assertEqual(library["category_scope"], "positive")
tag_queries = [
statement
for statement in statements
if "FROM prompt_tags pt" in statement
]
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
def test_host_root_failure_and_removal_do_not_search_another_root(self):
make_png(self.output / "image.png")
make_png(self.temp / "image.png")
+76
View File
@@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase):
db.list_history_outputs(
start.run_id, after=OutputPosition("0" * 64, (node, 0))
)
def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
quiet = db.save_prompt_pair("positive", "C")
other = db.save_prompt_pair("elsewhere", "A")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
self.capture(db, 0, bundle("A"), time=100)
newest = self.capture(db, 1, bundle("A"), time=200)
b = self.capture(db, 2, bundle("B"), time=300)
with db.recorded_history_snapshot():
found = db.get_newest_pair_run(positive, pair_a)
found_b = db.get_newest_pair_run(positive, pair_b)
none = db.get_newest_pair_run(positive, quiet)
foreign = db.get_newest_pair_run(positive, other)
missing = db.get_newest_pair_run(9999, pair_a)
self.assertEqual(found["run"]["run_id"], newest.run_id)
self.assertEqual(found["run"]["anchor_id"], "4")
self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$")
self.assertEqual(found_b["run"]["run_id"], b.run_id)
self.assertEqual(none, {"run": None})
self.assertIsNone(foreign)
self.assertIsNone(missing)
# A hint that no longer names the current pair is not followed.
db.delete_prompt_pair(pair_b)
recreated = db.save_prompt_pair("positive", "B")
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None})
with self.assertRaises(sqlite3.ProgrammingError):
db.get_newest_pair_run(positive, pair_a)
def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self):
db = self.database()
pair_a = db.save_prompt_pair("positive", "A")
pair_b = db.save_prompt_pair("positive", "B")
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
older = self.capture(db, 0, bundle("A"), time=100)
stale_epoch = self.capture(db, 1, bundle("A"), time=200)
conn = db.model.get_connection()
# A row whose reference epoch is not the current one is never followed.
conn.execute(
"UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?",
("00000000-0000-4000-8000-000000000000", stale_epoch.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(
db.get_newest_pair_run(positive, pair_a)["run"]["run_id"],
older.run_id,
)
# Seventeen newer rows whose hint names pair A but whose texts resolve to
# pair B fill the candidate bound, so the older valid run is not reached.
for item in range(2, 19):
start = self.capture(db, item, bundle("B"), time=300 + item)
conn.execute(
"UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?",
(pair_a, start.run_id),
)
conn.commit()
with db.recorded_history_snapshot():
self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None})
self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b))
def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self):
db = self.database()
pair = db.save_prompt_pair("positive", "negative")
saved = db.get_prompt_pair(pair)
self.capture(db, 0, bundle("negative"), time=100)
with db.recorded_history_snapshot():
# A prompt saved only as a pair's negative is not a Library positive.
self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair))
db.delete_prompt_pair(pair)
db.delete_prompt(saved["positive_prompt_id"])
with db.recorded_history_snapshot():
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
+919
View File
@@ -0,0 +1,919 @@
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
``shutil.which`` are mocked, and route tests swap the API's opener.
"""
import ctypes
import json
import logging
import os
import subprocess
import sys
import tempfile
import types
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
from aiohttp import web
from multidict import CIMultiDict
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
import utils.file_reveal as file_reveal
from utils.file_reveal import (
OpenerUnavailable,
is_local_request,
reveal_in_file_manager,
)
REVEAL = "/prompt_manager/images/reveal"
PROXY_HEADERS = (
"Forwarded",
"Via",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Forwarded-Server",
"X-Real-IP",
"X-Original-Forwarded-For",
"X-Original-Host",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
"Fastly-Client-IP",
)
ORIGIN = "http://127.0.0.1:8188"
def headers(**values):
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
base.update({key.replace("_", "-"): value for key, value in values.items()})
return CIMultiDict({key: value for key, value in base.items() if value is not None})
class TestIsLocalRequest(unittest.TestCase):
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
for remote, host, origin in (
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
("::1", "[::1]:8188", "http://[::1]:8188"),
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
("127.0.0.1", "localhost", "http://localhost:80"),
):
with self.subTest(remote=remote, host=host):
self.assertTrue(
is_local_request(remote, headers(Host=host, Origin=origin))
)
self.assertTrue(
is_local_request(
"127.0.0.1",
headers(Host="localhost", Origin="https://localhost:443"),
"https",
)
)
def test_non_loopback_or_unknown_remote_is_not_local(self):
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
with self.subTest(remote=remote):
self.assertFalse(is_local_request(remote, headers()))
def test_any_forwarding_header_makes_loopback_remote(self):
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
for spelling in (name, name.lower()):
with self.subTest(header=spelling):
request_headers = headers()
request_headers[spelling] = "127.0.0.1"
self.assertFalse(is_local_request("127.0.0.1", request_headers))
def test_origin_is_required_and_must_be_the_request_origin(self):
# A header-less proxy or curl leaves Origin out; a page on another
# loopback port or scheme sends its own (security review M1, L2).
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
for origin in (
"",
"null",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"https://127.0.0.1:8188",
"http://localhost:8188",
"http://foo.localhost:8188",
"http://evil.example:8188",
"http://[bad",
"http://127.0.0.1:8188/path",
"http://user@127.0.0.1:8188",
"file://127.0.0.1:8188",
):
with self.subTest(origin=origin):
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
self.assertTrue(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
)
for site in ("same-site", "cross-site", "none", ""):
with self.subTest(site=site):
self.assertFalse(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
)
def test_foreign_host_is_not_local(self):
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
with self.subTest(host=host):
self.assertFalse(
is_local_request(
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
)
)
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
class TestRevealGate(unittest.TestCase):
def test_one_reveal_at_a_time_then_a_cooldown(self):
now = [100.0]
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
now[0] += 5
self.assertFalse(gate.try_enter())
gate.leave()
now[0] += 0.5
self.assertFalse(gate.try_enter())
now[0] += 0.5
self.assertTrue(gate.try_enter())
def test_zero_cooldown_only_blocks_while_busy(self):
gate = file_reveal.RevealGate(cooldown=0)
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
gate.leave()
self.assertTrue(gate.try_enter())
class TestRevealInFileManager(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temp.cleanup)
self.root = Path(os.path.realpath(temp.name)) / "out"
self.path = self.root / "sub" / "a.png"
self.path.parent.mkdir(parents=True)
self.path.write_bytes(b"png")
self.popen = patch.object(subprocess, "Popen").start()
self.reaped = patch.object(file_reveal, "_reap").start()
self.addCleanup(patch.stopall)
def reveal(self, path=None):
reveal_in_file_manager(self.path if path is None else path, self.root)
def which(self, available):
def lookup(name, path=None):
self.assertEqual(path, file_reveal.SYSTEM_PATH)
return available.get(name)
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
def argv(self, call=0):
return self.popen.call_args_list[call].args[0]
def assert_detached(self, call=0):
options = self.popen.call_args_list[call].kwargs
for stream in ("stdin", "stdout", "stderr"):
self.assertIs(options[stream], subprocess.DEVNULL)
self.assertIs(options["close_fds"], True)
self.assertIs(options["start_new_session"], True)
self.assertNotIn("shell", options)
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
def windows(self, selected=True, startfile=None):
"""Windows without Windows: the shell libraries and os.startfile
injected; the patched shell records its calls."""
shell = FakeShell(select=0 if selected else -2147467259)
select = patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
)
select.shell = shell
start = patch.object(
file_reveal.os, "startfile", startfile or MagicMock(), create=True
)
platform = patch.object(file_reveal.sys, "platform", "win32")
return select, start, platform
def test_windows_selects_through_the_shell_api_without_a_process(self):
# A comma path splits Explorer's own command line (security review H1);
# the shell API takes the path as one value, unchanged.
path = self.root / "a," / "b,c.png"
path.parent.mkdir()
path.write_bytes(b"png")
select, start, platform = self.windows(selected=True)
with select, start as startfile, platform:
self.reveal(path)
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
startfile.assert_not_called()
self.popen.assert_not_called()
def test_windows_falls_back_to_opening_the_folder(self):
select, start, platform = self.windows(selected=False)
with select, start as startfile, platform:
self.reveal()
self.assertEqual(
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
)
startfile.assert_called_once_with(str(self.path.parent), "explore")
self.popen.assert_not_called()
def test_windows_folder_start_failure_raises(self):
select, start, platform = self.windows(
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
)
with select, start, platform, self.assertRaises(OSError):
self.reveal()
self.popen.assert_not_called()
def test_windows_without_startfile_is_unavailable(self):
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.sys, "platform", "win32"),
patch.object(file_reveal.os, "startfile", None, create=True),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_macos_reveals_with_the_system_open(self):
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
self.which({"open": "/planted/open"}),
):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
self.assert_detached()
self.popen.return_value.wait.assert_not_called()
self.reaped.assert_called_once_with(self.popen.return_value)
def test_macos_open_is_the_fixed_system_path(self):
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
with patch.object(file_reveal.os.path, "isfile", return_value=False):
self.assertIsNone(file_reveal._macos_open())
with (
patch.object(file_reveal.os.path, "isfile", return_value=True),
patch.object(file_reveal.os, "access", return_value=True),
):
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value=None),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_path_lookup_never_counts(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
# Linux and macOS openers are POSIX paths whatever host runs the code,
# so a host os.path with those rules must not reject them.
windows_313 = SimpleNamespace(isabs=lambda value: False)
with (
patch.object(file_reveal.os, "path", windows_313),
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
):
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
self.assertIsNone(file_reveal._executable("rel"))
def test_children_get_a_minimal_environment(self):
inherited = {
"PATH": "/venv/bin:/usr/bin",
"PYTHONPATH": "/comfy",
"LD_LIBRARY_PATH": "/venv/lib",
"VIRTUAL_ENV": "/venv",
"CIVITAI_API_KEY": "secret",
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
# What a desktop opener can need (final review m1): the X11
# cookie under GDM/SDDM, the session and the user's mimeapps.
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
}
with patch.dict(os.environ, inherited, clear=True):
env = file_reveal._child_env()
self.assertEqual(
env,
{
"PATH": file_reveal.SYSTEM_PATH,
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
},
)
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
self.popen.return_value.wait.return_value = 0
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
):
self.reveal()
self.assertEqual(
self.argv(),
[
"/usr/bin/dbus-send",
"--session",
"--print-reply",
"--reply-timeout=2000",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:{self.path.as_uri()}",
"string:",
],
)
self.assertEqual(self.popen.call_count, 1)
self.popen.return_value.wait.assert_called_once_with(
timeout=file_reveal.DBUS_WAIT
)
self.assert_detached()
self.reaped.assert_not_called()
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
cases = {
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
"no reply": (both, 1),
"start error": (both, OSError("no bus")),
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
}
for name, (available, outcome) in cases.items():
with self.subTest(name):
self.popen.reset_mock(side_effect=True, return_value=True)
self.reaped.reset_mock()
dbus = MagicMock()
if isinstance(outcome, OSError):
self.popen.side_effect = [outcome, MagicMock()]
elif isinstance(outcome, subprocess.TimeoutExpired):
dbus.wait.side_effect = [outcome, None]
self.popen.side_effect = [dbus, MagicMock()]
else:
self.popen.return_value.wait.return_value = outcome
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which(available),
):
self.reveal()
self.assertEqual(
self.popen.call_args.args[0],
["/usr/bin/xdg-open", str(self.path.parent)],
)
self.assert_detached(self.popen.call_count - 1)
self.reaped.assert_called_once()
if name == "hung":
dbus.kill.assert_called_once_with()
def test_linux_without_any_opener_is_unavailable(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_paths_are_refused(self):
with self.assertRaises(ValueError):
reveal_in_file_manager(Path("sub/a.png"), self.root)
self.popen.assert_not_called()
def test_containment_is_rechecked_just_before_launching(self):
# A parent swapped for a symlink after the route resolved the path
# (security review L4) must not open a folder outside the root.
outside = self.root.parent / "elsewhere"
outside.mkdir()
(outside / "a.png").write_bytes(b"png")
(self.root / "sub" / "a.png").unlink()
(self.root / "sub").rmdir()
try:
os.symlink(outside, self.root / "sub", target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks unavailable")
missing = self.root / "gone.png"
folder = self.root / "folder.png"
folder.mkdir()
for path in (self.path, missing, folder):
for platform in ("linux", "darwin", "win32"):
with (
self.subTest(path=path.name, platform=platform),
patch.object(file_reveal.sys, "platform", platform),
patch.object(file_reveal, "_windows_libraries") as shell,
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal(path)
shell.assert_not_called()
self.popen.assert_not_called()
def test_a_path_on_another_drive_is_not_contained(self):
# Windows: commonpath raises ValueError across drives.
with (
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal()
self.popen.assert_not_called()
class TestReaping(unittest.TestCase):
def test_reap_waits_on_a_daemon_thread(self):
process = MagicMock()
with patch.object(file_reveal.threading, "Thread") as thread:
file_reveal._reap(process)
thread.assert_called_once()
kwargs = thread.call_args.kwargs
self.assertIs(kwargs["target"], process.wait)
self.assertIs(kwargs["daemon"], True)
thread.return_value.start.assert_called_once_with()
def test_reveal_runs_on_its_own_single_worker(self):
executor = file_reveal.reveal_executor()
self.assertIs(file_reveal.reveal_executor(), executor)
self.assertEqual(executor._max_workers, 1)
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
class FakeShell:
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
def __init__(self, init=0, pidl=1234, select=0):
self.calls = []
self.ole32 = types.SimpleNamespace(
CoInitializeEx=self.fn("CoInitializeEx", init),
CoUninitialize=self.fn("CoUninitialize", None),
)
self.shell32 = types.SimpleNamespace(
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
ILFree=self.fn("ILFree", None),
)
def fn(self, name, result):
def call(*args):
self.calls.append((name, args))
if isinstance(result, Exception):
raise result
return result
return MagicMock(side_effect=call)
def names(self):
return [name for name, _ in self.calls]
class TestWindowsReveal(unittest.TestCase):
"""The Windows opener with the shell libraries and os.startfile faked.
The folder fallback is recorded in the same call list, so its place
inside the COM bracket is visible (final review m2)."""
def reveal(self, shell, path=r"C:\out\a,\b.png"):
def startfile(*args):
shell.calls.append(("startfile", args))
with (
patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
),
patch.object(
file_reveal.os,
"startfile",
MagicMock(side_effect=startfile),
create=True,
),
):
file_reveal._reveal_windows(Path(path))
return shell
def test_selects_the_item_and_frees_everything(self):
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
shell = self.reveal(FakeShell(), path)
self.assertEqual(
shell.names(),
[
"CoInitializeEx",
"ILCreateFromPathW",
"SHOpenFolderAndSelectItems",
"ILFree",
"CoUninitialize",
],
)
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
self.assertEqual(
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
)
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
def test_declares_the_shell_signatures(self):
shell = self.reveal(FakeShell())
create = shell.shell32.ILCreateFromPathW
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
self.assertIs(create.restype, ctypes.c_void_p)
open_select = shell.shell32.SHOpenFolderAndSelectItems
self.assertEqual(
open_select.argtypes,
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
)
self.assertIs(open_select.restype, ctypes.c_long)
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
self.assertEqual(
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
)
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
def test_already_initialized_com_is_used_and_left_alone(self):
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
self.assertNotIn("CoUninitialize", shell.names())
self.assertNotIn("startfile", shell.names())
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
self.assertNotIn("startfile", shell.names())
self.assertEqual(shell.names()[-1], "CoUninitialize")
def test_the_folder_fallback_runs_inside_com(self):
# ShellExecute callers should initialize COM first (final review m2).
for name, shell in {
"no pidl": FakeShell(pidl=None),
"select failed": FakeShell(select=-2147467259),
"raises": FakeShell(select=OSError("access violation")),
}.items():
with self.subTest(name):
names = self.reveal(shell).names()
self.assertEqual(names[0], "CoInitializeEx")
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
if name != "no pidl":
self.assertIn("ILFree", names)
def test_failed_com_still_opens_the_folder_without_balancing(self):
for init in (-2147024882, OSError("no ole32 export")):
with self.subTest(init=init):
names = self.reveal(FakeShell(init=init)).names()
self.assertEqual(names, ["CoInitializeEx", "startfile"])
def test_missing_libraries_open_the_folder(self):
startfile = MagicMock()
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.os, "startfile", startfile, create=True),
):
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
def test_libraries_are_unavailable_off_windows(self):
if sys.platform == "win32":
self.skipTest("loads the real shell libraries on Windows")
with self.assertRaises((AttributeError, OSError)):
file_reveal._windows_libraries()
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
class TestRealWindowsShell(unittest.TestCase):
"""Final review I1: the declared signatures against the real ole32 and
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
def setUp(self):
self.ole32, self.shell32 = file_reveal._windows_libraries()
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
path = Path(folder) / "a,b.png"
path.write_bytes(b"png") # write_bytes closes its handle
pidl = self.shell32.ILCreateFromPathW(str(path))
self.assertIsInstance(pidl, int)
self.assertNotEqual(pidl, 0)
self.shell32.ILFree(pidl)
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
path.unlink()
def probe(self):
"""CoInitializeEx's answer on this thread, balanced at once."""
result = self.ole32.CoInitializeEx(
None,
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
)
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
self.ole32.CoUninitialize()
return result
def test_com_is_entered_and_balanced_through_the_module_helper(self):
before = self.probe()
with file_reveal._com_apartment(self.ole32) as ready:
self.assertIs(ready, True)
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
self.assertEqual(self.probe(), before)
class TestRevealRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.opened, self.roots, self.threads = [], [], []
self.outcome = None
def opener(path, root):
self.threads.append(file_reveal.threading.current_thread().name)
if self.outcome is not None:
raise self.outcome
self.opened.append(path)
self.roots.append(root)
self.api._reveal_opener = opener
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
return app
def origin(self):
return str(self.client.make_url("/").origin())
def scope(self, index=0):
roots = self.api._get_all_output_dirs()
return self.api._gallery_root_scope(roots[index], index)
async def post(self, body, **request_headers):
# Like the workspace's fetch: a same-origin JSON POST.
sent = {"Origin": self.origin(), **request_headers}
sent = {key: value for key, value in sent.items() if value is not None}
response = await self.client.post(REVEAL, json=body, headers=sent)
return response.status, await response.json()
def body(self, path="sub/a.png", **changes):
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
async def test_loopback_request_opens_the_resolved_file(self):
target = make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body())
self.assertEqual((status, data), (200, {"success": True}))
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
self.assertEqual(self.roots, [self.output_dir])
# Its own worker, never ComfyUI's shared default executor (L1).
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
async def test_target_changed_before_launch_is_404(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = file_reveal.RevealTargetChanged()
status, data = await self.post(self.body())
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
async def test_non_loopback_remote_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
with patch.object(
web.BaseRequest, "remote", new_callable=PropertyMock
) as remote:
remote.return_value = "192.168.1.20"
status, data = await self.post(self.body())
self.assertEqual(status, 403)
self.assertEqual(data["code"], "not-local")
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_loopback_with_a_proxy_header_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
for name in PROXY_HEADERS:
with self.subTest(header=name):
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), Host="evil.example:8188")
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual(self.opened, [])
async def test_missing_or_foreign_origin_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
port = self.client.make_url("/").port
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
with self.subTest(origin=origin):
status, data = await self.post(self.body(), Origin=origin)
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
async def test_only_json_bodies_are_accepted(self):
# text/plain would be a CORS "simple" request with no preflight (L2).
make_png(self.output_dir / "sub" / "a.png")
body = json.dumps(self.body())
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
with self.subTest(content_type=content_type):
sent = {"Origin": self.origin()}
if content_type:
sent["Content-Type"] = content_type
response = await self.client.post(
REVEAL, data=body.encode(), headers=sent
)
self.assertEqual(response.status, 415)
data = await response.json()
self.assertEqual(
(data["success"], data["code"]), (False, "unsupported-media-type")
)
self.assertEqual(self.opened, [])
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
make_png(self.output_dir / "sub" / "a.png")
gate = self.api._reveal_gate
self.assertTrue(gate.try_enter())
status, data = await self.post(self.body())
self.assertEqual(status, 429)
self.assertEqual(data, {**data, "success": False, "code": "busy"})
gate.leave()
now = [50.0]
self.api._reveal_gate = file_reveal.RevealGate(
cooldown=1.0, clock=lambda: now[0]
)
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
now[0] += 1.0
statuses.append((await self.post(self.body()))[0])
self.assertEqual(statuses, [200, 429, 200])
self.assertEqual(len(self.opened), 2)
async def test_a_failed_reveal_releases_the_gate(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError("cannot start")
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
self.outcome = None
status, _ = await self.post(self.body())
self.assertEqual(status, 200)
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
gate = type(self.api)._reveal_gate
self.assertIsInstance(gate, file_reveal.RevealGate)
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
async def test_resolution_errors_are_404_and_never_log_the_path(self):
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
make_png(self.output_dir / "sub" / "a.png")
secret = "sub/" + "n" * 300 + "/a.png"
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
self.api.logger.debug("start")
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
with patch.object(
type(self.api),
"_reveal_path",
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
):
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertIs(data["success"], False)
for record in logs.records:
if record.levelno >= logging.WARNING:
self.assertNotIn("n" * 300, record.getMessage())
self.assertEqual(self.opened, [])
async def test_start_failure_never_logs_the_path_at_error_level(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
self.assertTrue(errors)
for record in errors:
self.assertNotIn(str(self.output_dir), record.getMessage())
self.assertIsNone(record.exc_info)
async def test_changed_or_missing_scope_is_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body(root_scope="0" * 64))
self.assertEqual((status, data["code"]), (409, "source-changed"))
status, data = await self.post(self.body(root_index=3))
self.assertEqual((status, data["code"]), (409, "source-changed"))
for scope in (None, "ABC", 7, "g" * 64):
with self.subTest(scope=scope):
status, _ = await self.post(self.body(root_scope=scope))
self.assertEqual(status, 400)
body = self.body()
del body["root_scope"]
status, _ = await self.post(body)
self.assertEqual(status, 400)
self.assertEqual(self.opened, [])
async def test_body_types_are_strict(self):
make_png(self.output_dir / "sub" / "a.png")
for changes in (
{"root_index": True},
{"root_index": "0"},
{"root_index": -1},
{"root_index": 0.0},
{"path": 5},
{"path": ""},
{"path": "a" * 5000 + ".png"},
{"path": "sub/a\0.png"},
):
with self.subTest(changes=changes):
status, data = await self.post(self.body(**changes))
self.assertEqual(status, 400, data)
response = await self.client.post(
REVEAL,
data="[1]",
headers={"Origin": self.origin(), "Content-Type": "application/json"},
)
self.assertEqual(response.status, 400)
self.assertEqual(self.opened, [])
async def test_traversal_and_absolute_paths_are_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
outside = make_png(self.comfy_dir / "outside.png")
for path in (
"../outside.png",
"sub/../../outside.png",
"sub\\..\\..\\outside.png",
str(outside),
"/" + outside.name,
"C:/outside.png",
"C:outside.png",
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, 400, data)
self.assertEqual(self.opened, [])
async def test_symlink_out_of_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "link.png")
status, _ = await self.post(self.body(path="link.png"))
self.assertIn(status, (403, 404))
self.assertEqual(self.opened, [])
async def test_missing_non_media_and_non_regular_files(self):
(self.output_dir / "folder.png").mkdir()
(self.output_dir / "notes.txt").write_text("x")
for path, expected in (
("sub/missing.png", 404),
("folder.png", 404),
("notes.txt", 403),
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, expected, data)
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_unavailable_opener_is_501_and_start_failure_500(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OpenerUnavailable()
status, data = await self.post(self.body())
self.assertEqual(status, 501)
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
self.outcome = OSError("cannot start")
status, data = await self.post(self.body())
self.assertEqual(status, 500)
self.assertIs(data["success"], False)
self.assertNotIn("cannot start", data["error"])
async def test_default_opener_is_the_platform_file_manager(self):
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -36,7 +36,8 @@ LIBRARY = {
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
NODE_TIMEOUT_SECONDS = 120
+17 -1
View File
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
for key in ("positive_id", "pair_id", "category", "title"):
self.assertNotIn(key, result["library"])
self.assertEqual(result["library"]["tags"], ["valid"])
# An unknown or non-string scope is dropped, never passed through.
for bad in ("recipe", 3, ["pair"]):
result = json.loads(
encode(
None,
library={
"status": "linked",
"category_scope": bad,
"tags_scope": bad,
"tags": [],
},
)
)
self.assertNotIn("category_scope", result["library"])
self.assertNotIn("tags_scope", result["library"])
result = json.loads(
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
)
+11
View File
@@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase):
self.db.list_library_images(positive, pair_id=unrelated)
self.assertIsNone(self.db.get_library_positive(9999))
def test_detail_variants_carry_their_pair_run_count(self):
counted = self.db.save_prompt_pair("Cat", "Blur")
fresh = self.db.save_prompt_pair("Cat", "Noise")
self.db.record_prompt_pair_use(counted, 3)
positive = self.db.get_prompt_pair(counted)["positive_prompt_id"]
variants = {
row["id"]: row for row in self.db.get_library_positive(positive)["variants"]
}
self.assertEqual(variants[counted]["run_count"], 3)
self.assertEqual(variants[fresh]["run_count"], 0)
def test_options_include_active_pair_categories_not_negative_only_or_archived(self):
self.save("Legacy", category="Legacy category")
pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category")
+11 -10
View File
@@ -73,18 +73,21 @@ 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.
// Each kept page stylesheet is linted: 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']) {
for (const name of ['library.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']) {
// #249 C8 and D3: the Images page and the inspector render the concept DOM, so the replaced production rules stay
// retired; the few production-only rules still consumed live in the overlay.
test('the overlay keeps no retired Images page or inspector rules', () => {
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
// `.image-grid` and `.image-card-actions` are concept selectors now, so only the retired production names are listed.
for (const retired of ['.image-facts', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation',
'.image-details', '.file-cover', '.file-inspector-inner', '.file-title', '.file-chip', '.file-facts', '.generation-facts', '.file-inspector-footer']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
@@ -106,15 +109,13 @@ test('workspace.html loads controls, viewer, concept, page CSS and the overlay i
'js/shared/toolkit/controls.css',
'lib/viewerjs/viewer.min.css',
'js/workspace/concept.css',
'js/workspace/images.css',
'js/workspace/library.css',
'js/workspace/inspector.css',
'js/workspace/workspace.css',
]);
});
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
test('the retired shell, vocabulary, editor, Images and inspector stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css']) {
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
}
});
+451
View File
@@ -0,0 +1,451 @@
"""Show one gallery file in this computer's file manager (TESTING.md D38).
The route that calls this is local-only (``is_local_request``) and
single-flight (``RevealGate``), and hands in a path it has already resolved
inside a gallery root, which is re-checked just before launching. Windows
goes through the shell API (no command line exists to parse); macOS and Linux
openers run as an argument list with an absolute executable from a fixed
system PATH, no shell, no inherited streams and a minimal environment, so
neither a file name nor ``PATH`` can change what runs or what it parses.
"""
import ipaddress
import os
import posixpath
import shutil
# Only POSIX argv launches of a fixed opener (see _launch); Windows never
# starts a process (see _select_item).
import subprocess # nosec B404
import sys
import threading
import time
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlsplit
# A local reverse proxy makes every client look like loopback, so a request
# that carries any forwarding-style header counts as remote: these names, and
# every X-Forwarded-*, X-Original-* and *-Client-IP header.
FORWARDING_HEADERS = frozenset(
(
"forwarded",
"via",
"x-real-ip",
"cf-connecting-ip",
"true-client-ip",
"x-client-ip",
"x-cluster-client-ip",
"fastly-client-ip",
)
)
FORWARDING_PREFIXES = ("x-forwarded-", "x-original-")
FORWARDING_SUFFIXES = ("-client-ip",)
DEFAULT_PORTS = {"http": 80, "https": 443}
# One reveal at a time per process, then a pause before the next.
REVEAL_COOLDOWN = 1.0
# dbus-send waits this long for the FileManager1 reply, so its exit status
# says whether a file manager really took the request.
DBUS_REPLY_TIMEOUT_MS = 2000
# The dbus-send process itself gets a little longer before it is killed.
DBUS_WAIT = 3.0
FILE_MANAGER_DEST = "org.freedesktop.FileManager1"
# Openers are looked up here only, never on ComfyUI's (venv) PATH, and the
# children get this as their PATH.
SYSTEM_PATH = os.pathsep.join(
("/usr/local/bin", "/usr/bin", "/bin", "/run/current-system/sw/bin")
)
MACOS_OPEN = "/usr/bin/open"
# What a desktop opener needs from ComfyUI's environment; everything else
# (venv PATH, PYTHONPATH, LD_LIBRARY_PATH, API keys) stays behind.
CHILD_ENV_NAMES = (
"HOME",
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"XDG_DATA_DIRS",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
# The X11 cookie: a non-default path under GDM or SDDM.
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
"LANG",
"LC_ALL",
)
class OpenerUnavailable(Exception):
"""No file manager can be started on this computer."""
class RevealTargetChanged(Exception):
"""The file is no longer a regular file inside its root."""
_executor_lock = threading.Lock()
_executor: ThreadPoolExecutor | None = None
def reveal_executor() -> ThreadPoolExecutor:
"""The one worker that runs openers, so a slow file manager or dbus reply
never holds a thread of ComfyUI's shared default executor."""
global _executor
with _executor_lock:
if _executor is None:
_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="pm-reveal"
)
return _executor
def _loopback_address(value: str | None) -> bool:
if not value:
return False
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
mapped = getattr(address, "ipv4_mapped", None)
return (mapped or address).is_loopback
def _loopback_host(host: str) -> bool:
"""True for a Host name naming this computer.
Rejects every other name, so a DNS-rebinding page whose name now resolves
to 127.0.0.1 still reads as remote.
"""
host = host.rstrip(".").lower()
if host == "localhost" or host.endswith(".localhost"):
return True
return _loopback_address(host.strip("[]"))
def _authority(authority: str) -> tuple[str, int | None] | None:
"""``(host, port)`` of a Host value, or None when it does not parse."""
try:
parts = urlsplit(f"//{authority}")
host, port = parts.hostname, parts.port
except ValueError:
return None
return (host, port) if host else None
def _forwarded(headers: Mapping) -> bool:
for name in headers.keys():
name = name.lower()
if (
name in FORWARDING_HEADERS
or name.startswith(FORWARDING_PREFIXES)
or name.endswith(FORWARDING_SUFFIXES)
):
return True
return False
def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool:
"""True when *origin* is exactly this request's scheme://host:port."""
default = DEFAULT_PORTS.get(scheme)
if default is None:
return False
try:
parts = urlsplit(origin)
origin_host, origin_port = parts.hostname, parts.port
except ValueError:
return False
if (
parts.scheme != scheme
or not origin_host
or parts.username is not None
or parts.password is not None
or parts.path
or parts.query
or parts.fragment
):
return False
host_name, host_port = host
return (origin_host.rstrip("."), origin_port or default) == (
host_name.rstrip("."),
host_port or default,
)
def is_local_request(remote: str | None, headers: Mapping, scheme="http") -> bool:
"""True only for a same-origin browser request from this computer.
That is: no forwarding-style header, a loopback client, a loopback name
in Host, an ``Origin`` that is exactly this request's scheme://host:port
(browsers send it on every fetch POST, so a missing one is refused), and
``Sec-Fetch-Site: same-origin`` whenever that header is sent.
"""
if _forwarded(headers):
return False
if not _loopback_address(remote):
return False
host = _authority(headers.get("Host", ""))
if host is None or not _loopback_host(host[0]):
return False
origin = headers.get("Origin")
if not origin or not _same_origin(origin, scheme, host):
return False
site = headers.get("Sec-Fetch-Site")
return site is None or site == "same-origin"
class RevealGate:
"""Single flight plus a cooldown: at most one reveal runs per process,
and the next may start only *cooldown* seconds after it finished."""
def __init__(self, cooldown: float = REVEAL_COOLDOWN, clock=time.monotonic):
self.cooldown = cooldown
self._clock = clock
self._lock = threading.Lock()
self._busy = False
self._finished: float | None = None
def try_enter(self) -> bool:
with self._lock:
if self._busy:
return False
if (
self._finished is not None
and self._clock() - self._finished < self.cooldown
):
return False
self._busy = True
return True
def leave(self) -> None:
with self._lock:
self._busy = False
self._finished = self._clock()
def _executable(name: str) -> str | None:
"""Absolute path of *name* on the fixed SYSTEM_PATH, never a relative hit.
Only the Linux and macOS openers are looked up here, so absoluteness follows
POSIX rules rather than the host's (Python 3.13 on Windows no longer counts
"/usr/bin/x" as absolute).
"""
found = shutil.which(name, path=SYSTEM_PATH)
return found if found and posixpath.isabs(found) else None
def _macos_open() -> str | None:
"""macOS's own ``open``, at its fixed system path."""
if os.path.isfile(MACOS_OPEN) and os.access(MACOS_OPEN, os.X_OK):
return MACOS_OPEN
return None
def _child_env() -> dict[str, str]:
env = {name: os.environ[name] for name in CHILD_ENV_NAMES if name in os.environ}
return {"PATH": SYSTEM_PATH, **env}
def _reap(process: subprocess.Popen) -> None:
"""Wait for *process* on a daemon thread, so it never lingers as a zombie
and is never waited on by the caller."""
threading.Thread(target=process.wait, name="pm-reveal-reaper", daemon=True).start()
def _contained_file(path: Path, root: Path) -> Path:
"""*path*'s real path when it is still a regular file inside *root*.
Run right before launching: a parent swapped for a symlink since the
route resolved the path must not open a folder outside the root.
"""
real = os.path.realpath(path)
real_root = os.path.realpath(root)
try:
inside = os.path.commonpath(
[os.path.normcase(real), os.path.normcase(real_root)]
) == os.path.normcase(real_root)
except ValueError:
inside = False
if not inside or real == real_root or not os.path.isfile(real):
raise RevealTargetChanged()
return Path(real)
# HRESULTs as the signed 32-bit values ctypes.c_long returns.
S_OK = 0
S_FALSE = 1
RPC_E_CHANGED_MODE = -2147417850 # 0x80010106: COM already set up otherwise
COINIT_APARTMENTTHREADED = 0x2
COINIT_DISABLE_OLE1DDE = 0x4
def _windows_libraries():
"""Private ole32 and shell32 handles, so the signatures set here never
change another library's view of ``ctypes.windll``."""
import ctypes
return ctypes.WinDLL("ole32"), ctypes.WinDLL("shell32")
def _declare_shell_signatures(ole32, shell32) -> None:
import ctypes
ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
ole32.CoInitializeEx.restype = ctypes.c_long
ole32.CoUninitialize.argtypes = []
ole32.CoUninitialize.restype = None
shell32.ILCreateFromPathW.argtypes = [ctypes.c_wchar_p]
shell32.ILCreateFromPathW.restype = ctypes.c_void_p
shell32.SHOpenFolderAndSelectItems.argtypes = [
ctypes.c_void_p,
ctypes.c_uint,
ctypes.c_void_p,
ctypes.c_ulong,
]
shell32.SHOpenFolderAndSelectItems.restype = ctypes.c_long
shell32.ILFree.argtypes = [ctypes.c_void_p]
shell32.ILFree.restype = None
def _shell_libraries():
"""The declared ole32 and shell32, or None where they cannot be loaded."""
try:
ole32, shell32 = _windows_libraries()
_declare_shell_signatures(ole32, shell32)
except Exception: # no shell libraries: the folder fallback still runs
return None
return ole32, shell32
@contextmanager
def _com_apartment(ole32):
"""The calling thread inside COM for the block; yields whether COM is
usable. Only a successful CoInitializeEx (S_OK or S_FALSE) is balanced,
so the persistent reveal thread never drifts."""
try:
result = ole32.CoInitializeEx(
None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
)
except Exception: # an unusable ole32 leaves COM alone
result = None
try:
yield result in (S_OK, S_FALSE, RPC_E_CHANGED_MODE)
finally:
if result in (S_OK, S_FALSE):
ole32.CoUninitialize()
def _select_item(shell32, path: Path) -> bool:
"""Open Explorer on *path*'s folder with *path* selected, via the shell
API. The path is one wide-string value, so no command line is ever
parsed (Explorer splits its own on commas). False on any failure; never
raises into the route."""
try:
pidl = shell32.ILCreateFromPathW(str(path))
if not pidl:
return False
try:
return shell32.SHOpenFolderAndSelectItems(pidl, 0, None, 0) >= 0
finally:
shell32.ILFree(pidl)
except Exception: # any shell failure falls back to the folder
return False
def _explore(folder: Path) -> None:
startfile = getattr(os, "startfile", None)
if startfile is None:
raise OpenerUnavailable()
# ShellExecute on the folder itself: no command line is built or parsed.
startfile(str(folder), "explore")
def _reveal_windows(path: Path) -> None:
libraries = _shell_libraries()
if libraries is None:
_explore(path.parent)
return
ole32, shell32 = libraries
with _com_apartment(ole32) as ready:
if ready and _select_item(shell32, path):
return
# ShellExecute inside the same apartment: shell extensions may need COM.
_explore(path.parent)
def _launch(argv: list[str]) -> subprocess.Popen:
options = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
# Its own session, so stopping ComfyUI does not take the window along.
"start_new_session": True,
"env": _child_env(),
}
# POSIX only (Windows uses the shell API): argv[0] is an absolute opener,
# the rest are fixed flags and one absolute path inside a gallery root,
# passed to execve as separate strings with no shell to re-parse them.
return subprocess.Popen(argv, **options) # nosec B603
def _show_items(path: Path) -> bool:
"""Ask the freedesktop FileManager1 service to select *path*; True only
when the service replied (``--print-reply`` makes the exit status real)."""
dbus = _executable("dbus-send")
if dbus is None:
return False
argv = [
dbus,
"--session",
"--print-reply",
f"--reply-timeout={DBUS_REPLY_TIMEOUT_MS}",
f"--dest={FILE_MANAGER_DEST}",
"--type=method_call",
"/org/freedesktop/FileManager1",
f"{FILE_MANAGER_DEST}.ShowItems",
f"array:string:{path.as_uri()}",
"string:",
]
try:
process = _launch(argv)
except OSError:
return False
try:
return process.wait(timeout=DBUS_WAIT) == 0
except subprocess.TimeoutExpired:
process.kill()
process.wait()
return False
def reveal_in_file_manager(path: Path, root: Path) -> None:
"""Open the file manager at *path*, a regular file inside *root*.
Re-checks that containment first (RevealTargetChanged). Raises
OpenerUnavailable when no opener exists and OSError when the one found
cannot start. Never waits on the file manager itself.
"""
path = Path(path)
if not path.is_absolute():
raise ValueError("path must be absolute")
path = _contained_file(path, Path(root))
if sys.platform == "win32":
_reveal_windows(path)
return
if sys.platform == "darwin":
opener = _macos_open()
if opener is None:
raise OpenerUnavailable()
_reap(_launch([opener, "-R", str(path)]))
return
if _show_items(path):
return
opener = _executable("xdg-open")
if opener is None:
raise OpenerUnavailable()
_reap(_launch([opener, str(path.parent)]))
+39 -4
View File
@@ -289,6 +289,39 @@ def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
return result
def _blank(value: str | bytes | None) -> bool:
if isinstance(value, bytes):
value = value.decode("utf-8", "replace")
return value is None or not value.strip()
def library_metadata(
positive_category: str | bytes | None,
positive_tags: list,
recipe: tuple | None = None,
) -> dict:
"""The panel's saved category and tags, each with the scope it came from.
D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's
own category and tags when set; otherwise each field separately falls back to
the linked positive prompt's, the values Set category writes and the Images
filters match. Tag sets are never merged. ``recipe`` is (category, tags).
"""
category, category_scope = positive_category, "positive"
tags, tags_scope = positive_tags, "positive"
if recipe is not None:
if not _blank(recipe[0]):
category, category_scope = recipe[0], "pair"
if recipe[1]:
tags, tags_scope = recipe[1], "pair"
return {
"category": category,
"category_scope": category_scope,
"tags": tags,
"tags_scope": tags_scope,
}
def _library_projection(library: dict) -> dict:
status = library.get("status")
result = {
@@ -307,9 +340,10 @@ def _library_projection(library: dict) -> dict:
value = library.get(key)
if value is None or isinstance(value, str):
result[key] = value
scope = library.get("metadata_scope")
if scope in ("pair", "positive", None):
result["metadata_scope"] = scope
for key in ("category_scope", "tags_scope"):
scope = library.get(key)
if scope in ("pair", "positive", None):
result[key] = scope
tags = library.get("tags")
result["tags"] = (
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
@@ -323,7 +357,8 @@ def _library_projection(library: dict) -> dict:
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
}
return result
+202 -100
View File
@@ -3,6 +3,8 @@ import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { createSendButton } from './prompt-sender.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { navigateTo } from './state.mjs';
import { showWorkspaceToast } from './toast.mjs';
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
@@ -15,8 +17,14 @@ const REPRESENTATIONS = {
limited: 'Record exceeds inspection limits; text is unavailable',
};
const TEXTLESS = new Set(['invalid', 'limited']);
const REVEAL_URL = '/prompt_manager/images/reveal';
// D38 Show in folder: the route's answers, in the maintainer's words.
const REVEAL_OPENED = 'Opened the folder on this computer.';
const REVEAL_NOT_LOCAL = 'Show in folder works only in a browser on the computer running ComfyUI.';
const REVEAL_UNAVAILABLE = 'No file manager is available on this computer.';
const REVEAL_BUSY = 'The folder is already opening.';
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'};
function safeFilename(value) {
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
@@ -63,6 +71,23 @@ export function inspectionDownloads(record) {
return result;
}
/** The reveal body for a listed file's identity (`root_index`, `root_scope`, `path`), else null (D38). */
export function revealTarget(identity) {
if (identity?.kind !== 'rooted-file') return null;
const {root_index, root_scope, path} = identity;
if (!Number.isInteger(root_index) || root_index < 0 || typeof root_scope !== 'string' || !/^[0-9a-f]{64}$/.test(root_scope) ||
typeof path !== 'string' || !path) return null;
return {root_index, root_scope, path};
}
/** The toast for a reveal outcome: null is success, otherwise the request's error (D38). */
export function revealOutcome(error) {
if (error === null) return REVEAL_OPENED;
if (error?.status === 403 && error.code === 'not-local') return REVEAL_NOT_LOCAL;
if (error?.status === 501) return REVEAL_UNAVAILABLE;
if (error?.status === 429 && error.code === 'busy') return REVEAL_BUSY;
return REVEAL_FAILED;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -82,7 +107,8 @@ function disclosure(label) {
function definitionList(entries, className) {
const list = el('dl', undefined, className);
for (const [label, value] of entries) {
const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row);
// A value is its text, or a prepared `dd` (one a caller keeps to add to, such as D38's Path).
const row = el('div'); row.append(el('dt', label), typeof value === 'object' && value !== null ? value : el('dd', value ?? 'Not available')); list.append(row);
}
return list;
}
@@ -96,7 +122,41 @@ function formatBytes(value) {
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
/** The six facts, and the limit note their values point to when the configuration exceeded inspection limits. */
function generationFacts(record, listClass, noteClass) {
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), listClass);
if (!FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) return [facts];
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', noteClass);
explanation.id = `file-fact-limit-${++factReasonId}`;
FACTS.forEach(([key], index) => {
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
});
return [facts, explanation];
}
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
function evidenceBadge(prompts) {
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
}
/** The file's extension without its leading dot, upper case ("PNG"). */
function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
function prefixedChip(prefix, text) {
const chip = el('span', undefined, 'chip');
chip.append(el('span', prefix, 'ca-visually-hidden'), text);
return chip;
}
/**
* One file's inspector. `variant: 'panel'` is the Images side panel in the concept's structure (app.js imageDetails
* a400–414, D20): cover, heading, filename, D21 badges, prompt blocks, tag chips (`onTagFilter`), facts and footer.
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
*/
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -107,121 +167,174 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
scope.listen(container, 'wheel', onInteraction, {passive:true});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
function statusView(state, parent) {
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'small');
status.setAttribute('role', state.error ? 'alert' : 'status'); parent.append(status);
if (state.error && state.refreshRequired) {
if (onRefresh) {
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
contentScope.listen(refresh, 'click', onRefresh); parent.append(refresh);
} else parent.append(el('p', 'Reopen this file from a refreshed image collection.', 'small'));
} else if (state.error && onRetry) {
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); parent.append(retry);
}
}
function setState(state) {
if (scope.signal.aborted) return;
contentScope?.dispose(); contentScope = childScope(scope);
container.replaceChildren();
if (state.loading || state.error) {
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted');
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
if (state.error && state.refreshRequired) {
if (onRefresh) {
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
} else if (state.error && onRetry) {
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry);
}
return;
}
render(state);
}
/**
* The concept's imageDetails (app.js a400–414). The side panel has the cover, `.detail-inner` and `.detail-footer`;
* the viewer (`full`) has a class-less wrapper that adds the two disclosures, with the footer buttons after it (D20).
*/
function render(state) {
const inner = el('div', undefined, full ? undefined : 'detail-inner');
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
const record = state.record;
if (!record) return;
const {file, library, metadata} = record;
const {file, library} = record;
const filename = record.identity.path.split('/').at(-1);
if (inline && record.download) {
const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview');
const video = file?.media_type === 'video';
const media = el(video ? 'video' : 'img'); media.src = record.download.url;
if (video) {
media.muted = true; media.playsInline = true; media.preload = 'metadata';
contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();});
} else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));}
cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview);
else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';}
container.append(cover);
}
const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner);
const header = el('div', undefined, 'file-title');
header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename));
const extension = file?.extension || filename.split('.').at(-1);
header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip'));
inner.append(header, el('p', filename, 'file-name muted'));
const chips = el('div', undefined, 'file-chips');
const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source));
const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null;
if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence'));
if (library?.status === 'linked' && library.category) {
const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category';
chips.append(el('span', `${label}: ${library.category}`, 'file-chip'));
}
if (chips.childNodes.length) inner.append(chips);
const linked = library?.status === 'linked';
const title = linked && library.title ? library.title : filename;
if (record.download && !full) container.append(cover(record));
container.append(inner);
const heading = el('div', undefined, 'panel-heading');
heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extensionOf(record)));
inner.append(heading, el('p', filename, 'small'));
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
if (badge) badges.append(el('span', badge, 'chip blue'));
if (linked && library.category) badges.append(prefixedChip(library.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
if (badges.childNodes.length) inner.append(badges);
const prompts = el('div'); inner.append(prompts);
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (library?.status === 'linked') {
const vocabulary = el('div', undefined, 'file-vocabulary');
vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted'));
const tags = el('div', undefined, 'file-chips');
for (const tag of library.tags) tags.append(el('span', tag, 'file-chip'));
vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary);
} else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted'));
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts');
inner.append(facts);
if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) {
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted');
explanation.id = `file-fact-limit-${++factReasonId}`;
FACTS.forEach(([key], index) => {
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
if (linked && library.tags.length) {
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again.
const filterable = onTagFilter && library.tags_scope === 'positive';
const tags = el('div', undefined, 'tags');
for (const tag of library.tags) {
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
}
inner.append(tags);
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(provenance(record, badge), rawMetadata(record));
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createButton('', 'send');
pair.setAttribute('aria-label', `Send ${promptContext ? `${promptContext.toLowerCase()} ` : ''}prompt pair to ComfyUI: ${title}`);
pair.append(createWorkspaceIcon('send'), ' Send to ComfyUI'); pair.dataset.sendRole = 'pair';
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
}
if (record.historyTarget) {
const history = createButton('');
history.append(createWorkspaceIcon('branch'), ' Prompt history');
// The concept's go(): the viewer closes, and a link to the route already shown re-renders it.
contentScope.listen(history, 'click', () => {
document.querySelector('dialog#viewer-dialog[open]')?.close();
navigateTo(record.historyTarget);
});
inner.append(explanation);
footer.append(history);
}
const provenance = disclosure('File & provenance');
provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted'));
if (record.identity.kind === 'reported-location') {
const occurrence = record.identity.occurrence;
provenance.append(definitionList([
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
], 'file-occurrence'));
if (!full && footer.childNodes.length) container.append(footer);
}
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
function cover(record) {
const {file} = record, video = file?.media_type === 'video';
const node = el(video ? 'video' : 'img', undefined, 'detail-cover'); node.src = record.download.url;
if (video) {
node.muted = true; node.playsInline = true; node.preload = 'metadata';
contentScope.cleanup(() => {node.pause(); node.removeAttribute('src'); node.load();});
} else {
node.alt = ''; node.decoding = 'async';
if (file?.width && file?.height) {node.width = file.width; node.height = file.height;}
contentScope.cleanup(() => node.removeAttribute('src'));
}
if (file) provenance.append(definitionList([
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
return node;
}
/** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */
function provenance(record, badge) {
const {file, library} = record, section = disclosure('File & provenance');
const reported = record.identity.kind === 'reported-location';
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small'));
const path = el('dd', record.identity.path ?? 'Not available');
const facts = file && definitionList([
['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)],
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type],
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
], 'file-facts'));
else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted'));
for (const [key, label] of FACTS) {
const fact = record.generation[key];
if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted'));
], 'meta-grid');
if (facts) {
// D38: "Show in folder" follows the Path value.
const target = api ? revealTarget(record.identity) : null;
if (target) path.append(' ', revealButton(target));
section.append(facts);
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
if (reported) {
const occurrence = record.identity.occurrence;
section.append(definitionList([
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
], 'meta-grid'));
}
provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted'));
section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small'));
if (record.download) {
const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url;
original.download = safeFilename(record.download.filename); provenance.append(original);
const original = el('a', 'Download original'); original.href = record.download.url;
original.download = safeFilename(record.download.filename); section.append(original);
}
inner.append(provenance);
const raw = disclosure('Workflow & raw metadata');
raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted'));
if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted'));
return section;
}
/** D38: "Show in folder" right after the Path value, for a listed file only; the server opens its file manager. */
function revealButton(target) {
const button = createButton('Show in folder');
button.setAttribute('aria-label', `Show ${target.path.split('/').at(-1)} in folder`);
let pending = false;
contentScope.listen(button, 'click', async () => {
if (pending) return;
pending = true; button.setAttribute('aria-busy', 'true');
let error = null;
try {
await api.post(REVEAL_URL, target, {signal:contentScope.signal});
} catch (failure) {
error = failure ?? {};
}
pending = false; button.removeAttribute('aria-busy');
if (!contentScope.signal.aborted) showWorkspaceToast(revealOutcome(error));
});
return button;
}
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
function rawMetadata(record) {
const {metadata} = record, section = disclosure('Workflow & raw metadata');
section.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'small'));
if (!metadata) section.append(el('p', 'Full metadata is unavailable for this inspection response.', 'small'));
else {
if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted'));
if (metadata.status !== 'available') section.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'small'));
for (const key of RAW_KEYS) {
const format = metadata.record_formats[key]; if (!format) continue;
raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted'));
const name = el('p', undefined, 'small'); name.append(el('strong', key));
section.append(name, el('p', REPRESENTATIONS[format], 'small'));
if (Object.hasOwn(metadata.raw, key)) {
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text);
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); section.append(text);
}
}
}
const downloads = inspectionDownloads(record);
for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) {
const item = downloads[key], action = createButton(label);
const group = el('div', undefined, 'file-download'); group.append(action);
section.append(action);
if (!item.available) {
action.setAttribute('aria-disabled', 'true');
const explanation = el('p', item.reason, 'muted');
const explanation = el('p', item.reason, 'small');
// IDs need only be unique within the current document, including its two inspectors.
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation);
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); section.append(explanation);
}
contentScope.listen(action, 'click', () => {
if (!item.available || contentScope.signal.aborted) return;
@@ -232,19 +345,8 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
const anchor = el('a'); anchor.href = url; anchor.download = item.filename;
container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000);
});
raw.append(group);
}
inner.append(raw);
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
}
if (record.historyTarget) {
const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget;
history.prepend(createWorkspaceIcon('branch')); footer.append(history);
}
if (footer.childNodes.length) container.append(footer);
return section;
}
return {setState, dispose:() => scope.dispose()};
}
+10
View File
@@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) {
value.contexts.forEach(validateContext);
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
}
/**
* Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614):
* split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats.
*/
export function addedTerms(base, selected) {
if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings');
const terms = text => text.split(',').map(term => term.trim());
const baseline = new Set(terms(base));
return terms(selected).filter(term => term && !baseline.has(term));
}
export function frozenPromptRoles(context, mode) {
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
return Object.fromEntries(['positive', 'negative'].map(role => {
+13 -3
View File
@@ -29,9 +29,10 @@ function place(parent, node, before) {
else parent.insertBefore(node, before);
}
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) {
const scope = createScope(), dispose = () => scope.dispose();
if (parentScope.signal.aborted) {dispose();return {dispose};}
// openRun is a hoisted declaration: on an aborted parent it answers false before reading any later state.
if (parentScope.signal.aborted) {dispose();return {dispose, openRun};}
parentScope.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
const section = el('section', undefined, 'library-surface recorded-history');
@@ -61,6 +62,9 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
render(next);runView?.setState(next);
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
// The history summary's "N tracked runs" (#249 D24): a completed window read's count; a read in flight keeps the last.
const window = next.window;
if (!window.loading) onRecordedCount?.(window.history_epoch && !window.error ? window.recorded_run_count : null);
}});
state = controller.state;
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
@@ -218,5 +222,11 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
});
render(state);
void controller.load();
return {dispose};
/** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */
function openRun({runId, anchorId = null, contextId = null, trigger}) {
if (scope.signal.aborted || !state.actionsEnabled) return false;
runView.open({runId, anchorId, contextId, trigger});
return true;
}
return {dispose, openRun};
}
+5
View File
@@ -210,6 +210,11 @@ export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) {
return `${source} · ${subfolder || 'All output folders'}`;
}
/** D44: how many "Refine this view" filters (Source, Folder, Media, Tags) narrow the view, for the Filters label. */
export function activeRefinements({ root = 'all', subfolder = '', media = 'all', tags = [] }) {
return [root !== 'all' && root !== '', Boolean(subfolder), media !== 'all', tags.length > 0].filter(Boolean).length;
}
/** 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;
-57
View File
@@ -1,57 +0,0 @@
/* 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-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-details .workspace-link { display: flex; font-size: 11px; }
@media (max-width: 700px) {
.image-details { position: static; max-block-size: none; padding: 17px; }
.image-details > :where(img, video) { block-size: 200px; }
}
@container images-page (inline-size < 600px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; }
}
.options-status { font-size: 12px; padding-inline: 4px; }
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
.prompt-positive { --prompt-role: #73dda9; }
[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; }
.prompt-negative { --prompt-role: #ff8c98; }
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; }
.prompt-source { margin-block: 6px 10px; }
/* #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 { /* #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; }
.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; }
.prompt-sender::backdrop { background: #000b; }
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
@media (max-width: 780px) {
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
}
+51 -14
View File
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, 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';
@@ -14,6 +14,12 @@ import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
const inner = el('div', undefined, 'detail-inner');
inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small'));
return inner;
}
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
@@ -78,10 +84,16 @@ let lastVisit = null;
// 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. */
// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy.
const embeddedFavoriteTargets = new Set();
/**
* Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted
* Library-embedded gallery.
*/
function deliverFavorite(key, favorite) {
if (favoriteTarget) favoriteTarget(key, favorite);
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
for (const target of embeddedFavoriteTargets) target(key, favorite);
}
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
@@ -132,6 +144,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// 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},
activeFilters:activeRefinements(filters),
onChange:value => {
if (recovering) return;
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
@@ -207,9 +220,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
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.'));
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
details.setAttribute('aria-label', 'Selected image details');
details.append(noSelection());
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.
@@ -217,7 +232,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
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,
const mediaViewer = createMediaViewer({api,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));
@@ -246,8 +261,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
detailInspector?.dispose();
selection = imageKey(image);
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true,
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -270,8 +285,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
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).
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
// wherever the listing reports favorites (Images and Library-embedded media).
const overlays = el('div', undefined, 'image-overlays');
if (bar) {
const label = el('label'), box = el('input');
@@ -314,10 +329,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
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).
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
if (!embedded) {
favoriteTarget = setFavorite;
scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
} else {
embeddedFavoriteTargets.add(setFavorite);
scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite));
}
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
async function toggleFavorite(image) {
@@ -555,6 +573,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
function updateScope() {
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
toolbar?.setActiveFilters(activeRefinements(filters));
}
function reset() {
mediaViewer.stopSlideshow();
@@ -562,7 +581,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!recovering) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
details.replaceChildren(noSelection());
}
selection = null;
focusAfterLoad = null;
@@ -572,6 +591,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
updateScope();
stream.reset(filters, {load:!recovering});
}
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
function filterByTag(tag) {
if (recovering || !tag.trim() || tag.includes(',')) return;
if (filters.tags.length === 1 && filters.tags[0] === tag) return;
filters.tags = [tag];
reset();
}
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
function openFilters(trigger) {
if (recovering) return;
@@ -623,15 +649,26 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (scope.signal.aborted) return;
renderLoadStatus();
if (found) reset();
if (!scope.signal.aborted) showWorkspaceToast(rescanFinished(result));
if (!scope.signal.aborted) announceRescan(rescanFinished(result));
} catch {
// A failed rescan leaves the collection and any discovery alert as they were.
if (!scope.signal.aborted) showWorkspaceToast(RESCAN_FAILED);
if (!scope.signal.aborted) announceRescan(RESCAN_FAILED);
} finally {
rescanning = false;
importDialog?.setRescanBusy(false);
}
}
/**
* D42: the concept's toast stays fixed at the bottom, so a discovery alert the rescan leaves is scrolled clear of
* the toast's band (its `scroll-margin-block-end`) only when it is in that band; elsewhere the page stays put.
*/
function announceRescan(message) {
showWorkspaceToast(message);
if (optionsStatus.hidden) return;
const {top, bottom} = optionsStatus.getBoundingClientRect();
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
// Numeric roots may now name another source; retire the old stream first.
+4 -2
View File
@@ -153,8 +153,10 @@ function resolutionFor(value) {
function libraryFor(value) {
if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' ||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library');
const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope };
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') ||
![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library');
// D39: each field names its own source, so a recipe's category can sit beside its positive's tags.
const library = { status: value.status, scope: value.scope, tags: [...value.tags], category_scope: value.category_scope, tags_scope: value.tags_scope };
for (const key of ['positive_id', 'pair_id', 'title', 'category']) {
const item = value[key] ?? null;
if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field');
-46
View File
@@ -1,46 +0,0 @@
.image-details.file-inspector { padding: 0; }
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector-inner { padding: 17px; }
#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; }
.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; }
.file-name { font-size: 12px; margin-block: 12px; }
.file-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; }
.file-evidence { color: var(--ca-focus); }
.file-format { flex-shrink: 0; max-inline-size: 50%; }
.file-inspector .prompt-inspector { margin-block-start: 15px; }
.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; }
.file-inspector .prompt-text { margin-block: 8px 0; }
.file-inspector .prompt-negative { margin-block-end: 12px; }
.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; }
.file-vocabulary .file-chip { font-size: 12px; }
.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; }
.generation-limit { font-size: 10px; line-height: 1.65; margin-block: 12px; }
.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; }
.generation-facts dd, .file-facts dd { margin: 3px 0 0; }
.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; }
.file-inspector summary { cursor: pointer; min-block-size: 24px; }
.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.file-inspector details p { font-size: 10px; line-height: 1.65; }
.file-facts { font-size: 11px; }
.file-facts > div { margin-block: 10px; }
.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; }
.raw-record-name { font-weight: 600; }
.file-download { margin-block: 12px; }
.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; }
.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 #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; }
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
@media (pointer: coarse) {
.ca-tools .file-inspector :is(button, a, summary) { min-inline-size: 40px; min-block-size: 40px; }
.ca-tools .file-inspector .prompt-actions button { inline-size: 36px; block-size: 36px; min-inline-size: 36px; min-block-size: 36px; }
}
+26
View File
@@ -40,3 +40,29 @@ export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1)
}
return [...visible].sort((a, b) => a - b);
}
// #249 D23: the card footer's words. English month names as the concept's copy, independent of the browser locale.
const MONTHS = Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']);
const DAY_MS = 86400000;
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>" (", <YYYY>" in another year), or "Not used yet". */
export function usedLabel(lastUsedAt, now = new Date()) {
const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null;
if (!used || Number.isNaN(used.getTime())) return 'Not used yet';
// A server clock ahead of this browser is not a future use.
const days = Math.max(0, localDay(now) - localDay(used));
if (days === 0) return 'Used today';
if (days === 1) return 'Used yesterday';
if (days < 7) return `Used ${days} days ago`;
// A use in another calendar year names it ("Used Oct 21, 2025"), so it never reads as this year's date.
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
}
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
export function ratingLabel(rating) {
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
}
+6 -5
View File
@@ -14,15 +14,16 @@ function parameters(filters,limit) {
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
query.set('sort',sort);return query;
}
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !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)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
@@ -37,11 +38,11 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
validatePage(page,cursor);const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
+25 -61
View File
@@ -1,48 +1,15 @@
main.library-main { --library-positive: #73dda9; }
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; }
.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; }
.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; }
.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; }
.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; }
.ca-tools .library-main .library-toolbar > button { inline-size: auto; }
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; }
.library-main :where(.library-shelf, .library-history, .library-linked-images) { min-inline-size: 0; }
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; }
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; }
.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; }
.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; }
.library-main .library-card-art > :only-child { grid-column: 1 / -1; }
.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); }
.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; }
.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
.library-main .library-title-link { color: var(--ca-text); text-decoration: none; }
.library-main .library-title-link:hover { color: var(--ca-accent); }
.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; }
.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); }
.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; }
.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); }
.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; }
.library-main .library-rating { color: var(--ca-muted); }
.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); }
.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; }
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; }
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); }
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; }
.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; }
.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; }
.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; }
.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); }
.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; }
.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); }
.library-main .library-shared-gallery { margin-block-start: 24px; }
.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; }
@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } }
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
@@ -50,24 +17,12 @@ main.library-main { --library-positive: #73dda9; }
main.library-main { --library-negative: #ff8c98; }
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
.library-main .library-branches { overflow-x: auto; position: relative; }
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; }
.library-main .library-variants .library-branches { margin-block: 0; }
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
.history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
.history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
/* D45: the panel's paging buttons wrap like the recorded toolbar's. */
.ca-tools .library-main :where(.timeline-panel, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
@@ -95,7 +50,7 @@ main.library-main { --library-negative: #ff8c98; }
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
.recorded-run-dialog :where(.muted:not(.prompt-text), .recorded-output-status) { font-size: 11px; line-height: 1.65; }
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
.recorded-provenance summary { cursor: pointer; }
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
@@ -115,7 +70,10 @@ main.library-main { --library-negative: #ff8c98; }
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); }
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
/* D45: a coarse pointer reaches the concept dot through a 47px target. */
.ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; }
/* D45: the 12px repeat-run dot (8px inside its border) gets a 44px coarse target the same way. */
.ca-tools .library-history .run-dot::after { content: ""; position: absolute; inset: -18px; }
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
}
@media (forced-colors: active) {
@@ -124,12 +82,18 @@ main.library-main { --library-negative: #ff8c98; }
}
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
These .row-actions rules restyle the concept's own selector for the history panels only (the shelf's card footer
keeps the concept's) and are retired in PR F (history). */
.library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ca-tools .library-main .library-history .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
@media (pointer: coarse) { .ca-tools .library-main .library-history .row-actions button { min-block-size: 40px; } }
/* D45: archived pairs (#235) are concept lanes at reduced emphasis (the .6 disabled-marker opacity); text stays at full contrast. */
.library-history .variant-lane.library-variant-archived::before, .ca-tools .library-history .library-variant-archived .variant-dot { opacity: .6; }
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } }
/* D45: at 390px and 200% zoom the panel heading and the legend row wrap, and the All images button wraps its label,
so the page never scrolls sideways (the retired production footer did the same). */
.library-history .timeline-panel > .panel-heading, .library-history .timeline-lower { flex-wrap: wrap; }
/* D45: while the repeat-run dot shows (left: 190px), the lane text wraps inside the 57px lane before it (43px + 139px). */
.library-history .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
.ca-tools .library-history .timeline-lower > button { /* D45 */ max-inline-size: 100%; white-space: normal; }
+209 -70
View File
@@ -1,4 +1,4 @@
import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js';
import {createScope,createButton} from '../shared/toolkit/core.js';
import {createPromptSender} from './prompt-sender.mjs';
import {mountPromptBlocks} from './prompt-inspector.mjs';
import {mountImages} from './images.mjs';
@@ -6,11 +6,14 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
import {promptRoles} from './prompt-blocks.mjs';
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
import {createLibraryEditorController} from './library-editor-state.mjs';
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
import {mountRecordedHistory} from './history.mjs';
import {countOf} from './plural.mjs';
import {addedTerms} from './history-state.mjs';
import {pageHeading} from './heading.mjs';
import {mountToolbar} from './toolbar.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
let shelfVisit=null;
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
@@ -23,11 +26,13 @@ const selectedPairs=new Map();
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
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;}
// 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();
@@ -144,20 +149,22 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
const toolbar=el('search',undefined,'library-toolbar');
const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500;
const category=createSelect([['','All categories']], '');category.value=filters.category;
const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000;
const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort;
const refresh=createButton('Refresh library');
toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh);
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
const toolbarScope=createScope();
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
const refinements=()=>filters.tags.length?1:0;
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters});
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
const caption=el('p','','library-caption');caption.setAttribute('role','status');
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
captionCount.setAttribute('role','status');
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link');
const actions=headingActions(main,section);
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
@@ -184,7 +191,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
}
scope.listen(exportButton,'click',exportLibrary);
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section);
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
@@ -195,29 +203,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
function cardFor(item,index) {
const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true');
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
const previews=item.previews || [];
for(const image of previews.slice(0,2)) {
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','library-art-empty'));
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
else {
const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
}
}
if(!previews.length) art.append(el('span','No available image previews','library-art-empty'));
const content=el('div',undefined,'library-card-content');
const heading=el('header',undefined,'library-card-title');
const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name);
heading.append(h2,el('span',item.category || 'Uncategorized','library-chip'));
const prompts=el('div',undefined,'library-card-prompts');
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']});
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image')));
const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip'));
content.append(heading,prompts,stats,vocabulary);
const footer=el('footer',undefined,'library-card-footer');
const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating);
const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history);
if(!previews.length) art.append(el('span','No available image previews','small'));
const name=displayTitle(item);
const content=el('div',undefined,'card-content');
const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history='';
heading.append(open,el('span',item.category || 'Uncategorized','chip'));
const prompts=el('div');
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name});
const meta=el('div',undefined,'card-meta');
meta.append(el('span',`\u2212 ${countOf(item.variant_count,'negative variant')}`,'chip red'),el('span',countOf(item.run_count ?? 0,'run')),el('span',countOf(item.image_count,'image')));
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
content.append(heading,prompts,meta,vocabulary);
const footer=el('div',undefined,'card-footer');
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
const facts=el('span');
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
else facts.textContent=used;
const actions=el('div',undefined,'row-actions');
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
actions.append(send,explore);footer.append(facts,actions);
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
}
function captureAnchor() {
@@ -285,7 +301,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
}
pendingMeasurements.clear();
const restore=dirty?(pendingAnchor || anchor):null;
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;}
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?422:448});dirty=false;}
const index=restore?indexesById.get(restore.id):undefined;
const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset);
renderWindow(target);
@@ -315,14 +331,21 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
function focusCard(index,last=false) {
if(index<0 || index>=state.items.length) return;
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
}
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
scope.listen(grid,'click',event=>{
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
const item=state?.items[indexesById.get(Number(card.dataset.promptId))];if(!item) return;
if(event.target.closest('[data-history]')) window.location.hash=`#/library/${item.id}`;
const send=event.target.closest('[data-send-positive]');if(send) sender.open({positive:item.text},send);
});
scope.listen(grid,'keydown',event=>{
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
const index=Number(card.dataset.index);
if(event.target.matches('a') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
else if(event.key==='Tab') {
const controls=card.querySelectorAll('a,button');const next=index+(event.shiftKey?-1:1);
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
}
@@ -337,9 +360,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`;
if(next.variantTotal!==null) {
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
}
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
schedule();scheduleLoad();
}
@@ -354,14 +380,29 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
optionsRequest?.abort();const request=optionsRequest=new AbortController();
try {
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}}
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
}
const reset=()=>{restoreScrollY=null;return stream.reset(filters);};
scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200));
scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200));
scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();});
scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore());
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
function applyToolbar(value) {
const next={text:value.q.trim(),category:value.category,sort:value.sort};
if(Object.entries(next).every(([key,item])=>filters[key]===item)) return;
Object.assign(filters,next);reset();
}
/** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */
function openFilters(trigger) {
openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement,
onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
}
function applyRefinement({tags,category=filters.category}) {
const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))];
if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
filters.category=category;filters.tags=next;reset();
}
scope.listen(more,'click',()=>stream.loadMore());
// Retry also re-reads a category list that failed (D15: Refresh is gone).
scope.listen(retry,'click',()=>{if(!optionsStatus.hidden)loadCategories();stream.loadMore();});
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
// Mobile viewport resizing can adjust scroll before delivering its resize event.
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
@@ -369,15 +410,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor();
// A write while mounted makes this stream stale: keep only what still means something.
shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
}
function mountHistory() {
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
const body=el('div');history.append(body);
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.');
// Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
const actions=headingActions(main,history);
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
@@ -420,21 +461,61 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
}
detail=state.detail;const content=contentScope;
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
// The concept summary (app.js a598): category chip, positive and selected negative blocks, then the counts (D24).
const summary=el('article',undefined,'surface');const categoryChip=el('span','','chip');summary.append(categoryChip);
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
const meta=el('div',undefined,'card-meta'),runsMeta=el('span'),imagesMeta=el('span'),variantsMeta=el('span');runsMeta.hidden=true;
meta.append(runsMeta,imagesMeta,variantsMeta);summary.append(meta);
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
content.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
const all=createButton('All images for this positive');
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false');
rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
// The concept's negative variants panel (app.js a598, style.css 1059-1154): heading, the lanes, then the legend.
const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants');
const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small'));
// Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition.
const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton('');
compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false');
rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();});
// The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest
// active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place.
const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null;
const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];};
content.cleanup(disposeCompareBlocks);
function compareBlock(text,label) {
const item=el('div',undefined,'compare-item');
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'});
return item;
}
function updateCompare() {
if(detail.variant_total===0) compare.setAttribute('aria-pressed','false');
compare.disabled=detail.variant_total===0;
if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;}
if(variants.nextSibling!==compareSection) variants.after(compareSection);
// D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes.
const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.id<oldest.id?variant:oldest,null);
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id);
const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline;
const name=lane?laneName(lane.entry):'';
const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]);
if(key===compareKey) return;
compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants'));
if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;}
const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected'));
compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small'));
const terms=addedTerms(baseline.negative_text,selected.negative_text);
if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added'));
else compareSection.append(el('p','No added terms.','small'));
}
const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted');
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
// D45: the bounded page count stays a status for assistive technology; the concept shows no count line.
const variantCount=el('p','','ca-visually-hidden');variantCount.setAttribute('role','status');
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true;
const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null;
@@ -471,13 +552,24 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1});
return entries;
}
/** D45: a dot names its pair by a title no other loaded lane shares, else by its number, so every name is unique. */
// Titles compare trimmed (accessible names are whitespace-normalized), and a title that reads like a fallback
// "variant N" is never used, so it cannot collide with an untitled lane's name.
const trimmedTitle=variant=>typeof variant.title==='string'?variant.title.trim():'';
function dotName(entry,entries) {
const title=trimmedTitle(entry.variant);
return title && !/^variant\s+\d+$/i.test(title) && entries.filter(other=>trimmedTitle(other.variant)===title).length===1?title:`variant ${entry.number}`;
}
const runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0;
function laneFor(entry) {
let lane=lanes.get(entry.key);
if(!lane) {
const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span');
node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text);
// Concept lane (app.js a598): the "−" dot, "<name> · N runs", then the repeat-run dot (D26).
const node=el('div',undefined,`variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','variant-dot');const label=el('span');
const runDot=createButton('','run-dot');runDot.hidden=true;
node.append(button,label,runDot);lane={node,button,label,runDot,text:document.createTextNode(''),entry};label.append(lane.text);
if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));}
content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane);
content.listen(button,'click',()=>select(lane.entry.variant.id));content.listen(runDot,'click',()=>openRunDot(lane));lanes.set(entry.key,lane);
}
lane.entry=entry;return lane;
}
@@ -488,9 +580,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
for(const [index,entry] of entries.entries()) {
const lane=laneFor(entry),name=laneName(entry);
lane.node.style.setProperty('--lane-offset',`${38+index*47}px`);
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`);
const named=entry.kind==='separate'?'the saved negative variant outside the loaded list':dotName(entry,entries);
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative ${named}`);
lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current';
lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`;
lane.text.textContent=`${name} · ${countOf(runCount(entry.variant),'run')}${entry.kind==='archived'?' · Archived':''}`;
// D26: the small dot appears only for a pair counted more than once.
lane.runDot.hidden=runCount(entry.variant)<=1;lane.runDot.setAttribute('aria-label',`Show repeat run for ${named}`);lane.runDot.disabled=savedReadState!=='current';
if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);}
if(previous.nextSibling!==lane.node) previous.after(lane.node);
previous=lane.node;
@@ -505,17 +600,19 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.';
const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0;
showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown));
updateCompare();
};
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
updateParent=()=>{
main.querySelector('h1').textContent=displayTitle(detail.positive);categoryChip.textContent=detail.positive.category || 'Uncategorized';
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${displayTitle(detail.positive)}`);
const name=displayTitle(detail.positive);
main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name);
};
let activePairId;
updateParent();updateBranches(state);
branches.append(emptyNote);
const note=el('p','Saved combinations, not an execution timeline.','muted');
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter);
const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all);
variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower);
const context=el('p','','library-variant-context');context.setAttribute('role','status');
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
const images=el('div',undefined,'library-linked-images');gallery.append(images);
@@ -549,7 +646,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
return {...variant,...current};
});
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';updateMeta();
savedReadState=changed || unobserved || additional?'stale':'current';
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
reloadSaved.hidden=savedReadState==='current';
@@ -568,23 +665,23 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
}
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);});
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
const recordedView=mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
}});
},onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}});
// The heading carries the pair send, as in the concept; the saved texts keep their per-role actions.
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false});
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false,title:displayTitle(detail.positive)});
function select(pairId,{force=false}={}) {
if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return;
if(pairId!==null && !recordFor(pairId)) pairId=null;
activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));
const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId);
const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId);
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
// An archived pair's images are read in the archived scope; the gallery stays authoritative.
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
updateSend();
updateSend();updateCompare();
}
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
async function rereadSelectedPair() {
@@ -648,7 +745,49 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
if(pair) select(pair.id,{force:true});
}
current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply};
content.listen(all,'click',()=>select(null));
/** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */
// One slot: each open replaces the previous dialog's scope, so repeated opens never pile up cleanups.
let imagesDialogScope=null;content.cleanup(()=>{imagesDialogScope?.dispose();imagesDialogScope=null;});
function openImagesDialog({title,intro,className,fetchPage,trigger}) {
imagesDialogScope?.dispose();
const dialogScope=createScope();imagesDialogScope=dialogScope;
const grid=el('div',undefined,className);
openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}});
mountImages(grid,api,dialogScope,{embedded:true,fetchPage});
}
const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal});
/** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */
function openAllImagesDialog(id,trigger) {
openImagesDialog({title:'All images for this positive',trigger,fetchPage:({parameters,signal})=>imagesPage(id,new URLSearchParams(parameters),signal)});
}
/** Concept "Repeat run" (a1291-1304): the pair's newest linked image only. */
function openRepeatRun(pair,trigger) {
openImagesDialog({title:'Repeat run',intro:'Same positive and negative, another execution.',className:'spaced-15',trigger,fetchPage:async({parameters,signal})=>{
const query=new URLSearchParams(parameters);query.set('pair_id',String(pair.id));query.set('limit','1');query.delete('cursor');
if(pair.archived===true) query.set('archive','archived');
const page=await imagesPage(positiveId,query,signal),images=Array.isArray(page?.images)?page.images.slice(0,1):page?.images;
return {...page,images,total:Array.isArray(images)?images.length:page?.total,has_more:false,next_cursor:null};
}});
}
/** D26: the run dot opens Recorded run for the pair's newest recorded execution, else Repeat run. */
const runLookups=new Set();
async function openRunDot(lane) {
const pair=lane.entry.variant,trigger=lane.runDot;
if(savedReadState!=='current' || runLookups.has(pair.id)) return;
runLookups.add(pair.id);let run=null,failed=false;
try {
const value=await api.get(`/prompt_manager/library/${positiveId}/history/pairs/${pair.id}/newest`,{signal:content.signal,cache:'no-store'});
run=value?.run && typeof value.run.run_id==='string'?value.run:null;
} catch {
failed=true;
} finally {runLookups.delete(pair.id);}
// The view may have gone stale while the lookup was in flight; a stale view opens nothing.
if(content.signal.aborted || savedReadState!=='current') return;
if(run && recordedView.openRun({runId:run.run_id,anchorId:run.anchor_id ?? null,contextId:run.context_id ?? null,trigger})) return;
if(failed) showWorkspaceToast('Recorded runs could not be read. Showing the newest linked image.');
openRepeatRun(pair,trigger);
}
content.listen(all,'click',()=>openAllImagesDialog(positiveId,all));
const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null;
if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);}
if(archivedShown) archivedLoader.load(positiveId);
+2 -2
View File
@@ -19,7 +19,7 @@ function element(tag, className, text) {
return node;
}
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
@@ -80,7 +80,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
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(),
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
function closeViewer(restore = true) {
if (closed) return;
+50 -49
View File
@@ -1,30 +1,21 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createSendButton } from './prompt-sender.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { openCopyFallback } from './workspace-dialogs.mjs';
export function promptRoles(prompts) {
return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text]));
}
const sources = {
library: 'Saved library prompt',
execution: 'Recorded at execution',
'graph-inferred': 'Inferred from the saved workflow',
'embedded-parameters': 'Embedded generation parameters',
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
'saved-association': 'Saved library association',
};
// #249 D22: the concept's prompt block (app.js a298–302) carries no source line; a file's evidence is its panel badge
// (D21) and its "File & provenance" line. Roles without a confirmed text keep these status strings.
const statuses = {
absent: 'Not recorded in this file.',
ambiguous: 'Multiple possible prompts. Source could not be determined.',
unresolved: 'Prompt could not be resolved.',
};
const fieldSources = {
runtime_return: 'Runtime return',
host_cache_output: 'Host cache output',
graph_literal: 'Inferred graph literal',
graph_semantics: 'Inferred graph semantics',
};
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -41,37 +32,48 @@ function childScope(parentScope) {
return scope;
}
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
// context so assistive technology never meets two identically named prompt regions in one dialog.
const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`;
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
// The title is mutable so a saved title edited in place (setTitle below, #249 F1) renames the actions.
let currentTitle = title || '';
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${currentTitle ? `: ${currentTitle}` : ''}`;
// An aborted parent gets one inert handle; every method is the same no-op.
const inert = () => {};
if (parentScope.signal.aborted) return {update:inert, setState:inert, setTitle:inert, dispose:inert};
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
scope.listen(container, 'click', async event => {
if (!actionsEnabled) return;
const send = event.target.closest('[data-send-role]');
if (send) {
if (send && (send.dataset.sendRole === 'pair' || Object.hasOwn(roles, send.dataset.sendRole))) {
const role = send.dataset.sendRole;
sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send);
}
const copy = event.target.closest('[data-copy-role]');
if (copy) {
const role = copy.dataset.copyRole;
const status = copy.closest('section').querySelector('.prompt-copy-status');
if (copy && Object.hasOwn(roles, copy.dataset.copyRole)) {
// The concept's copy: the shared toast, or its "Copy prompt" select-to-copy dialog without a clipboard.
const text = roles[copy.dataset.copyRole];
try {
await navigator.clipboard.writeText(roles[role]);
if (!scope.signal.aborted) status.textContent = 'Copied.';
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
await navigator.clipboard.writeText(text);
if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.');
} catch {
if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.';
if (scope.signal.aborted) return;
// A re-render during the pending write detaches the clicked button; Escape then returns to its successor.
const trigger = copy.isConnected === false ? container.querySelector(`[data-copy-role="${copy.dataset.copyRole}"]`) : copy;
copyFallback(text, {scope, trigger});
}
}
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
});
let roles = {}, actionsEnabled = true;
let roles = {}, actionsEnabled = true, current = {prompts};
function render(state) {
if (scope.signal.aborted) return;
current = state;
roles = {};
actionsEnabled = state.actionsEnabled !== false;
container.replaceChildren();
@@ -87,38 +89,32 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
return;
}
roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role)));
for (const role of visibleRoles) {
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
for (const role of visibleRoles.filter(name => Object.hasOwn(ROLE_LABELS, name))) {
const record = state.prompts?.[role];
const block = el('section', undefined, `prompt-block prompt-${role}`);
block.setAttribute('aria-label', contextLabel ? named(role) : title);
const heading = el('div', undefined, 'prompt-block-heading');
heading.append(el('h3', title));
const block = el('section', undefined, `prompt-section ${role}`);
block.setAttribute('aria-label', named(role[0].toUpperCase() + role.slice(1)));
const heading = el('div', undefined, 'prompt-heading');
heading.append(el('span', ROLE_LABELS[role], 'role-label'));
block.append(heading);
if (Object.hasOwn(roles, role)) {
const proof = record.evidence;
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
else {
const text = el('pre', roles[role], 'prompt-text');
text.tabIndex = 0;
block.append(text);
}
const actions = el('div', undefined, 'prompt-actions');
const send = createSendButton(`Send ${action(role)}`);
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
const copy = createButton('', 'ca-icon-button');
copy.setAttribute('aria-label', `Copy ${action(role)}`);
copy.title = `Copy ${action(role)}`;
const copy = createButton('', 'icon-btn');
copy.setAttribute('aria-label', actionName('Copy', role));
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
const send = createButton('', 'send icon-btn');
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
send.append(createWorkspaceIcon('send'));
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
actions.append(copy, send);
heading.append(actions);
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
block.append(status);
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
const text = el('p', roles[role] === '' ? `No ${role} prompt (explicitly empty)` : roles[role], 'prompt-text');
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
text.tabIndex = 0;
block.append(text);
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
container.append(block);
}
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
@@ -128,5 +124,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
}
}
render({prompts});
return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()};
// A saved title edited in place renames the actions without a remount (the history summary, #249 F1).
const setTitle = next => {
const value = next || '';
if (value !== currentTitle) {currentTitle = value;render(current);}
};
return {update:prompts => render({prompts}), setState:render, setTitle, dispose:() => scope.dispose()};
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
const receiptNodes = new Map();
const evidenceValues = new Map();
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onRefresh:refreshCurrent});
function selectedReceipt() {
const selected = latest.selection.receipt;
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
+18 -1
View File
@@ -77,8 +77,21 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
}
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
/** The concept's `go()` (app.js 292–297): a link to the route already shown re-renders it instead of doing nothing. */
export function navigateTo(hash, win = window) {
if (win.location.hash === hash) win.dispatchEvent(new Event(ROUTE_REFRESH_EVENT));
else win.location.hash = hash;
}
export function mountRouter(win, onRoute) {
let current;
const refresh = () => {
current = undefined;
update();
};
const update = () => {
const route = resolveRoute(win.location.hash);
const hash = route.hash || `#/${route.id}`;
@@ -88,6 +101,10 @@ export function mountRouter(win, onRoute) {
onRoute(route);
};
win.addEventListener('hashchange', update);
win.addEventListener(ROUTE_REFRESH_EVENT, refresh);
update();
return () => win.removeEventListener('hashchange', update);
return () => {
win.removeEventListener('hashchange', update);
win.removeEventListener(ROUTE_REFRESH_EVENT, refresh);
};
}
+17 -6
View File
@@ -2,7 +2,8 @@
* 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).
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads
* "Filters · N" while N "Refine this view" filters are active (D44).
*/
import { createButton } from '../shared/toolkit/core.js';
import { createWorkspaceIcon } from './icons.mjs';
@@ -16,6 +17,12 @@ const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'High
const isText = value => typeof value === 'string';
/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */
function filtersLabel(count) {
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number');
return count ? `Filters · ${count}` : 'Filters';
}
/** `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 ||
@@ -48,10 +55,11 @@ function categoryOptions(categories) {
/**
* 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}`.
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view";
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
*/
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) {
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
throw new TypeError('The toolbar needs the page scope');
}
@@ -83,7 +91,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
sort.setAttribute('aria-label', 'Sort results');
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
const filters = createButton('Filters');
const filters = createButton(filtersLabel(activeFilters));
root.append(label, category, sort, filters);
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
@@ -97,6 +105,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
category.replaceChildren(...categoryOptions(next));
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
}
function setActiveFilters(count) {
filters.textContent = filtersLabel(count);
}
setValue(value);
const report = () => onChange(read());
@@ -107,5 +118,5 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
scope.cleanup(() => root.remove());
container.append(root);
return { element: root, value: read, setValue, setCategories };
return { element: root, value: read, setValue, setCategories, setActiveFilters };
}
+21 -3
View File
@@ -12,6 +12,8 @@
* - "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).
* - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this
* positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell.
*
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
*/
@@ -69,9 +71,10 @@ function createShell() {
/** 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;
const { trigger, onClose } = session;
session = null;
if (shell?.dialog.open) shell.dialog.close();
onClose?.();
if (trigger?.isConnected) trigger.focus();
}
@@ -97,11 +100,14 @@ function bindScope(scope) {
});
}
function show(heading, nodes, { scope, trigger, focusTarget }) {
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
bindScope(scope);
session = { scope, trigger: trigger ?? session?.trigger ?? null };
// A swapped-out body is gone from the shell, so its owner releases it.
const replaced = session?.onClose;
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose };
replaced?.();
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
if (!shell.dialog.open) shell.dialog.showModal();
@@ -287,3 +293,15 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
actions.append(cancel, apply);
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
}
/**
* The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or
* another dialog replaces its body, so the page can dispose what it mounted there.
*/
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) {
requireScope(scope);
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
show(title, nodes, { scope, trigger, onClose });
}
+83 -3
View File
@@ -128,6 +128,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
@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 */ }
/* #249 D34: the details' disclosure summaries and "Download original" keep 40px targets too (the concept's coarse
rule covers buttons only); the summary keeps its disclosure marker. */
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; /* D34 */ }
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; /* D34 */ }
}
@media (max-width: 700px) {
#viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
@@ -135,15 +139,91 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* 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.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` */
.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */
.prompt-text[tabindex]:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } /* production-only: a confirmed prompt stays keyboard-reachable (#249 D22) */
@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.recorded-run-dialog { padding: 16px; } /* PR F */
}
/* Images side panel (#249 D2), production-only. Real prompts can be far longer than the concept's samples, so the sticky
panel scrolls within the window and its footer stays reachable; where the concept unsticks it, it grows instead. */
.detail-panel { min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; /* D2 */ }
@media (max-width: 700px) { .detail-panel { max-block-size: none; /* D2 */ } }
@container images-page (inline-size < 600px) { .detail-panel { position: static; max-block-size: none; /* D2 */ } }
/* #249 D2/D20: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` (panel) and the
viewer details' wrapper as the concept's do. */
:is(.detail-inner, #viewer-details > div) > .prompt-inspector { display: contents; /* D20 */ }
/* #249 D2: real titles and filenames can be one unbroken word; they wrap inside the 310px panel. */
.panel-heading h2, .detail-inner > p.small { min-inline-size: 0; overflow-wrap: anywhere; }
/* #249 D3: production-only rules kept from the retired images.css and inspector.css. Each has a live consumer. */
/* The inspector wraps real unbroken literals (model paths, raw records) inside the 310px panel and the 340px viewer
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
.images-main { container: images-page / inline-size; }
@container images-page (inline-size < 600px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
}
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
/* Until their pages rebuild (PR F history): the history page's and the recorded run's prompt blocks keep their
spacing (on the shelf's cards `.prompt-inspector` is `display: contents`, so its margin is inert there), the Archive
negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its dialog and send button. */
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
.prompt-negative { --prompt-role: #ff8c98; }
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
.prompt-empty, .prompt-block > .muted { font-size: 10px; }
/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */
pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; }
.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; }
.prompt-sender::backdrop { background: #000b; }
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
@media (max-width: 780px) {
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
}
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
and send keep the full text, and history shows all of it. */
.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; /* D23 */ }
/* #249 D23: the prompt blocks' mount point is transparent, so the section sits in `.card-content` as the concept's does. */
.card-content > .prompt-inspector { display: contents; /* D23 */ }
/* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount
point keeps its "Saved prompt texts" region name. */
.library-history article.surface > .prompt-inspector { display: contents; /* D24 */ }
/* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never
wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */
.card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }
.card-title .chip { flex-shrink: 0; max-width: 60%; overflow-wrap: anywhere; /* D23 */ }
.prompt-card .tags .chip { max-width: 100%; overflow-wrap: anywhere; /* D23 */ }
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
-2
View File
@@ -8,9 +8,7 @@
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<link rel="stylesheet" href="js/workspace/concept.css">
<link rel="stylesheet" href="js/workspace/images.css">
<link rel="stylesheet" href="js/workspace/library.css">
<link rel="stylesheet" href="js/workspace/inspector.css">
<link rel="stylesheet" href="js/workspace/workspace.css">
<script src="lib/viewerjs/viewer.min.js" defer></script>
<script src="js/comfy-metadata.js" defer></script>