Compare commits

..
Author SHA1 Message Date
Vito Sansevero 5f82d606b1 fix(images): name the missing link in the Set category toast
Review M4: say that no selected image is linked to a saved prompt, in
the same terms as the partial-success toast and right for one image.

Also review M1: a monitor test that two parallel samplers sharing one
positive and one negative write that pair (the resolver collapse).
2026-10-21 10:25:48 -07:00
Vito Sansevero 6da378442b fix(inspector): check only the negative the legacy parse read for a zero-out
Review I1/M2/M3/M5 of the zero-out legacy-fill guard. The guard decided
over every node with a `negative` input, so a graph whose read sampler
encodes "blurry" lost that legacy-filled negative when another branch
zeroed its own (fewer than the legacy pages: below the parity floor).

- comfy-metadata.js gains `legacyNegativeSource`, which returns the graph
  and sampler `negative` link that `extractGenerationParams` reads from.
  It reuses the parser's own sampler pick (`selectSampler`, factored out
  of `fromPromptGraph` without changing any reading). The workspace
  checks only that link: a direct zero-out shows the zeroed empty
  negative, a zero-out further up the path leaves it unfilled, and
  anything else keeps the legacy fill.
- Workflow-only files go through the parser's own workflow conversion,
  so bypassed (mode 4) and muted (mode 2) zero-outs and unconnected ones
  behave as the legacy reading does. A subgraph instance whose definition
  runs a zero-out (directly or nested) leaves the negative unfilled.
- One bounded walk with an index pointer replaces a walk per negative
  input with queue.shift().
- An ambiguous fact that the legacy parse knows (a grid's per-branch
  model) is still filled, documented: the legacy pages show it and this
  page never shows less.

Tests: the reversed mixed graph shows "blurry"; the mixed graph whose
read sampler is zeroed, the zero-out-only grid and a direct workflow
zero-out show the zeroed empty negative; a combined zero-out and a
zero-out subgraph instance stay unfilled; bypassed and unconnected
workflow zero-outs keep the legacy reading. An API-graph Reroute in
front of a zero-out gives the legacy parse no negative, so the server
answer stays (that expectation changed from zeroed).
2026-10-21 10:25:48 -07:00
Vito Sansevero 38fc3ee236 fix(images): explain why Set category has nothing to set
Set category on a selection where no image is linked to a Library prompt
said only "None of the selected images has a linked prompt." It now says
why and what it means: "The selected images aren't in your Library yet,
so there is no prompt category to set." Unit and page-workflow tests
follow the copy.
2026-10-21 09:37:42 -07:00
Vito Sansevero 6b7f9e1fee fix(inspector): never show the positive as a zeroed negative from the legacy fill
When the server leaves the negative undecided, the workspace fills it from
the legacy browser parser (comfy-metadata.js), which follows
ConditioningZeroOut back to its input and so reads the positive text as
the negative. The workspace now checks the prompt graph first:

- every sampler's negative is a ConditioningZeroOut (Reroutes allowed):
  the negative shows the server's explicitly empty zeroed state
  ({status: resolved, text: "", source: zeroed-conditioning});
- a zero-out elsewhere in a negative path (one branch zeroed, another
  encoded; a zero-out combined with other conditioning), or a
  workflow-only file holding one: the negative is not filled and keeps
  the server's status;
- graphs without a zero-out keep the legacy fill unchanged.

comfy-metadata.js is untouched; the legacy pages keep their reading.
2026-10-21 09:36:08 -07:00
Vito Sansevero dd4e3125e9 fix(resolver): identical sampler branches resolve to their shared prompt pair
A LoRA epoch-compare grid runs one sampler per epoch, every one fed by the
same positive encode and the same ConditioningZeroOut negative, and saves
one concatenated image. `_resolve` returned "Multiple sampler branches"
(ambiguous) whenever more than one sampler was found, even though every
branch used the same pair, so the workspace inspector fell back to the
legacy parser and the image could not be paired.

When every branch resolves to identical texts and stored recipes, the
result is now that pair: state resolved, no sampler named (none alone made
the output), reason "Every sampler branch uses the same prompt pair", the
first branch's sides and provenance, and every branch kept in `candidates`
as evidence. A branch that differs, cannot be read, or is not resolved
keeps the result ambiguous. Both profiles share the rule; the default
profile still cannot read ConditioningZeroOut, so the zero-out grid stays
ambiguous for ingestion and keeps the scan's legacy-parser fallback.

Workspace facts: a resolved result without a named sampler over several
listed samplers merges facts per field, as an ambiguous one does (the
grid's shared seed/steps are known, the per-epoch model is ambiguous).

Tests that encoded the old behavior (identical branches => ambiguous):
- test_prompt_pair_resolver: test_multiple_samplers_are_ambiguous_even_when_texts_match
  -> test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair
  (plus a new differing-texts case that stays ambiguous);
  test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor (same prompt)
  -> ..._of_one_prompt_share_it_without_an_anchor (anchored cases unchanged).
- test_pair_scan: two samplers sharing one prompt now ingest the pair
  ("cat"/"blur") instead of the positive-only fallback.
- test_api_prompt_inspector, test_inspection_outcomes, test_pair_image_linking:
  their "ambiguous" fixtures now use samplers with different prompts.
No parity vector changes (f01/f09 branches are not all resolved).
2026-10-21 09:32:23 -07:00
Vito e9996996e2 feat(views): saved views and Favorites on Images and Library (#268)
* feat(views): saved-views store and API

Add a saved_views table (page images|library, trimmed 1-100 character
name, whitelisted JSON state of at most 4 KB, position, timestamps)
installed through the same validated-savepoint migration as image
favorites. It is optional in old backups and required in a migrated
restore stage; restore rejects a tampered definition, extra indexes and
any row that would fail write validation.

Routes under /prompt_manager/saved-views: GET lists views by position,
POST {page,name,state} returns 201 {success, view}, PATCH /{id} {name}
renames and DELETE /{id} removes, both 404 for an unknown id. The state
must hold exactly the page's toolbar and Refine-this-view keys (Images:
q, category, sort, source, folder, media, tags; Library: q, category,
sort, tags) with their types and sort/media values; anything else is a
400.

Refs #249

* feat(views): Save view, the SAVED VIEWS sidebar and opening a view

The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".

The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.

D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.

* feat(views): Edit rename/delete and the Favorites saved view

- Favorites is the first SAVED VIEWS row (star icon, name, real count).
  The count is the Favorites listing's own limit=1 total, follows the
  nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
  takes favorites=1: each selected root's favorites are read through the
  image_favorites primary key and matched against the cached scan with the
  toggle's case folding; a failed read is an error, never an unfiltered
  page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
  Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
  View name, Delete... (danger), Cancel and Save. Delete... confirms in the
  same dialog (never window.confirm). Toasts "Saved view renamed." and
  "Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
  Recorded as D51 in TESTING.md.

* fix(views): open stale saved views safely and finish the review fixes

Startup and restore now check only the saved_views structure (table,
columns, types, sizes, a JSON object state) and never the value rules,
so a row saved under older rules no longer stops the database from
opening. Reads normalize each view: missing keys take their defaults,
unknown keys are dropped, invalid values default and the name is made
displayable. POST and PATCH still validate strictly.

Only request validation (SavedViewInvalid) maps to 400; database and
internal errors are a logged generic 500. A saved view no longer
replaces the plain Images or Library return state, a toolbar change
while sources load keeps a saved Source and Folder, a state over 4 KB
is refused in the browser with its own toast, the delete confirmation
skips its failure toast after the page is gone, the Edit dialog reuses
the shared field helper, and quick star toggles coalesce into one
Favorites count read.

* test(views): cover leaving a pending saved view and add its coverage floor

A saved view left while the list or a rename is still pending leaves
the next page alone; the shell drops two unreachable heading guards.
workspace/saved-views.mjs gets its frontend coverage baseline entry;
no existing floor changes.

* fix(views): a corrupt saved-view state no longer blocks startup

At startup a saved_views row whose state is not a JSON object is
logged as a warning by id only and left in place. The listing and
rename skip it, so its #/saved/<id> link behaves like a missing view.
Restore stays strict and still rejects a backup holding such a row.
2026-10-21 09:10:18 -07:00
Vito 762669758b feat(history): concept gallery cards, history comparison and CSS retirement (#266)
* feat(history): concept shared gallery

The history page's gallery is the concept's section.history-gallery with its
panel heading (Shared gallery, <name> · same positive prompt) and the Images
media cards, followed by the recorded executions. A saved variant with no
images shows the concept's empty copy. Recorded as D47.

* feat(history): drop the shared gallery's Linked images caption (D47 ruling)

The concept's "Shared gallery" heading is the gallery's only heading: the
embedded collection's "Linked images" heading and "N loaded · N linked
images" count repeated it. mountImages gains caption: false and a
focusTarget; only the history page's shared gallery passes them, so the
Images page and the All images and Repeat run dialogs keep their caption.
The layout row and the selected-image side panel stay (TESTING.md D47).

* test(history): concept comparison for prompt history

history-concept.spec.mjs compares the Prompt history with the concept's
#prompt/alpine on the concept fixture at 1600x1100 and 390x844, both
themes and motion settings, on Chromium and touch, with its behaviors
in both documents and a mutation check. inventoryOf gains a history
section; mapHistoryConcept applies D24, D35, D45, D46, D47 and the
approved Show archived button, laying the concept out at runtime.

Corrections it demonstrated: each compared negative mounts in a
display: contents child of the concept's .compare-item (D25), and the
concept fixture now links images oldest first and counts one pair use
per image, so newest-first reads and lane run counts match the concept.
history-canonical-comparison.mjs uses the concept's selectors for
production and drops its closed polish entries.

* feat(history): drop the Linked images caption from the history dialogs (D47 ruling)

The maintainer extended the D47 trim to the history page's two media
dialogs. All images for this positive and Repeat run are named by their
own dialog title, as in the concept (app.js a1282-1304), so their
embedded collection no longer shows the "Linked images" heading or the
"N loaded · N linked images" count. A stranded focus returns to the
dialog's close button. The Images page keeps its caption.

The history concept comparison now records each dialog's caption count
in both documents, and TESTING.md D47 covers the dialogs.

* refactor(workspace): retire library.css and the interim page guards

Every page is rebuilt, so the rules that waited for PRs D, E and F go.
library.css is deleted with its link; the overlay is now the only
stylesheet after concept.css.

Deleted because the concept already draws them: the .library-grid
tracks and breakpoints, the history .row-actions flex row and button
size, summary cursors, h2 margins, and the sender and Recorded run
dialogs' colors and border. Deleted because they had no effect:
min-inline-size on block children, a .recorded-run-dialog pre limit,
the general .prompt-inspector margin (now scoped to the Recorded run
dialog, the only mount point that is not display: contents) and a 700px
padding a later rule overrode. The duplicated sender and Recorded run
dialog guards are merged into one rule each.

Kept with a marker: windowed Library rows (D3), the shelf states (D40),
the history panel's wrap, archived-lane and coarse-target rules (D45),
and under the new D48 the production surfaces the concept never shows:
recorded executions and the Recorded run dialog (#209, D26), the
Archive negative confirmation (#235) and the prompt sender. They now use
the concept's --green and --red tokens instead of private copies with
the same values.

The workspace browser specs on Chromium and the concept specs on touch
pass unchanged. TESTING.md records D48 and closes the role-block gap.

* refactor(workspace): drop the dead embedded caption and tighten the review guards

- images.mjs: an embedded collection never has a caption (D47), so the
  caption option, its "Linked images" heading, the embedded count string
  and the heading arm of the focus fallback are removed; callers no
  longer pass caption:false. Behavior is unchanged.
- workspace.css: delete the never-applied .recorded-run-dialog::backdrop
  rule (the concept's .ca-tools dialog::backdrop always won) and note that
  the D3 row's min-inline-size: 0 is load-bearing for the concept's
  <=700px 1fr track.
- concept-css test: the waiting-rule guard is case-insensitive and is
  proven against the exact retired comments and the kept provenance labels.
- history-concept spec: function replacement for the singular run label,
  and the DOM mutation removes Compare negatives by role.
- D47 markers sit where the caption trim happens.
2026-10-21 05:16:38 -07:00
Vito 1ff2ddce05 fix(inspector): fall back to the positive's category and tags for recipe-linked images (#267)
* 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.

* test(inspector): assert the D39 fallback tag chips in their saved order

* fix(inspector): treat Unicode whitespace as a blank History recipe category

* test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query
2026-10-21 04:18:28 -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
86 changed files with 5512 additions and 600 deletions
+217 -29
View File
@@ -361,8 +361,8 @@ of the approved concept's `docs/concepts/workspace/style.css`. Never edit the co
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
PRs retire, and the production overlay `workspace.css` last.
the toolkit controls, Viewer.js, `concept.css` and the production overlay `workspace.css`
last; every page stylesheet that waited for its page PR is retired.
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
other page or canvas script loads workspace CSS.
@@ -373,19 +373,28 @@ D-number must be one of this spec's (#249): a comment that cites any other issue
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
for those. The lint test fails if `workspace.css` is missing. The match is by exact
selector, so a narrower selector over a concept rule (for example
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
convention.
`.ca-tools dialog.prompt-sender`) passes the lint; by convention such rules carry a
marker too.
The lint also runs over every page stylesheet that loads before the overlay and is
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.
PR D deleted `images.css` and `inspector.css`, and PR F-b deleted `library.css`, the
last page stylesheet (test `the retired shell, vocabulary, editor, Images, inspector and
library stylesheets stay deleted`). `the overlay keeps no retired Images page or
inspector rules` keeps the replaced card, filter, toolbar, preview, facts and inspector
rules out, and `the overlay keeps no rule waiting for a page rebuild` keeps out the
interim guards and the history page's private role colors. Every rule that stayed has
a live consumer and a marker: the Library rows windowed (D3) and the shelf's states
(D40), the history panel's wrap, archived-lane and coarse-target rules (D45), and under
**D48** the production surfaces the concept never shows, drawn in the concept's tokens
(`--green`, `--red`, `--ca-*`): recorded executions and the Recorded run dialog (#209,
D26), `pre.prompt-text` for stored text in the frozen field provenance and the Archive
negative confirmation, whose `section.prompt-block` (#235) keeps its rules too, and the
acknowledged prompt sender (spec §2.2). Rules that repeated the concept
(the `.library-grid` tracks and breakpoints, the history `.row-actions` flex row and
button size, `summary` cursors, `h2` margins, the sender and Recorded run dialogs'
colors and border) or had no effect (`min-inline-size` on block children, a
`.recorded-run-dialog pre` limit, a 700px padding that a later rule overrode) were
deleted; the workspace browser specs on Chromium and the concept specs on touch prove
the pages unchanged.
**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
@@ -422,13 +431,63 @@ Recorded shell deviations:
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
palette, then the OS.
- **D5**: no Activity button or Activity row until an activity center exists.
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
System plus the production Overview hub (its own outline home glyph, not the concept's
`activity` pulse, and no count).
The connection reads "Your library, on your server" with a neutral muted dot.
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
the concept's `links()` rows: the search icon, the view name and no count, linking
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
filters, and the view's name as the h1, breadcrumb and title. A value its page no
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
A toolbar change made while the sources are still loading keeps the saved Source and
Folder until discovery settles. A saved view, like Favorites, never replaces the plain
Images or Library return state. Writes (POST, PATCH) validate strictly; a state over
4 KB is refused in the browser with "The view is too large to save. Shorten its
search, folder or tags and try again." Startup and restore check only the table's
structure and that each row's state is a JSON object of at most 4 KB, never the value
rules, so a row saved under older rules (a missing or retired key, a sort no longer
offered) never stops the database from opening: reads fill missing keys with their
defaults, drop unknown keys, default invalid values and make the name displayable
(`tests/test_saved_views.py`). A live row whose state is not a JSON object never
blocks startup either: its id is logged as a warning, the row is left in place, the
listing and rename skip it, and its `#/saved/<id>` link behaves like a missing view.
Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is
a logged 500 with a generic message. The Favorites count re-read after starring is
coalesced to one read 300 ms after the last toggle.
`a saved view row matches the concept's` saves one view in both documents and
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
reopening, the defaults that replace rejected values, and failed reads and saves.
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
ruling, 2026-10-21). The comparison maps only that row out.
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
the concept's Favorites row (star icon, name, count). Its count is the Favorites
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
the nav-count rules (hidden until loaded, absent when its read fails, never a
placeholder) and is re-read after every confirmed star or unstar, on any page.
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
from `image_favorites` and matched against the scan with the toggle's case folding,
and a failed read is an error, never an unfiltered page. Unlike the concept, an
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
undone in place. Favorites neither restores nor replaces the Images return state.
Save view there saves `favorites: true`, so the view reopens the favorites. An open
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`; its Cancel
closes the dialog without deleting rather than returning to Edit); Delete
removes the view, replaces the dead route with the view's plain page and toasts
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
"The view could not be deleted. Try again."; a view another tab already deleted counts
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
route, star updates, Save view on Favorites, Edit, and the failure paths;
`tests/test_image_favorites.py` covers `favorites=1`.
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
library.", and the title has no " concept" suffix.
library.", and the title has no " concept" suffix. Save view's dialog action reads
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
@@ -455,7 +514,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
to it (production only: the concept re-renders its grid and drops focus), Info on the
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
sorted alphabetically). Removed toolbar and filter-row items flow again from the
concept's own boxes (`reflow`), checked against the concept page with those items
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
@@ -561,7 +620,9 @@ Recorded deviations on this page (spec §8):
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
"Rescanning…".
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
- **D16 (retired)**: Save view is the concept's own toolbar button, between sort and
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
with the Refine filters; the comparisons no longer map it.
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
outside the current year), hidden under every other sort.
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
@@ -657,12 +718,18 @@ Recorded deviations on this page (spec §8):
`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**: a recipe-linked image shows its recipe's own category and tags (#224), not
its positive's. The concept shows the prompt's category and tags for every image;
the concept fixture's recipes have neither, so their images show no category chip
and no tag row. `positive_links` on `/__test__/concept/reset` links the newest
images to their positive alone, so the inspector comparison sees the positive's
category ("Positive prompt category: " hidden prefix, D21) and tag filters.
- **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
@@ -760,8 +827,7 @@ Behaviors, in both documents: the title and Explore history open the prompt's hi
(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
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), 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
@@ -786,6 +852,114 @@ 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.
- **D47** (#249 PR F-b): the shared gallery is the concept's `section.history-gallery`
(region "Shared gallery") with `.panel-heading` (h2 "Shared gallery", `span.small`
"<name> · same positive prompt") after the variants panel (and Compare), and
recorded executions (#209) follow it, as the concept has no recorded section. The
name is the lane's own name ("<title>", else "Negative variant N"), with " · Archived"
for an archived pair, and follows a rename. Its cards are the Images media cards: the
star wherever the read returns `favorite`, no checkbox or selection bar (Images only,
#259), the image opens the Viewer. A saved variant with no images shows the concept's
`.empty` "Saved variant · no runs yet." beside the grid (the concept puts it inside
`.image-grid`, which only holds positioned cards here). The concept's "Shared gallery"
heading is the gallery's only heading and names its region: the embedded collection's
"Linked images" heading and "N loaded · N linked images" count are removed, since they
repeated it (maintainer ruling, 2026-10-21). Two production pieces stay, which the
concept's gallery does not show: the layout row (Grid/List/Masonry and Thumbnail size),
because the legacy gallery let people change layout and density and nothing else on the
page offers it, and the selected-image side panel, the one shared inspector the Images
page uses (spec §3.3), so an image's details stay reachable without opening the viewer.
The same ruling covers the page's two media dialogs, All images for this positive and
Repeat run: as in the concept (app.js a1282-1304), each is named by its own dialog
title, with no "Linked images" heading or "N loaded · N linked images" count, and a
stranded focus returns to the dialog's close button (maintainer ruling, 2026-10-21).
The Images page keeps its caption. With no saved variant the page shows every image
of the positive under "All images for this positive, including unassigned images. …"
and no empty copy (a state the concept never reaches).
- **D48** (#249 PR F-b): production surfaces the concept never shows keep their own
overlay rules, drawn in the concept's tokens: recorded executions and the Recorded run
dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
prompt sender (spec §2.2). With `library.css` retired, these rules live in
`workspace.css` (see "Concept shell and comparison method").
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").
`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the
immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in
both themes and motion settings, on Chromium and touch: the heading, the summary, the
variants panel, the comparison (Compare negatives pressed) and the shared gallery's
place and heading, all measured by `inventoryOf`'s `history` section with the concept's
selectors in both documents, with "Clean architecture" selected (the middle lane in
either order). The fixture links and records its images oldest first, so ids grow with
time as real runs do, and counts one pair use per image, which gives the concept's
"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four
recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the
visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to
the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with
"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show
archived button (added to the concept at runtime) and D47 (the gallery's body is the
Images collection, so its height is not compared). D25: each compared block mounts in a
`display: contents` child of the concept's `.compare-item`, so the item carries no
production class or margin. Behaviors are compared in both documents: a red dot changes
the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a
dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run
where the concept shows Repeat run), neither dialog has a collection caption (D47), the
heading send previews the selected pair, and
← Prompt library navigates. `history comparison detects mutations` injects
`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older
`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded
executions (#209) checks; its production selectors are now the concept's.
```sh
npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit
```
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
@@ -884,7 +1058,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
@@ -1109,10 +1285,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
@@ -1205,8 +1391,10 @@ Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. The Images card tag
button (#249 D9) and shared role-block
spacing/padding/glyph polish remain outside #209. The
button (#249 D9) remains outside #209; every prompt block now renders the concept's
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
gap is closed, and only the production-only surfaces above keep their own rules
(D48). The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
a whole-application or full-gallery/Viewer pixel-parity claim.
+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)
+13
View File
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
)
found.update(path for (path,) in rows)
return found
def get_root_favorites(self, root_key: str) -> set[str]:
"""Every relative key favorited under *root_key* (a primary-key range).
The Favorites listing (``favorites=1``) intersects these with the
root's scan, so its size follows the root's favorites, not its files.
"""
conn = self.model.get_connection()
rows = conn.execute(
"SELECT relative_path FROM image_favorites WHERE root_key=?",
(root_key,),
)
return {path for (path,) in rows}
+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(
+10 -2
View File
@@ -17,6 +17,7 @@ from typing import Optional, Set, Tuple
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .saved_views import migrate_saved_views, saved_views_schema_error
from .execution_history_schema import (
history_schema_error,
migrate_execution_history,
@@ -300,6 +301,7 @@ class PromptModel:
migrate_execution_history(conn)
migrate_library_state(conn)
migrate_image_favorites(conn)
migrate_saved_views(conn)
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
conn.execute("SAVEPOINT prompt_pair_schema")
@@ -1019,8 +1021,9 @@ class PromptModel:
Args:
path: File to inspect
require_library_state: Also require the tables every current
database has (Library state and image favorites), as a
migrated restore stage must; old backups may lack them.
database has (Library state, image favorites and saved
views), as a migrated restore stage must; old backups may
lack them.
Returns:
(ok, reason): reason is "ok" when the file is acceptable, otherwise
@@ -1080,6 +1083,11 @@ class PromptModel:
)
if favorites_error:
return False, favorites_error
views_error = saved_views_schema_error(
conn, allow_missing=not require_library_state
)
if views_error:
return False, views_error
return True, "ok"
except sqlite3.Error as e:
return False, f"Invalid SQLite database: {e}"
+2
View File
@@ -12,6 +12,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
from .pairs import PairDependencyError, PairOperations
from .library import LibraryOperations, titled
from .image_favorites import ImageFavoriteOperations
from .saved_views import SavedViewOperations
from .library_commands import LibraryCommandOperations
from .vocabulary import VocabularyOperations
from .execution_history import ExecutionHistoryOperations
@@ -306,6 +307,7 @@ class PromptDatabase(
ExecutionOutputOperations,
HistoryReadOperations,
ImageFavoriteOperations,
SavedViewOperations,
):
"""Database operations class for managing prompts."""
+334
View File
@@ -0,0 +1,334 @@
"""Persisted saved views (D16): a named Images or Library toolbar state.
A view's state holds exactly its page's toolbar values and its Refine-this-view
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
only the structure (the table, its columns and each row's types, sizes and JSON
object), never the value rules: a row saved under older rules must not stop the
database from opening. Startup is more tolerant still: a row whose state is not
a JSON object is logged by id, left in place and skipped by every read, while
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
usable values and defaults for the rest. Optional in old backups, required once
a database is current (see ``library_schema.py``).
"""
import json
import logging
import re
import sqlite3
from typing import Optional
# The models logger's parent ("prompt_manager.database.models" logs there too).
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
TABLE = "saved_views"
PAGES = ("images", "library")
# The schema check's literal SQL repeats the name and state caps (names in
# bytes, at most 4 per character); keep them in step.
MAX_NAME_LENGTH = 100
MAX_STATE_BYTES = 4096
MAX_TAGS = 20
MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
FALLBACK_NAME = "Saved view"
class SavedViewInvalid(ValueError):
"""A request's page, name or state breaks the write rules (a 400)."""
# (kind, limit-or-choices, default) per state key; "text" values are
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
_STATE_FIELDS = {
"images": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _IMAGE_SORTS, "newest"),
"source": ("source", None, "all"),
"folder": ("text", 1024, ""),
"media": ("choice", _MEDIA, "all"),
"tags": ("tags", None, ()),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None, False),
},
"library": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
},
}
_DDL = (
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
_COLUMNS = (
("id", "INTEGER", 0, 1),
("page", "TEXT", 1, 0),
("name", "TEXT", 1, 0),
("state", "TEXT", 1, 0),
("position", "INTEGER", 1, 0),
("created_at", "TEXT", 1, 0),
("updated_at", "TEXT", 1, 0),
)
_SELECT = "SELECT id, page, name, state FROM saved_views"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def validate_name(name: object) -> str:
"""The trimmed name, 1 to 100 characters without control characters."""
if not isinstance(name, str):
raise SavedViewInvalid("Name must be a string")
trimmed = name.strip()
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
if _CONTROL.search(trimmed):
raise SavedViewInvalid("Name contains control characters")
return trimmed
def _validate_value(key: str, kind: str, limit, value: object) -> None:
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
if kind == "tags":
if not isinstance(value, list) or len(value) > MAX_TAGS:
raise SavedViewInvalid(
f"State '{key}' must be a list of at most {MAX_TAGS}"
)
for tag in value:
if (
not isinstance(tag, str)
or not tag.strip()
or len(tag) > MAX_TAG_LENGTH
or "," in tag
or _CONTROL.search(tag)
):
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
return
if kind == "bool":
if not isinstance(value, bool):
raise SavedViewInvalid(f"State '{key}' must be true or false")
return
if not isinstance(value, str):
raise SavedViewInvalid(f"State '{key}' must be a string")
if kind == "choice" and value not in limit:
raise SavedViewInvalid(f"State '{key}' is not a supported value")
if kind == "source" and not _SOURCE.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
def encode_state(page: object, state: object) -> str:
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
if page not in PAGES:
raise SavedViewInvalid("Page must be 'images' or 'library'")
fields = _STATE_FIELDS[page]
if not isinstance(state, dict):
raise SavedViewInvalid("State must be an object")
if set(state) != set(fields):
raise SavedViewInvalid(
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
)
for key, (kind, limit, _default) in fields.items():
_validate_value(key, kind, limit, state[key])
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
raise SavedViewInvalid("State must encode to at most 4 KB")
return encoded
def _decode_object(raw: object) -> Optional[dict]:
"""The JSON object stored in *raw*, or None when it holds no object."""
if isinstance(raw, dict):
return raw
if not isinstance(raw, str):
return None
try:
decoded = json.loads(raw)
except (ValueError, RecursionError):
return None
return decoded if isinstance(decoded, dict) else None
def normalize_state(page: str, raw: object) -> dict:
"""The page's full state from a stored row, never raising.
Every key of the page's current whitelist is present: a stored value that
passes today's write rule is kept, anything else (missing, invalid, or a
row that is not a JSON object) takes the key's default, and keys the
whitelist no longer has are dropped. An unknown page has no keys.
"""
fields = _STATE_FIELDS.get(page, {})
stored = _decode_object(raw) or {}
state = {}
for key, (kind, limit, default) in fields.items():
value = stored.get(key, default)
try:
_validate_value(key, kind, limit, value)
except SavedViewInvalid:
value = default
state[key] = list(value) if kind == "tags" else value
return state
def normalize_name(name: object) -> str:
"""A stored name made displayable: trimmed, capped, never empty."""
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
return trimmed or FALLBACK_NAME
def saved_views_schema_error(
conn: sqlite3.Connection,
*,
allow_missing: bool = True,
check_states: bool = True,
) -> Optional[str]:
"""Reject unsupported saved-views schemas and structurally invalid rows.
Reads only. Checks the table, its columns, each row's types and sizes and
that each state is a JSON object; never the value rules, which change
between versions and are normalized on read instead. A missing table is
acceptable for backups that predate it unless ``allow_missing`` is false.
``check_states=False`` (live startup) skips the JSON-object check; such
rows are reported by ``unusable_view_ids`` and skipped on read.
"""
objects = conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
"OR (type IN ('index','trigger') AND tbl_name=?)",
(TABLE, TABLE),
).fetchall()
if not objects:
return None if allow_missing else "Missing saved views schema"
tables = [sql for kind, _name, sql in objects if kind == "table"]
if len(tables) != 1 or len(objects) != 1:
return "Saved views name is reserved by an unsupported schema object"
if _compact(tables[0] or "") != _compact(_DDL):
return "Saved views has an invalid table definition"
columns = tuple(
(row[1], row[2].upper(), row[3], row[5])
for row in conn.execute(f"PRAGMA table_info({TABLE})")
)
if columns != _COLUMNS:
return "Saved views has invalid columns"
# Literal SQL: the table name and limits are constants of this module.
if conn.execute(
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
"OR page NOT IN ('images','library') "
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
"OR typeof(updated_at) != 'text' "
"OR length(CAST(name AS BLOB)) > 400 "
"OR length(CAST(state AS BLOB)) > 4096 "
"OR length(CAST(created_at AS BLOB)) > 64 "
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
).fetchone():
return "Saved views contains invalid values"
if check_states and unusable_view_ids(conn):
return "Saved views contains invalid values"
return None
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
"""Ids of rows whose state is not a JSON object (never listed)."""
return [
view_id
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
if _decode_object(state) is None
]
def migrate_saved_views(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it."""
conn.execute("SAVEPOINT saved_views_schema")
try:
error = saved_views_schema_error(conn, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
if error:
raise sqlite3.DatabaseError(error)
# A corrupt state never stops the live database from opening: the row
# stays (no data is destroyed) and reads skip it. Only its id is logged.
for view_id in unusable_view_ids(conn):
_LOGGER.warning(
"Saved view id %d has an unreadable state and is hidden", view_id
)
conn.execute("RELEASE saved_views_schema")
except BaseException:
conn.execute("ROLLBACK TO saved_views_schema")
conn.execute("RELEASE saved_views_schema")
raise
def _view(row) -> dict:
view_id, page, name, state = row
return {
"id": view_id,
"page": page,
"name": normalize_name(name),
"state": normalize_state(page, state),
}
class SavedViewOperations:
"""PromptDatabase mixin: list, create, rename and delete saved views."""
def list_saved_views(self) -> list[dict]:
"""Every view, in list order (position, then creation)."""
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
# A row whose state is no JSON object (logged at startup) is never listed.
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
def create_saved_view(self, page: object, name: object, state: object) -> dict:
"""Validate and append one view at the end of the list."""
from .models import utc_now_iso # models imports this module's schema
encoded = encode_state(page, state)
trimmed = validate_name(name)
now = utc_now_iso()
with self.model.get_connection() as conn:
cursor = conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) SELECT ?, ?, ?, "
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
(page, trimmed, encoded, now, now),
)
row = conn.execute(
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _view(row)
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
"""The renamed view, or None when no view has *view_id*."""
from .models import utc_now_iso
trimmed = validate_name(name)
with self.model.get_connection() as conn:
conn.execute(
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
(trimmed, utc_now_iso(), view_id),
)
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
# A hidden (unreadable) view is not found, as in the listing.
return _view(row) if row and _decode_object(row[3]) is not None else None
def delete_saved_view(self, view_id: int) -> bool:
"""True when a view was removed."""
with self.model.get_connection() as conn:
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return cursor.rowcount > 0
+3
View File
@@ -25,6 +25,7 @@ from .prompts import PromptRoutesMixin
from .pairs import PairRoutesMixin
from .library import LibraryRoutesMixin
from .vocabulary import VocabularyRoutesMixin
from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
@@ -248,6 +249,7 @@ class PromptManagerAPI(
PairRoutesMixin,
LibraryRoutesMixin,
VocabularyRoutesMixin,
SavedViewRoutesMixin,
HistoryRoutesMixin,
PromptRoutesMixin,
ImageRoutesMixin,
@@ -432,6 +434,7 @@ class PromptManagerAPI(
self._register_pair_routes(routes)
self._register_library_routes(routes)
self._register_vocabulary_routes(routes)
self._register_saved_view_routes(routes)
self._register_history_routes(routes)
self._register_prompt_routes(routes)
self._register_image_routes(routes)
+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)
+63 -24
View File
@@ -36,7 +36,10 @@ 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,
@@ -52,7 +55,10 @@ except ImportError:
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,
@@ -690,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict:
``q`` (at most 500 characters) matches the relative path, case-folded, and
the linked positive's text, title and tag names. ``category`` is exact,
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
the linked positive. Raises ValueError for any unknown or oversized value.
the linked positive, and ``favorites=1`` keeps only favorites (the
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
value.
"""
text = query.get("q", "").strip()
folded = text.casefold()
@@ -701,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict:
tags = sorted(
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
)
favorites = query.get("favorites", "0")
if (
len(text) > 500
favorites not in ("0", "1")
or len(text) > 500
or len(folded) > 500
or media not in ("all", "image", "video")
or order not in WORKSPACE_ORDERS
@@ -721,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict:
"folder": folder,
"category": category,
"tags": tags,
"favorites": favorites == "1",
}
@@ -730,7 +741,12 @@ _COUNT_OMITTED = object()
def _filtered_page(
entries: list, start: int, size: int, filters: dict, links: dict | None
entries: list,
start: int,
size: int,
filters: dict,
links: dict | None,
favorites: frozenset | None = None,
) -> tuple[int, list, int | None]:
"""Count the *entries* the filters admit and take *size* from *start* on.
@@ -739,6 +755,7 @@ def _filtered_page(
category or tag filter admits only those, and ``q`` admits a filename match
or a linked-positive hit. Returns the total, the page and the number of
distinct linked positives among all admitted entries (None without links).
*favorites*, when given, admits only those listed paths (``favorites=1``).
"""
query, media, folder = filters["query"], filters["media"], filters["folder"]
members_only = filters["category"] is not None or bool(filters["tags"])
@@ -752,6 +769,8 @@ def _filtered_page(
continue
if media != "all" and (media == "video") != entry.video:
continue
if favorites is not None and entry.path not in favorites:
continue
match = None if links is None else links.get(entry.normalized)
if members_only and match is None:
continue
@@ -1140,6 +1159,7 @@ class ImageRoutesMixin:
[str(root) for root in output_dirs],
filters["category"],
filters["tags"],
filters["favorites"],
]
).encode()
).hexdigest()
@@ -1327,8 +1347,11 @@ class ImageRoutesMixin:
start = len(entries) - bisect_left(ascending, after, key=key)
else:
start = bisect_right(entries, after, key=key)
favorites = (
self._workspace_favorite_paths(listings) if filters["favorites"] else None
)
total, page, positive_count = _filtered_page(
entries, start, limit + 1, filters, links
entries, start, limit + 1, filters, links, favorites
)
if not needed and after is not None:
positive_count = _COUNT_OMITTED
@@ -2341,6 +2364,25 @@ class ImageRoutesMixin:
self._favorite_listing_cache[cache_key] = (files, listing)
return listing
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
"""The listed paths of every favorite in the selected roots (blocking).
One primary-key read per root (get_root_favorites), matched against the
root's cached ``{relative key: listing spelling}``, so the work follows
the favorites rather than the files, and a key folds case exactly as
the toggle stored it (Windows). A favorite whose file is gone matches
nothing. A failed read raises: an unfiltered page must never pass for
the favorites.
"""
paths = set()
for _index, root, files in listings:
listing = self._favorite_listing(root, files)
for key in self.db.get_root_favorites(_favorite_root_key(root)):
listed = listing.get(key)
if listed is not None:
paths.add(root / listed)
return frozenset(paths)
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
"""``(root index, listing path)`` of the favorites among *page* entries."""
by_root: dict[tuple[int, Path], dict[str, str]] = {}
@@ -2464,7 +2506,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
@@ -2472,23 +2515,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):
+115
View File
@@ -0,0 +1,115 @@
"""Saved views (D16): list, create, rename and delete named toolbar states.
The page, the name and the whitelisted per-page state are validated here
(through ``database/saved_views.py``) before anything is written. Only that
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
other failure, including a ``ValueError`` raised by the database layer, is a
logged 500 with a generic message.
"""
from functools import wraps
from typing import Callable
from aiohttp import web
try:
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
except ImportError:
from database.saved_views import SavedViewInvalid, encode_state, validate_name
_CREATE_FIELDS = frozenset({"page", "name", "state"})
_RENAME_FIELDS = frozenset({"name"})
_MAX_ID = 2**63 - 1
class SavedViewNotFound(LookupError):
pass
def saved_view_response(handler: Callable) -> Callable:
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
@wraps(handler)
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except SavedViewNotFound:
return web.json_response(
{"success": False, "error": "Saved view not found"}, status=404
)
except SavedViewInvalid as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception:
self.logger.exception("Saved view operation failed")
return web.json_response(
{"success": False, "error": "Saved view operation failed"}, status=500
)
return wrapped
async def _body(request: web.Request, fields: frozenset) -> dict:
"""A JSON object whose keys are exactly ``fields``."""
try:
body = await request.json()
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
raise SavedViewInvalid("Expected a JSON object") from None
if not isinstance(body, dict):
raise SavedViewInvalid("Expected a JSON object")
if set(body) != fields:
raise SavedViewInvalid(
"Request must contain exactly " + ", ".join(sorted(fields))
)
return body
def _view_id(request: web.Request) -> int:
raw = request.match_info["view_id"]
if not raw.isascii() or not raw.isdigit():
raise SavedViewInvalid("Invalid saved view ID")
value = int(raw)
if not 1 <= value <= _MAX_ID:
raise SavedViewInvalid("Invalid saved view ID")
return value
class SavedViewRoutesMixin:
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
@saved_view_response
async def list_saved_views(self, request: web.Request) -> web.Response:
views = await self._run_in_executor(self.db.list_saved_views)
return web.json_response({"success": True, "views": views})
@saved_view_response
async def create_saved_view(self, request: web.Request) -> web.Response:
body = await _body(request, _CREATE_FIELDS)
encode_state(body["page"], body["state"])
validate_name(body["name"])
view = await self._run_in_executor(
self.db.create_saved_view, body["page"], body["name"], body["state"]
)
return web.json_response({"success": True, "view": view}, status=201)
@saved_view_response
async def rename_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
body = await _body(request, _RENAME_FIELDS)
validate_name(body["name"])
view = await self._run_in_executor(
self.db.rename_saved_view, view_id, body["name"]
)
if view is None:
raise SavedViewNotFound
return web.json_response({"success": True, "view": view})
@saved_view_response
async def delete_saved_view(self, request: web.Request) -> web.Response:
view_id = _view_id(request)
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
raise SavedViewNotFound
return web.json_response({"success": True, "id": view_id})
+84 -2
View File
@@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
}
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
@@ -152,7 +154,8 @@ export async function mutate(page,{css=null,remove=[]}={}) {
* 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` (#249 E3) holds the Prompt library's toolbar,
* caption, grid, first six cards and whatever follows the grid; history stays null until PR F.
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
@@ -441,6 +444,85 @@ export function measureInventory(scope) {
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};
}
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
const spoken=node=>node.classList.contains('ca-visually-hidden');
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
function promptSectionOf(section,origin) {
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),...relative(section,origin),...edges(section),
label:when(label,()=>({text:label.textContent,...font(label)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function panelHeadingOf(heading,origin) {
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
return {...layout(heading),...edges(heading),...relative(heading,origin),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
}
function laneOf(lane,timeline) {
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
before:pseudo(lane,'::before'),
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
}
function timelineOf(timeline,origin) {
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
structure:structureOf(timeline)};
}
function variantsOf(panel) {
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
}
function compareOf(section) {
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
return {...edges(section),...relative(section,main),structure:structureOf(section),
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
}
function historyOf() {
const panel=main?.querySelector('section.timeline-panel');
if(!panel)return null;
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
return {
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
variants:variantsOf(panel),
compare:when(compare,compareOf),
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
@@ -450,7 +532,7 @@ 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:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
/**
+18 -4
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);
@@ -163,10 +163,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
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:[],metadata_scope:null};};
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'}}));
@@ -621,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})=>{
+24 -25
View File
@@ -10,13 +10,14 @@ 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',
],
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
// exact history comparison is tests/browser/history-concept.spec.mjs.
remainingApplicationPolish: [
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
@@ -33,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
await testInfo.attach(name, { path, contentType: 'image/png' });
}
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: 'article.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot:not([hidden])',
});
const RECORDED_HISTORY_SELECTORS = Object.freeze({
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',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
});
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-section',
prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
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',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
// production adds the recorded executions (#209), which the concept does not have.
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
+294
View File
@@ -0,0 +1,294 @@
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} from './concept-inventory.mjs';
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and
// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's
// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both
// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order
// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are
// artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const TITLE='Alpine observatory';
const SELECTED='Clean architecture';
const LANES=3;
const SHOW_ARCHIVED='Show archived (0)';
const SECTIONS=['heading','history','noPageScroll'];
/**
* Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production.
* `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one
* (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves.
*/
export function mapHistoryConcept(reference) {
const {history}=reference.oracle;
return {...reference,history:{...history,
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
summary:history.summary,
variants:{...history.variants,
// D45: the bounded "N of M saved negative variants" count stays a visually hidden status.
structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}},
compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item,
// D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page.
sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button,
label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}},
// D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and
// heading are compared here; the cards are the Images comparison's.
gallery:history.gallery&&withoutHeight(history.gallery),
}};
}
const COMPARED=['baseline','selected'];
const withoutHeight=({height,...gallery})=>gallery;
function compare(reference,actual) {
const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery
?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]]));
return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
function assertHistoryMatch(reference,actual) {
const diff=compare(reference,actual);
if(diff.length)throw new Error(`the Prompt history 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 decoded=page=>page.evaluate(async()=>{
await document.fonts.ready;
await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
});
const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true});
const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true});
const galleryCards=page=>page.locator('.history-gallery .media-card');
/** Press a variant dot and wait until the summary and the gallery follow it. */
async function selectVariant(page,name) {
await dot(page,name).click();
await expect(dot(page,name)).toHaveAttribute('aria-pressed','true');
await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`);
await expect(galleryCards(page).first()).toBeVisible();
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function setCompare(page,pressed) {
const button=compareButton(page);
if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click();
await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed));
await page.mouse.move(0,0);await settle(page);
}
async function openConceptHistory(page,theme,motion) {
await openConcept(page,'prompt/alpine',theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await selectVariant(page,SELECTED);
}
async function openProductionHistory(page,theme,motion,positiveId) {
await openProduction(page,`library/${positiveId}`,theme,motion);
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible();
await selectVariant(page,SELECTED);
}
// D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap,
// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser
// lays out what follows; at 1600px nothing wraps.
const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; }
.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`;
/**
* The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular
* (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo.
*/
async function conceptAsRecorded(page) {
const restoreLayout=await mutate(page,{css:D45_LAYOUT});
await page.evaluate(label=>{
const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')];
const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]);
lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`);
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
},SHOW_ARCHIVED);
await settle(page);
return ()=>page.evaluate(()=>{
const {timeline,order,texts,archived}=window.__historyOracle;
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
for(const [span,text] of texts)span.textContent=text;
archived.remove();delete window.__historyOracle;
}).then(restoreLayout);
}
/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */
async function conceptInventory(page) {
const reference=await inventoryOf(page,'#main');
const restore=await conceptAsRecorded(page);
const oracle=await inventoryOf(page,'#main');
await restore();await settle(page);
return {...reference,oracle};
}
const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent();
const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(),
cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))});
/** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */
async function dialogOf(page,trigger,{name,cards}) {
await trigger.click();
const dialog=page.getByRole('dialog',{name,exact:true});
await expect(dialog).toBeVisible();
if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards);
// D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …").
const captions=await dialog.locator('.section-caption').count();
const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions};
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
return result;
}
/** The behaviors Task F5 lists, with identical selectors in both documents. */
async function behaviorsOf(page,{sent,runDialog}) {
await selectVariant(page,'Quality baseline');
const before={negative:await negativeText(page),gallery:await galleryOf(page)};
await selectVariant(page,SELECTED);
const after={negative:await negativeText(page),gallery:await galleryOf(page)};
const pressed=[await compareButton(page).getAttribute('aria-pressed')];
await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent();
await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4});
const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog});
await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
const send=await sent(page);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('h1#page-title')).toHaveText('Prompt library');
return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()};
}
/** 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);
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(`history 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 positiveId=testInfo.manifest.prompts[0].positive_id;
const host=await openHost(context);
try {
const result={};
for(const motion of MOTIONS) {
const capture=motion==='no-preference';
await openConceptHistory(page,theme,motion);
const reference=await conceptInventory(page);
await setCompare(page,true);
const referenceCompared=await conceptInventory(page);
if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`);
const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null;
await openProductionHistory(page,theme,motion,positiveId);
const actual=await inventoryOf(page,'#workspace-main');
await setCompare(page,true);
const actualCompared=await inventoryOf(page,'#workspace-main');
if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`);
const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null;
result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared,
compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors};
}
const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('history-geometry',{path,contentType:'application/json'});
for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) {
// The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546).
const {summary,variants,compare:compared,gallery}=referenceCompared.history;
expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'});
// D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them.
expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']);
expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'});
variants.timeline.lanes.forEach((lane,index)=>{
expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57});
expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'});
});
expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]);
expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'});
expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']);
expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'});
expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}});
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]);
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
}
const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference'];
expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}});
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
test('history comparison detects mutations',async({page},testInfo)=>{
await page.setViewportSize({width:1600,height:1100});
await openConceptHistory(page,'dark','no-preference');
const reference=await conceptInventory(page);
await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id);
const measure=()=>inventoryOf(page,'#workspace-main');
assertHistoryMatch(reference,await measure());
const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'});
const cssMutated=await measure();
expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/);
await restoreCss();await settle(page);
assertHistoryMatch(reference,await measure());
await compareButton(page).evaluate(node=>node.remove());
const domMutated=await measure();
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
});
+6 -8
View File
@@ -85,8 +85,8 @@ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
@@ -329,9 +329,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
const conceptLoad=await openConceptLoaded(page,theme,motion);
const reference=await inventoryOf(page,'#main');
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
const removed={};
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
const restore=await mutate(page,{remove:[selector]});
removed[key]=(await inventoryOf(page,'#main'))[key];
await restore();
@@ -385,12 +385,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
`${motion} toolbar without Save view`).toEqual([]);
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
`${motion} filter row without the sample tag`).toEqual([]);
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
+18 -14
View File
@@ -5,16 +5,15 @@ import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInve
// 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), which the fixture
// links to its positive prompt alone (`positive_links`, D39), so its panel shows 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.
// 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;
const SEED={positive_links:1};
// 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
@@ -39,15 +38,15 @@ function withFocusablePrompts(inventory) {
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. */
/** 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, D38 and D39 through the fixture's
* `positive_links`); everything else must equal production. `reference.viewerPlain` is the concept's own full details
* 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.
*/
@@ -232,7 +231,7 @@ const historyOf=async page=>{
return page.evaluate(()=>location.hash);
};
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request,SEED);});
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);
@@ -303,15 +302,20 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
});
// D39: a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes do
// not have, so its panel has neither (the concept shows the prompt's for every image).
test('a recipe-linked image shows only its recipe metadata (D39)',async({page})=>{
// 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');
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tags .chip')).toHaveText(['Execution recorded']);
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']);
});
+7 -12
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,reflow,withoutItems} from './concept-inventory.mjs';
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} 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,
@@ -18,8 +18,8 @@ const CONCEPT_PROMPTS=6;
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');
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
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']};
@@ -251,11 +251,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
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.
// The browser as the re-layout's oracle: the concept page itself 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();
@@ -269,13 +265,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
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};
result[motion]={reference,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];
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
const {grid,cards}=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'});
@@ -291,7 +287,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
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([]);
+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();
+24 -3
View File
@@ -17,7 +17,6 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
MAX_POSITIVE_LINKS as CONCEPT_MAX_POSITIVE_LINKS,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
@@ -182,6 +181,7 @@ def create_app(root: Path) -> web.Application:
) -> None:
with api.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -443,7 +443,6 @@ def create_app(root: Path) -> web.Application:
limits = {
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
"positive_links": CONCEPT_MAX_POSITIVE_LINKS,
}
if (
request.query
@@ -467,11 +466,33 @@ def create_app(root: Path) -> web.Application:
root,
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
positive_links=data.get("positive_links", 0),
)
)
return web.json_response({"success": True, **manifest})
@routes.post("/__test__/saved-views/raw")
async def insert_raw_saved_view(request: web.Request) -> web.Response:
"""Store a view row as an older build wrote it, bypassing the API's rules."""
data = await request.json()
if (
not isinstance(data, dict)
or set(data) != {"page", "name", "state"}
or not all(isinstance(data[key], str) for key in data)
):
raise web.HTTPBadRequest()
def insert() -> int:
with api.db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO saved_views (page, name, state, position, "
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
(data["page"], data["name"], data["state"]),
).lastrowid
return web.json_response(
{"success": True, "id": await api._run_in_executor(insert)}
)
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
+39 -4
View File
@@ -56,13 +56,23 @@ function remainingFooter(footer,spans) {
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
/**
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
*/
function savedNav(nav) {
const rows=nav.rows.filter(row=>row.text!=='Needs review');
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
}
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='saved-nav')return savedNav(nav);
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
return {...nav,rows:[
@@ -76,8 +86,9 @@ function mapShellConcept(reference) {
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D28: only the WORKSPACE group label remains.
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
@@ -199,6 +210,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
}
});
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
await page.setViewportSize({width,height});
const save=async(action,mainSelector)=>{
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
await dialog.getByRole('button',{name:action,exact:true}).click();
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
await page.mouse.move(0,0);
return inventoryOf(page,mainSelector);
};
await openConcept(page,'images','dark');
const reference=await save('Apply in prototype','#main');
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
});
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
+326 -18
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 {
@@ -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);
});
@@ -282,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++;});
@@ -301,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);
});
@@ -380,7 +381,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
const hashes={};
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/workspace.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
@@ -399,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();
@@ -564,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'}}}));
@@ -575,7 +576,7 @@ 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 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++;});
@@ -595,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');
@@ -608,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');
@@ -617,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);
});
@@ -625,14 +626,14 @@ 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('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');
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);
};
@@ -721,3 +722,310 @@ 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);
// D47: as in the concept (a1291-1304), the dialog title names the image; no collection heading or count repeats it.
await expect(repeat.locator('.section-caption')).toHaveCount(0);
await expect(repeat.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(repeat.getByText(/ loaded · /)).toHaveCount(0);
expect(await repeat.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Repeat run']);
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']);
});
// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation).
test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{
const f=page.fixture;await open(page,f.canonical_id);
const section=gallery(page),heading=section.locator(':scope > .panel-heading');
expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery');
await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery');
// The page opens on the newest lane, a saved variant with no runs: the concept's empty copy.
await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt');
await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible();
await expect(section.locator('article[data-image-key]')).toHaveCount(0);
// D47 (maintainer ruling 2026-10-21): the concept's "Shared gallery" heading is the gallery's only heading, so the
// collection's "Linked images" heading and its "N loaded · N linked images" count are gone; the layout row and the
// selected-image side panel stay.
await expect(section.locator('.section-caption')).toHaveCount(0);
await expect(section.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(section.getByText(/ loaded · /)).toHaveCount(0);
// Outside the side panel (whose own heading names the selected image), the gallery has one section heading.
expect(await section.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Shared gallery']);
await expect(section.getByRole('group',{name:'Image layout',exact:true})).toBeVisible();
await expect(section.getByRole('combobox',{name:'Thumbnail size',exact:true})).toBeVisible();
await expect(section.getByRole('complementary',{name:'Selected image details',exact:true})).toBeVisible();
// As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it.
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name)));
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt');
const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2);
await expect(section.locator('.empty')).toBeHidden();
const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
const pair=details.variants.find(variant=>variant.title==='Quality baseline').id;
const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json();
const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean');
// The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and
// no checkbox (the selection bar stays Images-only, #259).
expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']);
expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']);
await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
const star=first.locator('.image-overlays > button.star-button');
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
await expect(star).toHaveText(image.favorite?'★':'☆');
await expect(first.locator('.media-info h3')).toHaveCount(1);
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
// C3 activation: the image opens the viewer and Escape returns focus to it.
const openButton=first.locator('.image-open');await openButton.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused();
await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between');
await expect(section).toHaveCSS('margin-top','24px');
// D47 (maintainer ruling 2026-10-21) covers the history page's dialogs too: the All images dialog is named by its
// own title, with no "Linked images" heading or "N loaded · N linked images" count, as the concept's (a1282-1290).
await variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
const all=page.getByRole('dialog',{name:'All images for this positive',exact:true});
await expect(all.locator('article[data-image-key]').first()).toBeVisible();
await expect(all.locator('.section-caption')).toHaveCount(0);
await expect(all.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
await expect(all.getByText(/ loaded · /)).toHaveCount(0);
expect(await all.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['All images for this positive']);
await page.keyboard.press('Escape');await expect(all).toHaveCount(0);
// At 390px the heading stays inside the gallery and the page never scrolls sideways.
await page.setViewportSize({width:390,height:844});
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]);
expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5);
});
+7 -3
View File
@@ -174,7 +174,9 @@ for (const embedded of [false, true]) {
if (close !== 'parent abort') {
await expect(preview).toBeFocused();
await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']);
await expect(caption(page)).toContainText(embedded ? '80 loaded' : '80 of 80');
// An embedded collection has no caption (D47); its loaded cards show the continuation instead.
if (embedded) { await expect(cards(page)).toHaveCount(80); await expect(caption(page)).toHaveCount(0); }
else await expect(caption(page)).toContainText('80 of 80');
}
await page.evaluate(() => window.continuationFixture.scope.dispose());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
@@ -806,7 +808,9 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
await expect(main.locator('search.toolbar > *')).toHaveCount(4);
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
@@ -1917,7 +1921,7 @@ test('Set category without a linked image posts nothing and opens no dialog', as
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
await checkbox(cards(page).last(), 'same.png').check();
await categoryButton(page).click();
await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.');
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
await expect(page.locator('dialog[open]')).toHaveCount(0);
expect(posted).toBe(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
+45 -19
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,7 +450,7 @@ 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();
@@ -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);
});
@@ -532,7 +534,7 @@ test('add negative re-renders history with the saved or reused pair selected',as
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('p.prompt-text')).toHaveText(f.selected_negative);
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
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');
@@ -552,7 +554,7 @@ test('reused pair outside first forty remains selected without loading every pag
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('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,22 +693,23 @@ 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 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);
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
// D47: the archived pair's gallery is named by its "Shared gallery" heading, with no separate loaded count.
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.panel-heading .small')).toHaveText(/ · Archived · same positive prompt$/);
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
const writes=page.writes.length;
@@ -728,7 +754,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,7 +765,7 @@ 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(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);
@@ -748,7 +774,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 +835,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 +899,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/library.css','js/workspace/workspace.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)) {
+23 -16
View File
@@ -7,6 +7,7 @@ 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). */
@@ -28,8 +29,10 @@ async function openHistory(page,text='Cat portrait') {
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');
}
@@ -57,9 +60,10 @@ 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});
@@ -70,8 +74,10 @@ test('history displays the selected full negative beneath its positive and filte
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})=>{
@@ -145,19 +151,19 @@ test('failed library reads retry while late search responses cannot overwrite ne
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.
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, 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']);
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','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(toolbar.getByRole('button')).toHaveText(['Save view','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 expect(toolbar.getByRole('button')).toHaveText(['Save view','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);
@@ -200,8 +206,9 @@ test('the caption count is announced once per change and kept while the shelf re
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
await page.goto(route);await openHistory(page);
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
const card=gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
// 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);
@@ -210,7 +217,7 @@ test('a Library-embedded media card stars its file and the star survives a reloa
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(gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
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})=>{
@@ -327,10 +334,10 @@ 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);
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(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
});
@@ -553,10 +560,10 @@ 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']);
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
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;
@@ -0,0 +1,546 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/saved-views';
const main = page => page.locator('#workspace-main');
const toolbar = page => main(page).locator('search.toolbar');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
const imageCards = page => page.locator('article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
// The saved views' own rows, after the Favorites row (D51).
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
for (const [label, value] of Object.entries(values)) {
const control = refineDialog(page).getByLabel(label, { exact: true });
if (label === 'Tags') await control.fill(value);
else await control.selectOption(value);
}
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
await expect(refineDialog(page)).toHaveCount(0);
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).views;
}
async function create(request, data) {
const response = await request.post(API, { data });
expect(response.status()).toBe(201);
return (await response.json()).view;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.savedErrors = [];
page.on('pageerror', error => page.savedErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
page.on('response', response => {
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
page.savedErrors.push(`${response.status()} ${response.url()}`);
}
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.savedErrors).toEqual([]);
});
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
await expect(imageCards(page)).toHaveCount(1);
await sortBy(page).selectOption('name');
await refine(page, { Source: '1', Media: 'image' });
await expect(filtersButton(page)).toHaveText('Filters · 2');
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
await saveButton.click();
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
const dialog = saveDialog(page);
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(saveButton).toBeFocused();
expect(await listed(request)).toEqual([]);
await saveButton.click();
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
// The concept's links() row: the search icon, then the name, and no count.
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
await expect(row).not.toHaveAttribute('aria-current', 'page');
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
await expect(promptCards(page).first()).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
await expect(row).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(search(page)).toHaveValue('dogs');
await expect(sortBy(page)).toHaveValue('name');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await filtersButton(page).click();
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
await page.keyboard.press('Escape');
// A reload opens the same view from its link.
await page.reload();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
await expect(imageCards(page)).toHaveCount(1);
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('My workspace view');
await name.fill(' ');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view']);
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
await page.goto(`${workspace}#/library`);
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await refine(page, { Tags: 'dogs' });
await expect(filtersButton(page)).toHaveText('Filters · 1');
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await expect(name).toHaveValue('Search: dog');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
const library = (await listed(request))[1];
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
await expect(search(page)).toHaveValue('dog');
await expect(filtersButton(page)).toHaveText('Filters · 1');
await expect(promptCards(page)).toHaveCount(1);
// Saved views open the page; one saved view to another re-renders with the other's state.
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(2);
});
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Gone source',
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('name-desc');
// The missing Source and Folder are dropped, so no refinement is active.
await expect(filtersButton(page)).toHaveText('Filters');
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
// A Source that does exist, with a folder it lists, applies.
const kept = await create(request, { page: 'images', name: 'Input shared',
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
await page.goto(`${workspace}#/saved/${kept.id}`);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
const raw = async data => {
const response = await request.post('/__test__/saved-views/raw', { data });
await expect(response).toBeOK();
return (await response.json()).id;
};
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
await page.goto(`${workspace}#/saved/${corruptId}`);
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await page.goto(`${workspace}#/saved/${imagesId}`);
await expect(heading(page)).toHaveText('Old cats');
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
await expect(search(page)).toHaveValue('cat');
await expect(sortBy(page)).toHaveValue('newest');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
await expect(heading(page)).toHaveText('Saved view');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
await viewRows(page).filter({ hasText: 'Dogs' }).click();
await expect(heading(page)).toHaveText('Dogs');
await expect(search(page)).toHaveValue('dogs');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(promptCards(page)).toHaveCount(2);
await search(page).fill('dog');
await expect(promptCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/saved/${cats.id}`);
await expect(heading(page)).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(heading(page)).toHaveText('Prompt library');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(dogs.id).not.toBe(cats.id);
});
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
let release;
const held = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Input shared');
await sortBy(page).selectOption('name');
await expect(imageCards(page)).toHaveCount(0);
release();
await expect(filtersButton(page)).toHaveText('Filters · 2');
await expect(sortBy(page)).toHaveValue('name');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
await page.unroute(`**${API}`);
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
};
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
await holdList();
await page.goto(`${workspace}#/saved/${view.id}`);
await page.evaluate(() => { window.location.hash = '#/tags'; });
await expect(heading(page)).toHaveText('Tags & categories');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(heading(page)).toHaveText('Tags & categories');
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
await holdList();
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
await page.evaluate(() => { window.location.hash = '#/library'; });
await expect(heading(page)).toHaveText('Prompt library');
releaseList();
await expect(viewRows(page)).toHaveText(['Cats']);
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await page.unroute(`**${API}`);
// A rename that lands after the view was left does not retitle the next page.
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
await main(page).locator('.heading-actions > button').first().click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(heading(page)).toHaveText('Images');
releaseRename();
await expect(viewRows(page)).toHaveText(['Kittens']);
await expect(heading(page)).toHaveText('Images');
await expect(page).toHaveTitle('Images · Prompt Manager');
});
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
await page.goto(`${workspace}#/saved/999`);
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
: route.fallback());
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(viewRows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
await expect(search(page)).toHaveValue('cat');
await expect(promptCards(page)).toHaveCount(1);
await expect(viewRows(page)).toHaveText(['Cats']);
});
test('a failed save says so and adds nothing', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await page.route(`**${API}`, route => route.request().method() === 'POST'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
});
async function star(page, key) {
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
}
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
let release;
const held = new Promise(resolve => { release = resolve; });
// Hidden until its read settles: never a placeholder "0".
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
await expect(favoritesCount(page)).toHaveCount(0);
release();
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
await expect(favoritesCount(page)).toHaveText('0');
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Added to favorites.');
await expect(favoritesCount(page)).toHaveText('1');
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
});
await favoritesRow(page).click();
await expect(page).toHaveURL(/#\/favorites$/);
await expect(heading(page)).toHaveText('Favorites');
await expect(heading(page)).toBeFocused();
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
await expect(page).toHaveTitle('Favorites · Prompt Manager');
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
expect(listings.at(-1).get('favorites')).toBe('1');
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
await star(page, '1:shared/same.png');
await expect(toast(page)).toHaveText('Removed from favorites.');
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
await expect(favoritesCount(page)).toHaveText('0');
await star(page, '1:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
// Favorites never replaces the Images return state: Images still lists everything.
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
await expect(heading(page)).toHaveText('Images');
await expect(imageCards(page)).toHaveCount(2);
await page.goto(`${workspace}#/favorites`);
await expect(heading(page)).toHaveText('Favorites');
await expect(imageCards(page)).toHaveCount(1);
});
test('a failed Favorites count shows no badge', async ({ page }) => {
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/images`);
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
await expect(favoritesRow(page)).toBeVisible();
await expect(favoritesCount(page)).toHaveCount(0);
// The refused count read is this test's own; nothing else failed.
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
});
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await star(page, '0:shared/same.png');
await expect(favoritesCount(page)).toHaveText('1');
await page.goto(`${workspace}#/favorites`);
await expect(imageCards(page)).toHaveCount(1);
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const [view] = await listed(request);
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
await expect(viewRows(page)).toHaveText(['Best']);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Best');
await expect(imageCards(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// Plain pages have no Edit.
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
await viewRows(page).click();
await expect(heading(page)).toHaveText('Dogs');
// D51: Edit leads the heading's actions of an open view.
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
await edit.click();
const dialog = editDialog(page);
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
await expect(name).toHaveValue('Dogs');
await expect(name).toBeFocused();
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(edit).toBeFocused();
// An empty name keeps the dialog open on its field.
await edit.click();
await name.fill(' ');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(name).toBeFocused();
await name.fill('Good dogs');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view renamed.');
await expect(viewRows(page)).toHaveText(['Good dogs']);
await expect(heading(page)).toHaveText('Good dogs');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
await expect(edit).toBeFocused();
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
// The rename kept the view's page state.
await expect(search(page)).toHaveValue('dogs');
await expect(imageCards(page)).toHaveCount(1);
// Delete… asks first; its Cancel deletes nothing.
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
const confirm = deleteDialog(page);
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(confirm).toHaveCount(0);
expect(await listed(request)).toHaveLength(1);
await edit.click();
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(viewRows(page)).toHaveCount(0);
expect(await listed(request)).toEqual([]);
// The dead link left no history entry.
await page.goBack();
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
await expect(edit).toHaveText('Edit');
let failing = true;
await page.route(`**${API}/${view.id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
: route.fallback());
await edit.click();
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
await expect(heading(page)).toHaveText('Cats');
await expect(viewRows(page)).toHaveText(['Cats']);
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
expect(await listed(request)).toHaveLength(1);
failing = false;
await edit.click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view deleted.');
await expect(page).toHaveURL(/#\/library$/);
await expect(heading(page)).toHaveText('Prompt library');
await expect(viewRows(page)).toHaveCount(0);
});
+16 -12
View File
@@ -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 to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) 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.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-history .history-gallery .image-card-actions button').first().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 page.locator('.library-history .history-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-history .history-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"]`);
@@ -541,7 +542,7 @@ test('Library visual evidence keeps natural cards and returns from real history
await noOverflow(page);
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.');
@@ -637,15 +638,18 @@ test('embedded history gallery keeps the history heading and disposes its layout
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,.detail-panel,dialog[open]')).toHaveCount(0);
}
+4 -4
View File
@@ -95,11 +95,11 @@ 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 expect(toolbar.getByRole('button')).toHaveText(['Save view','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 expect(toolbar.getByRole('button')).toHaveText(['Save view','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);
@@ -109,7 +109,7 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
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']);
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
});
test('category Browse sets only the category constraint',async({page})=>{
@@ -825,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/library.css','js/workspace/workspace.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});
+9 -4
View File
@@ -18,6 +18,8 @@ const routes = [
['tags', 'Tags & categories', null],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
['favorites', 'Favorites', null],
];
async function countThemeWrites(page) {
@@ -427,10 +429,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
'nav#saved-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
@@ -457,7 +462,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
+59 -53
View File
@@ -157,9 +157,6 @@ CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
MAX_SEEDLESS_IMAGES = 2
# The newest images linked to their positive alone (no recipe), for the inspector's
# positive-scope category and tag chips (D39).
MAX_POSITIVE_LINKS = 6
SEEDLESS_SIZE = (1536, 1152)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
@@ -171,6 +168,7 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -344,7 +342,6 @@ def seed_workspace_concept(
*,
extra_images: int = 0,
seedless_images: int = 0,
positive_links: int = 0,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
@@ -354,7 +351,6 @@ def seed_workspace_concept(
"""
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
_bounded("positive_links", positive_links, MAX_POSITIVE_LINKS)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
@@ -396,56 +392,66 @@ def seed_workspace_concept(
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
now = time.time()
images, runs = [], []
for round_index in range(rounds):
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
if index >= total:
break
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = None if index < positive_links else owner["pair_ids"][variant]
if pair_id is not None:
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
slots = [
(round_index, prompt_index, prompt)
for round_index in range(rounds)
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
]
# Linked and recorded oldest first, as real runs arrive, so link and run ids
# grow with time and every newest-first read (by id or by mtime) lists the
# concept's order (app.js:180-239). The manifest keeps the concept's order.
for round_index, prompt_index, prompt in reversed(slots):
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
name = concept_filename(prompt, round_index)
path = folder / name
facts = write_concept_png(path, prompt_index, round_index)
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
# The concept lists images newest first in this order.
stamp = now - 60 - index * 60
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
pair_id = owner["pair_ids"][variant]
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
# Real pair counting: each image is one run of its pair, as the
# concept's "<name> · N runs" counts its images (app.js a598, D26).
db.record_prompt_pair_use(pair_id)
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
context = RecipeContext(
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
)
reference = OutputReference(
"4", 0, "output", "concepts", name, "posix", roots.output_key
)
runs.append(
_record_run(
api,
epoch=epoch,
item=index,
context=context,
output=reference,
observed_at=int(stamp * 1000),
)
)
images.append(
{
"filename": name,
"image_id": image_id,
"pair_id": pair_id,
"prompt": prompt.id,
"variant": variant,
"seed": seed,
"path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
images.reverse()
runs.reverse()
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
+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,
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"saved_views",
"image_favorites",
"image_prompt_pairs",
"prompt_pair_tags",
+1 -1
View File
@@ -25,7 +25,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
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();
+16
View File
@@ -224,6 +224,22 @@ test("facts use resolution.sampler_id for detailer-stage nodes", () => {
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
});
test("a shared pair resolved across sampler branches merges facts per field (#249)", () => {
// The server resolves identical branches with no sampler named: settings the
// branches agree on are known, those they differ on (a LoRA per epoch) are ambiguous.
const shared = { resolution: { state: "resolved", sampler_id: null, reason: "Every sampler branch uses the same prompt pair" } };
const grid = factGraph();
grid.lora = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "epoch_10.safetensors" } };
grid.other = structuredClone(grid.s); grid.other.inputs.model = ["lora", 0];
assert.deepEqual(statusValues(factsFor(grid, shared)), {
seed: ["known", "9007199254740993"], steps: ["known", "28"], cfg: ["known", "6.5000000000000000001"],
sampler: ["known", "dpmpp_2m"], scheduler: ["known", "karras"], model: ["ambiguous", null],
});
assert.ok(Object.values(factsFor(grid, shared)).every(fact => fact.sampler_id === null));
// One listed sampler keeps reading that sampler alone.
assert.equal(factsFor(factGraph(), shared).model.value, "model.safetensors");
});
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
+1 -1
View File
@@ -30,7 +30,7 @@ test("facts guider providers are guiders in the semantics table", () => {
});
test("the facts tables are not part of the legacy page contract", () => {
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]);
assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams", "legacyNegativeSource"]);
assert.ok(Object.isFrozen(metadata._factsTables));
});
+15
View File
@@ -166,6 +166,21 @@ test('a title names each action after its prompt (concept "Copy positive prompt:
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',()=>{
+1 -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()=>{
+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);
});
+1 -1
View File
@@ -33,7 +33,7 @@ test('the outcome toast counts the prompts and the skipped images', async () =>
test('the dialog and failure copy is the plan\'s', async () => {
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
});
+11
View File
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
assert.equal(query.has('favorites'),false);
});
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({favorites:true,q:'fog'});
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
await stream.reset({favorites:false});
assert.equal(query.has('favorites'),false);
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
+135 -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);
@@ -534,6 +538,136 @@ test('a definite server prompt is kept even when the legacy parser reads it diff
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
// #249: a LoRA compare grid. Every sampler's negative is a ConditioningZeroOut of the
// positive encode; the legacy parser follows the zero-out back to the positive text.
function zeroedGridGraph(samplers = 3) {
const graph = {
1: { class_type: 'UNETLoader', inputs: { unet_name: 'base.safetensors', weight_dtype: 'default' } },
2: { class_type: 'CLIPLoader', inputs: { clip_name: 'te.safetensors', type: 'stable_diffusion' } },
4: { class_type: 'CLIPTextEncode', inputs: { text: 'a woman holding a coffee cup', clip: ['2', 0] } },
5: { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['4', 0] } },
6: { class_type: 'EmptyLatentImage', inputs: { width: 512, height: 512, batch_size: 1 } },
};
for (let index = 0; index < samplers; index++) {
graph[`1${index}`] = { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: `epoch_${index}.safetensors`, strength_model: 1, model: ['1', 0] } };
graph[`2${index}`] = { class_type: 'KSampler', inputs: { seed: 42, steps: 8, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1,
model: [`1${index}`, 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] } };
}
return graph;
}
function undecided(graph) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: graph };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
test('a zeroed negative is never filled with the positive text from the legacy parse (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The legacy pages keep their reading: the zero-out passes the positive through.
assert.equal(parser.extractGenerationParams({ prompt: zeroedGridGraph() }).negativePrompt, 'a woman holding a coffee cup');
const record = undecided(zeroedGridGraph());
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// Behind an API-graph Reroute the legacy parse reads no negative, so there is nothing
// to correct: the server's answer stays.
const rerouted = zeroedGridGraph(1);
rerouted[7] = { class_type: 'Reroute', inputs: { value: ['5', 0] } };
rerouted[20].inputs.negative = ['7', 0];
assert.equal(parser.legacyNegativeSource({ prompt: rerouted }), null);
assert.deepEqual(undecided(rerouted).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
test('the zero-out check follows the sampler the legacy parse read (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const zeroed = { status: 'resolved', text: '', source: 'zeroed-conditioning' };
// The legacy parse reads the first sampler (20): its negative is the zero-out.
const mixed = zeroedGridGraph(2);
mixed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
mixed[21].inputs.negative = ['8', 0];
assert.deepEqual(undecided(mixed).prompts.negative, zeroed);
// Reversed: the sampler it reads encodes "blurry", which it showed before and still does.
const reversed = zeroedGridGraph(2);
reversed[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
reversed[20].inputs.negative = ['8', 0];
assert.equal(parser.extractGenerationParams({ prompt: reversed }).negativePrompt, 'blurry');
assert.deepEqual(undecided(reversed).prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
// The positive fills from the legacy parse in both cases.
assert.equal(undecided(mixed).prompts.positive.text, 'a woman holding a coffee cup');
assert.equal(undecided(reversed).prompts.positive.text, 'a woman holding a coffee cup');
});
test('a zero-out mixed into the read negative leaves it as the server left it (#249)', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
// A zero-out combined with another conditioning is not an empty negative.
const combined = zeroedGridGraph(1);
combined[8] = { class_type: 'CLIPTextEncode', inputs: { text: 'blurry', clip: ['2', 0] } };
combined[9] = { class_type: 'ConditioningCombine', inputs: { conditioning_1: ['5', 0], conditioning_2: ['8', 0] } };
combined[20].inputs.negative = ['9', 0];
assert.deepEqual(undecided(combined).prompts.negative, { status: 'ambiguous', text: null, source: null });
});
function workflowOnly(workflow) {
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'unresolved', text: null, source: null }, negative: { status: 'unresolved', text: null, source: null } };
Object.assign(data.metadata, { raw: { workflow: JSON.stringify(workflow) }, record_formats: { workflow: 'reserialized-json' },
prompt_graph: { status: 'absent', value: null }, workflow_graph: { status: 'parsed', value: workflow } });
return contract('toInspectionRecord')(data, entry, extractGenerationFacts);
}
// A UI workflow: encode 4 -> zero-out 5 -> sampler 3's negative, encode 4 -> positive.
function zeroedWorkflow({ zeroMode = 0, extraNodes = [], extraLinks = [], negativeLink = 3 } = {}) {
return { nodes: [
{ id: 4, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [1, 2] }], widgets_values: ['a woman holding a coffee cup'] },
{ id: 5, type: 'ConditioningZeroOut', mode: zeroMode, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 1 }], outputs: [{ links: [3] }] },
{ id: 1, type: 'UNETLoader', inputs: [], outputs: [{ links: [4] }], widgets_values: ['base.safetensors', 'default'] },
{ id: 3, type: 'KSampler', inputs: [{ name: 'model', link: 4 }, { name: 'positive', link: 2 }, { name: 'negative', link: negativeLink }], widgets_values: [42, 'fixed', 8, 1, 'euler', 'simple', 1] },
...extraNodes,
], links: [[1, 4, 0, 5, 0, 'CONDITIONING'], [2, 4, 0, 3, 1, 'CONDITIONING'], [3, 5, 0, 3, 2, 'CONDITIONING'], [4, 1, 0, 3, 0, 'MODEL'], ...extraLinks] };
}
test('workflow-only files follow the legacy reading of their zero-out (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// A connected zero-out on the read sampler's negative is the zeroed empty negative.
const direct = zeroedWorkflow();
assert.equal(parser.extractGenerationParams({ workflow: direct }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(direct).prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
// A bypassed zero-out (mode 4) passes its conditioning through: the legacy reading is right.
const bypassed = workflowOnly(zeroedWorkflow({ zeroMode: 4 }));
assert.deepEqual(bypassed.prompts.negative, { status: 'resolved', text: 'a woman holding a coffee cup', source: 'graph-inferred' });
// An unconnected zero-out beside a text negative changes nothing.
const unconnected = workflowOnly(zeroedWorkflow({ negativeLink: 6,
extraNodes: [{ id: 6, type: 'CLIPTextEncode', inputs: [], outputs: [{ links: [6] }], widgets_values: ['blurry'] }],
extraLinks: [[6, 6, 0, 3, 2, 'CONDITIONING']] }));
assert.deepEqual(unconnected.prompts.negative, { status: 'resolved', text: 'blurry', source: 'graph-inferred' });
});
test('a negative read through a subgraph holding a zero-out is not filled (#249)', t => {
const parser = require('../../web/js/comfy-metadata.js');
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
// The sampler's negative comes from a subgraph instance whose definition zeroes it.
const definition = 'aaaaaaaa-0000-4000-8000-000000000001';
const workflow = zeroedWorkflow({ negativeLink: 7,
extraNodes: [{ id: 9, type: definition, inputs: [{ name: 'conditioning', type: 'CONDITIONING', link: 8 }], outputs: [{ links: [7] }] }],
extraLinks: [[7, 9, 0, 3, 2, 'CONDITIONING'], [8, 4, 0, 9, 0, 'CONDITIONING']] });
workflow.definitions = { subgraphs: [{ id: definition, nodes: [{ id: 1, type: 'ConditioningZeroOut', inputs: [], outputs: [] }], links: [] }] };
assert.equal(parser.extractGenerationParams({ workflow }).negativePrompt, 'a woman holding a coffee cup', 'the legacy parse would fill it');
assert.deepEqual(workflowOnly(workflow).prompts.negative, { status: 'unresolved', text: null, source: null });
// The same instance of a definition without a zero-out keeps the legacy fill.
workflow.definitions.subgraphs[0].nodes[0].type = 'Reroute';
assert.equal(workflowOnly(workflow).prompts.negative.text, 'a woman holding a coffee cup');
});
test('callers without the browser global pass the legacy parser explicitly', () => {
const parser = require('../../web/js/comfy-metadata.js');
const entry = image(), data = envelope(entry);
+1 -1
View File
@@ -119,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()=>{
+202
View File
@@ -0,0 +1,202 @@
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
const state = normalizeViewState('library', { tags });
state.tags.push('two');
assert.deepEqual(tags, ['one']);
assert.throws(() => normalizeViewState('tags', {}), TypeError);
});
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
const { defaultViewName } = await source;
assert.equal(defaultViewName('neon'), 'Search: neon');
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
assert.equal(defaultViewName(''), 'My workspace view');
assert.equal(defaultViewName(' '), 'My workspace view');
assert.equal(defaultViewName(undefined), 'My workspace view');
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
});
test('the listing keeps usable views in order and normalizes their state', async () => {
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
const views = parseSavedViews({ success: true, views: [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 0, page: 'images', name: 'Bad id', state: {} },
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
{ id: 5, page: 'images', name: ' ', state: {} },
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
assert.throws(() => parseSavedViews({ success: true }), TypeError);
assert.equal(savedViewHash(12), '#/saved/12');
});
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options.cache]);
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
},
post(url, body) {
calls.push(['POST', url, body]);
return Promise.resolve({ success: true, view: { id: 9, ...body } });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
},
del(url) {
calls.push(['DELETE', url]);
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
},
};
return { api, pending };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const controller = new AbortController();
const store = createSavedViews(api, controller.signal);
const seen = [];
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
store.load();
const settled = store.settled();
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
assert.equal((await settled).status, 'ready');
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
assert.equal(store.find(3), null);
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
store.load();
pending[1].reject(new Error('offline'));
await flush();
assert.equal(store.status, 'error');
assert.deepEqual(store.views.map(view => view.id), [2]);
// A second read aborts the first; only the newer one lands.
store.load();
store.load();
assert.equal(pending[2].signal.aborted, true);
pending[2].resolve({ success: true, views: [] });
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
await flush();
assert.deepEqual(store.views.map(view => view.id), [5]);
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
controller.abort();
});
test('creating a view posts the full state and re-reads the list', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const created = store.create('library', 'Neon', { q: 'neon' });
await flush();
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
assert.equal(store.find(9).name, 'Neon');
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
await assert.rejects(broken.create('images', 'X', {}), TypeError);
});
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const renamed = store.rename(9, 'Dusk');
await flush();
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
assert.equal(store.find(9).name, 'Dusk');
const removed = store.remove(9);
await flush();
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
pending[1].resolve({ success: true, views: [] });
assert.equal(await removed, 9);
assert.equal(store.find(9), null);
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
const gone = store.remove(4);
await flush();
pending[2].resolve({ success: true, views: [] });
assert.equal(await gone, 4);
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
const failed = store.remove(4);
await flush();
pending[3].resolve({ success: true, views: [] });
await assert.rejects(failed, /Locked/);
api.patchResult = Promise.resolve({ success: true, view: null });
const invalid = store.rename(4, 'X');
await flush();
pending[4].resolve({ success: true, views: [] });
await assert.rejects(invalid, TypeError);
assert.equal(pending.length, 5);
assert.throws(() => store.rename(0, 'X'), TypeError);
assert.throws(() => store.remove('9'), TypeError);
});
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
assert.equal(MAX_STATE_BYTES, 4096);
assert.equal(stateFits(IMAGE_DEFAULTS), true);
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
assert.equal(stateFits(big), false);
const { api } = fakeApi();
const store = createSavedViews(api);
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
+59 -9
View File
@@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica
test('nav counts reject negative, non-integer and missing totals', async () => {
const { summarizeNavCounts } = await source;
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
assert.equal(result.get('images'), null, JSON.stringify(invalid));
assert.equal(result.get('favorites'), null);
assert.equal(result.get('library'), 2);
assert.equal(result.get('tags'), null);
}
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
});
test('nav counts settle independently so a slow images read never delays the others', async () => {
@@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth
let releaseImages;
const images = new Promise(resolve => { releaseImages = resolve; });
const api = {get: async url => {
if (url.includes('favorites=1')) return {success: true, total: 2};
if (url.startsWith('/prompt_manager/images')) return images;
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
throw new Error('Unavailable');
@@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth
const settled = [];
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
await new Promise(resolve => setTimeout(resolve, 0));
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
releaseImages({success: true, total: 40});
const counts = await done;
assert.deepEqual(settled.at(-1), ['images', 40]);
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
});
test('nav counts issue three bounded requests and drop only the failed count', async () => {
test('nav counts issue four bounded requests and drop only the failed count', async () => {
const { loadNavCounts } = await source;
const calls = [];
const signal = new AbortController().signal;
@@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
const urls = calls.map(([url]) => new URL(url, 'http://host'));
assert.deepEqual(urls.map(url => url.pathname),
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.equal(urls[0].searchParams.has('favorites'), false);
// D51: the Favorites count is the Favorites page's own listing.
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
assert.equal(urls[1].searchParams.get('favorites'), '1');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a favorite change re-reads only the Favorites count', async () => {
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
const calls = [];
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
// A real zero is a count, unlike an outstanding read.
assert.deepEqual([...counts], [['favorites', 0]]);
});
test('the Favorites route is native and resolves by its hash', async () => {
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
const route = resolveRoute('#/favorites');
assert.equal(route.id, 'favorites');
assert.equal(route.label, 'Favorites');
assert.equal(routeTitle(route), 'Favorites');
assert.equal(isHandoff(route), false);
// No href: Overview lists no legacy card for it.
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
});
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
@@ -160,3 +189,24 @@ test('navigating to the current route re-renders it, as the concept go() does',
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
});
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
const route=resolveRoute('#/saved/7');
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
assert.equal(isHandoff(route),false);
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/saved/3'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
assert.equal(win.location.hash,'#/saved/3');
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
dispose();
});
+93 -9
View File
@@ -116,19 +116,21 @@ test('toolbar emits concept markup and order', async () => {
dom.body.append(container);
const changes = [];
const filters = [];
const saves = [];
const toolbar = mountToolbar(container, {
scope, categories: ['Portraits', 'Landscapes'],
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
onSaveView: trigger => saves.push(trigger),
});
const root = container.children[0];
assert.equal(toolbar.element, root);
assert.equal(root.tagName, 'SEARCH');
assert.equal(root.className, 'toolbar');
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
const [label, category, sort, filtersButton] = root.children;
const [label, category, sort, saveButton, filtersButton] = root.children;
assert.equal(label.className, 'search-field');
assert.equal(label.htmlFor, 'search');
assert.deepEqual(tags(label), ['svg', 'input']);
@@ -151,8 +153,12 @@ test('toolbar emits concept markup and order', async () => {
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
assert.equal(sort.value, 'name');
// D16: no Save view until saved views are persisted.
assert.equal(button(root, 'Save view'), undefined);
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
assert.equal(button(root, 'Save view'), saveButton);
assert.equal(saveButton.type, 'button');
saveButton.click();
assert.deepEqual(saves, [saveButton]);
assert.deepEqual(filters, []);
assert.equal(filtersButton.textContent, 'Filters');
assert.equal(filtersButton.type, 'button');
filtersButton.click();
@@ -184,6 +190,9 @@ test('toolbar emits concept markup and order', async () => {
toolbar.setValue({ sort: 'bogus' });
assert.equal(sort.value, 'newest');
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
// Every toolbar page offers Save view, so the callback is required.
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
onChange() {}, onFilters() {} }), TypeError);
} finally {
scope.dispose();
@@ -200,7 +209,7 @@ test('toolbar search uses a debounced onChange that stops with its scope', async
const container = new dom.Element('div');
const changes = [];
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
const input = container.children[0].children[0].children[1];
assert.equal(input.id, 'search');
for (const text of ['c', 'ca', 'cat']) {
@@ -244,7 +253,7 @@ test('sort lists concept options first', async () => {
try {
const container = new dom.Element('div');
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
onChange() {}, onFilters() {} });
onChange() {}, onFilters() {}, onSaveView() {} });
const sort = container.children[0].children[2];
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
@@ -469,6 +478,40 @@ test('copy fallback selects the text', async () => {
}
});
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
test('an input dialog with a fallback applies it for an empty value', async () => {
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const applied = [];
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
open('My workspace view');
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
const body = byId(dom, 'modal-body');
const [field, actions] = body.children;
const [label, input] = field.children;
assert.equal(label.textContent, 'View name');
assert.equal(input.id, 'action-input');
assert.equal(input.value, 'My workspace view');
assert.equal(input.maxLength, 100);
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
input.value = ' ';
actions.children[1].click();
assert.deepEqual(applied, ['Saved view']);
open('');
byId(dom, 'action-input').value = ' Castles ';
byId(dom, 'modal-body').children[1].children[1].click();
assert.deepEqual(applied, ['Saved view', 'Castles']);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
@@ -565,6 +608,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;
@@ -621,8 +705,8 @@ test('the Filters button shows the active refinement count', async () => {
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];
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
const filtersButton = container.children[0].children[4];
// The visible text is the accessible name, so the count is spoken too.
assert.equal(filtersButton.textContent, 'Filters · 2');
toolbar.setActiveFilters(1);
@@ -632,7 +716,7 @@ test('the Filters button shows the active refinement count', async () => {
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);
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), 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,
+3 -1
View File
@@ -375,7 +375,9 @@ 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.
+68 -3
View File
@@ -128,7 +128,12 @@ class TestPromptInspector(ImageAPITestCase):
for expected in ("ambiguous", "unresolved"):
source = graph()
if expected == "ambiguous":
source["4"] = source["3"]
# Branches with different prompts (#249: identical ones resolve).
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}
source["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
else:
source["1"]["class_type"] = "UnknownEncoder"
png(
@@ -1302,6 +1307,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 +1403,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 +1425,8 @@ class TestPromptInspector(ImageAPITestCase):
"title": None,
"category": None,
"tags": [],
"metadata_scope": None,
"category_scope": None,
"tags_scope": None,
},
)
for field in (
+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))
+248
View File
@@ -0,0 +1,248 @@
"""Several sampler branches that share one prompt pair resolve to that pair (#249).
The shape is a LoRA epoch-compare grid: one sampler per LoRA epoch, every
sampler fed by the same positive ``CLIPTextEncode`` and the same
``ConditioningZeroOut`` negative, every decoded image labelled, batched and
concatenated into one grid that a single ``SaveImage`` writes. Which branch
made which tile does not matter to the prompt: every branch used the same pair.
Branches that differ stay ambiguous.
"""
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
POSITIVE = "a woman holding a coffee cup, in a beanie, sitting by a window"
OTHER = "a man reading a newspaper on a park bench"
NEGATIVE = "blurry, low quality"
# Each profile with the negative it can read: inspection reads the zero-out.
PROFILES = {
"default": (resolver.DEFAULT_PROFILE, NEGATIVE),
"inspection": (resolver.INSPECTION_PROFILE, None),
}
def _grid(epochs: int = 4, positives: dict = None, negative: str = None) -> dict:
"""A compare grid; ``positives`` maps an epoch to its own positive text.
The negative is a ``ConditioningZeroOut`` of the positive, or with
``negative`` an encoded text (the default profile cannot read a zero-out).
"""
graph = {
"1": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "base.safetensors", "weight_dtype": "default"},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": "te.safetensors", "type": "stable_diffusion"},
},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
"4": {
"class_type": "CLIPTextEncode",
"inputs": {"text": POSITIVE, "clip": ["2", 0]},
},
"5": (
{"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}}
if negative is None
else {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative, "clip": ["2", 0]},
}
),
"6": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
},
}
batch = {}
for epoch in range(epochs):
base = 10 + epoch * 10
lora, sampler, decode, label = (str(base + offset) for offset in range(4))
positive = "4"
if positives and epoch in positives:
positive = str(base + 5)
graph[positive] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": positives[epoch], "clip": ["2", 0]},
}
graph[lora] = {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"lora_name": f"subject_epoch_{epoch:02d}.safetensors",
"strength_model": 1.0,
"model": ["1", 0],
},
}
graph[sampler] = {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1.0,
"model": [lora, 0],
"positive": [positive, 0],
"negative": ["5", 0],
"latent_image": ["6", 0],
},
}
graph[decode] = {
"class_type": "VAEDecode",
"inputs": {"samples": [sampler, 0], "vae": ["3", 0]},
}
graph[label] = {
"class_type": "AddLabel",
"inputs": {"text": f"epoch {epoch}", "image": [decode, 0]},
}
batch[f"images.image{epoch}"] = [label, 0]
graph["900"] = {"class_type": "BatchImagesNode", "inputs": batch}
graph["901"] = {
"class_type": "ImageConcatFromBatch",
"inputs": {"num_columns": 2, "images": ["900", 0]},
}
graph["902"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "compare/grid", "images": ["901", 0]},
}
return graph
def _texts(result: dict) -> tuple:
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestIdenticalSamplerBranches(unittest.TestCase):
def test_grid_of_identical_branches_resolves_to_the_shared_pair(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (POSITIVE, negative or ""))
# No one branch is named: every branch made this pair.
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
# Every branch stays listed as the evidence for the shared pair.
self.assertEqual(
[item["sampler_id"] for item in result["candidates"]],
["11", "21", "31", "41"],
)
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(POSITIVE, negative or "")},
)
self.assertEqual(result["reason_code"], None)
if negative is None:
self.assertEqual(
result["negative"]["provenance"]["text_source"],
"zeroed-conditioning",
)
def test_default_profile_keeps_an_unreadable_zero_out_grid_ambiguous(self):
# Ingestion cannot read ConditioningZeroOut: every branch is unresolved,
# so the grid stays ambiguous and the scan keeps its legacy fallback.
result = resolve_prompt_pair({"prompt": _grid()})
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertEqual(
{item["state"] for item in result["candidates"]}, {"unresolved"}
)
def test_single_branch_grid_still_names_its_sampler(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair(
{"prompt": _grid(epochs=1, negative=negative)}, profile=profile
)
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["sampler_id"], "11")
self.assertEqual(result["reason"], "")
def test_branches_with_different_prompts_stay_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(positives={2: OTHER}, negative=negative)
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
self.assertIsNone(result["positive"])
self.assertEqual(
sorted(_texts(item)[0] for item in result["candidates"]),
sorted([POSITIVE] * 3 + [OTHER]),
)
def test_a_branch_that_cannot_be_read_keeps_the_grid_ambiguous(self):
for name, (profile, negative) in PROFILES.items():
with self.subTest(profile=name):
source = _grid(negative=negative)
source["31"]["inputs"]["negative"] = ["800", 0]
source["800"] = {"class_type": "MysteryConditioning", "inputs": {}}
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
def test_identical_unreadable_branches_are_not_collapsed(self):
source = _grid(epochs=2)
source["5"] = {"class_type": "MysteryConditioning", "inputs": {}}
for name, (profile, _negative) in PROFILES.items():
with self.subTest(profile=name):
result = resolve_prompt_pair({"prompt": source}, profile=profile)
self.assertEqual(result["state"], "ambiguous")
def test_same_text_with_a_different_recipe_stays_ambiguous(self):
adapters = {
("RecipeText", 0): resolver.OutputAdapter(
"text", "conditioning", compose=True
)
}
source = _grid(epochs=2, negative=NEGATIVE)
source["21"]["inputs"]["positive"] = ["700", 0]
source["700"] = {
"class_type": "RecipeText",
"inputs": {"prepend_text": "a woman", "text": "holding a coffee cup"},
}
alone = resolve_prompt_pair(
{"prompt": source}, output_node_id="21", adapters=adapters
)
# Branch "10" reads the same effective text through a plain encoder.
source["4"]["inputs"]["text"] = alone["positive"]["text"]
result = resolve_prompt_pair({"prompt": source}, adapters=adapters)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
{_texts(item) for item in result["candidates"]},
{(alone["positive"]["text"], NEGATIVE)},
)
self.assertEqual(
{item["positive"]["storage_text"] for item in result["candidates"]},
{alone["positive"]["text"], "holding a coffee cup"},
)
def test_inspector_shows_the_positive_and_an_explicitly_empty_negative(self):
evidence = inspect_prompt_evidence({"prompt": _grid()})
prompts = evidence["prompts"]
self.assertEqual(prompts["positive"]["status"], "resolved")
self.assertEqual(prompts["positive"]["text"], POSITIVE)
self.assertEqual(prompts["negative"]["status"], "resolved")
self.assertEqual(prompts["negative"]["text"], "")
self.assertEqual(prompts["negative"]["source"], "zeroed-conditioning")
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertIsNone(evidence["resolution"]["sampler_id"])
def test_inspector_keeps_differing_grids_ambiguous(self):
evidence = inspect_prompt_evidence({"prompt": _grid(positives={0: OTHER})})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(evidence["prompts"]["positive"]["status"], "ambiguous")
self.assertNotIn(POSITIVE, str(evidence["prompts"]))
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
async def page(self, status=200, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
data = await response.json()
self.assertEqual(response.status, status, data)
return data
async def star(self, path, root_index=0, method="PUT"):
response = await self.client.request(
method, FAVORITE, json={"root_index": root_index, "path": path}
)
self.assertEqual(response.status, 200)
def make(self, *names):
for offset, name in enumerate(names):
target = make_png(self.output_dir / name)
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
async def test_lists_only_favorites_and_counts_them(self):
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
await self.star("a.png")
await self.star("sub/d.png")
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
self.assertEqual(data["total"], 2)
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
# The nav count's read: limit=1 still reports every favorite's total.
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
# Other filters intersect with it.
sub = await self.page(favorites="1", subfolder="sub")
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
# favorites=0 (and no parameter) is the whole listing.
self.assertEqual((await self.page(favorites="0"))["total"], 4)
self.assertEqual((await self.page())["total"], 4)
await self.star("a.png", method="DELETE")
self.assertEqual((await self.page(favorites="1"))["total"], 1)
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
for name in ("a.png", "c.png", "e.png"):
await self.star(name)
expected = {
"newest": ["e.png", "c.png", "a.png"],
"oldest": ["a.png", "c.png", "e.png"],
"name": ["a.png", "c.png", "e.png"],
"name-desc": ["e.png", "c.png", "a.png"],
"rating": ["e.png", "c.png", "a.png"],
}
for sort, names in expected.items():
seen, cursor = [], None
while True:
params = {"favorites": "1", "sort": sort, "limit": "1"}
if cursor:
params["cursor"] = cursor
data = await self.page(**params)
self.assertEqual(data["total"], 3, sort)
seen += [i["path"] for i in data["images"]]
cursor = data["next_cursor"]
if not data["has_more"]:
break
self.assertEqual(seen, names, sort)
# A cursor is bound to its filters: a whole-listing cursor is refused.
whole = await self.page(limit="1")
await self.page(400, favorites="1", cursor=whole["next_cursor"])
async def test_favorites_follow_their_own_root(self):
second = self.comfy_dir / "second"
make_png(second / "a.png")
make_png(self.output_dir / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
await self.star("a.png", root_index=1)
data = await self.page(favorites="1")
self.assertEqual(
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
)
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
async def test_windows_case_folded_favorite_is_listed(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
await self.star("Sub/A.png")
(self.output_dir / "Sub" / "A.png").rename(
self.output_dir / "Sub" / "a.png"
)
(self.output_dir / "Sub").rename(self.output_dir / "sub")
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
self.make("a.png", "b.png")
await self.star("a.png")
await self.star("b.png")
(self.output_dir / "b.png").unlink()
self.api.invalidate_gallery_cache()
data = await self.page(favorites="1")
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
self.assertEqual(data["total"], 1)
async def test_rejects_other_values(self):
for value in ("true", "2", "", "yes"):
data = await self.page(400, favorites=value)
self.assertFalse(data["success"])
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
self.make("a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
self.api.db.get_root_favorites = broken
data = await self.page(500, favorites="1")
self.assertFalse(data["success"])
self.assertNotIn("images", data)
class TestImageFavoriteSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
)
self.assertEqual(db.get_image_favorites("output", []), set())
# One root's favorites, read through the primary key.
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
self.assertEqual(db.get_root_favorites("missing"), set())
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
self.assertTrue(db.clear_image_favorite("output", "a.png"))
+3 -1
View File
@@ -254,7 +254,9 @@ class TestOutcomeMessages(unittest.TestCase):
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
# A second sampler with its own prompt (#249: identical ones resolve).
graph["8"] = _encode("other positive")
graph["5"] = _ksampler("8")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
+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")
+24 -1
View File
@@ -51,11 +51,34 @@ class TestPairImageLinking(unittest.TestCase):
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_parallel_identical_branches_create_their_shared_pair(self):
# #249: two samplers on one positive and one negative are one certain pair.
meta = metadata()
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
self.assertEqual(pairs[0]["image_count"], 1)
self.assertEqual(pairs[0]["run_count"], 0)
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
meta = metadata(positive="dog")
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
meta = metadata()
meta["prompt"]["4"] = meta["prompt"]["3"]
# Two samplers with different positives (#249: identical ones resolve).
meta["prompt"]["5"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": "dog"},
}
meta["prompt"]["4"] = {
"class_type": "KSampler",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
+6 -1
View File
@@ -44,10 +44,15 @@ class TestPairScan(AdminAPITestCase):
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
def test_samplers_sharing_one_prompt_link_that_prompt(self):
# #249: every branch resolves to one exact pair, so the pair is ingested.
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
self.assertEqual(sorted(self._texts()), ["blur", "cat"])
pairs = self.api.db.list_prompt_pairs()["pairs"]
self.assertEqual(
[(p["positive_text"], p["negative_text"]) for p in pairs], [("cat", "blur")]
)
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
# The prompt the inspector and the legacy panels show for this file.
+23 -4
View File
@@ -54,13 +54,30 @@ class TestPromptPairResolver(unittest.TestCase):
self.assertEqual(result["positive"]["text"], "portrait")
self.assertIsNone(result["negative"])
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
def test_multiple_samplers_with_matching_texts_resolve_to_the_shared_pair(self):
source = graph()
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": source["3"]["inputs"],
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "resolved")
self.assertEqual(
(result["positive"]["text"], result["negative"]["text"]),
("portrait", "blurry"),
)
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(len(result["candidates"]), 2)
def test_multiple_samplers_with_different_texts_are_ambiguous(self):
source = graph()
source["5"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "other"}}
source["4"] = {
"class_type": "KSamplerAdvanced",
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
result = resolve_prompt_pair(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), 2)
self.assertIsNone(result["positive"])
@@ -2253,12 +2270,14 @@ class TestDetailerStages(unittest.TestCase):
self.assertEqual(_sides(result), (parity.DETAILER_POS, parity.NEG))
self.assertEqual(result["sampler_id"], "50")
def test_raw_and_detailed_saves_stay_ambiguous_without_an_anchor(self):
def test_raw_and_detailed_saves_of_one_prompt_share_it_without_an_anchor(self):
source = _detailed(same_prompt=True)
source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}}
result = _inspect(source)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], "Multiple sampler branches")
# #249: both saves used one pair, so it resolves with no sampler named.
self.assertEqual(result["state"], "resolved")
self.assertIsNone(result["sampler_id"])
self.assertEqual(result["reason"], resolver.IDENTICAL_BRANCHES_REASON)
self.assertEqual(
sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"]
)
+652
View File
@@ -0,0 +1,652 @@
"""Saved views (D16): the persisted store, its restore schema and the API."""
import json
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
try:
from test_api_images import ImageAPITestCase
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase
from tests.open_handles import assert_closed
from database.models import PromptModel
from database.operations import PromptDatabase
from database.saved_views import (
MAX_STATE_BYTES,
TABLE,
SavedViewInvalid,
encode_state,
migrate_saved_views,
normalize_state,
saved_views_schema_error,
validate_name,
)
from utils.hashing import generate_prompt_hash
URL = "/prompt_manager/saved-views"
IMAGES_STATE = {
"q": "cat",
"category": "Animals",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": ["fluffy"],
"favorites": False,
}
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
IMAGES_DEFAULTS = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
# A row saved by an older build: no favorites key, a retired key, a sort
# that is no longer offered and an over-long folder.
OLD_IMAGES_STATE = json.dumps(
{
"q": "sunset",
"category": "",
"sort": "shuffle",
"source": "all",
"folder": "x" * 2000,
"media": "image",
"tags": ["golden"],
"layout": "grid",
}
)
class TestSavedViewsApi(ImageAPITestCase):
async def send(self, method, path="", body=None, **kwargs):
if body is not None:
kwargs["json"] = body
response = await self.client.request(method, URL + path, **kwargs)
return response, await response.json()
async def create(self, page="images", name="Cats", state=None):
state = dict(
IMAGES_STATE if page == "images" else LIBRARY_STATE, **(state or {})
)
response, data = await self.send(
"POST", body={"page": page, "name": name, "state": state}
)
self.assertEqual(response.status, 201, data)
return data["view"]
async def test_create_list_rename_delete_round_trip(self):
response, data = await self.send("GET")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "views": []})
first = await self.create(name=" Cats ")
self.assertEqual(
first,
{
"id": first["id"],
"page": "images",
"name": "Cats",
"state": IMAGES_STATE,
},
)
second = await self.create(page="library", name="Best", state={})
_, data = await self.send("GET")
self.assertTrue(data["success"])
self.assertEqual(data["views"], [first, second])
response, data = await self.send(
"PATCH", f"/{first['id']}", {"name": " Kittens "}
)
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "view": {**first, "name": "Kittens"}})
_, listed = await self.send("GET")
# Renaming keeps the view's place in the list.
self.assertEqual(
[view["name"] for view in listed["views"]], ["Kittens", "Best"]
)
response, data = await self.send("DELETE", f"/{first['id']}")
self.assertEqual(response.status, 200)
self.assertEqual(data, {"success": True, "id": first["id"]})
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [second])
for method, body in (("PATCH", {"name": "Again"}), ("DELETE", None)):
response, data = await self.send(method, f"/{first['id']}", body)
self.assertEqual(response.status, 404)
self.assertFalse(data["success"])
self.assertIn("error", data)
async def test_views_are_ordered_by_position_and_ids_are_not_reused(self):
views = [await self.create(name=f"View {n}") for n in range(3)]
await self.send("DELETE", f"/{views[2]['id']}")
later = await self.create(name="Later")
self.assertGreater(later["id"], views[2]["id"])
with self.api.db.model.get_connection() as conn:
conn.execute(
f"UPDATE {TABLE} SET position = -1 WHERE id = ?", (views[1]["id"],)
)
_, listed = await self.send("GET")
self.assertEqual(
[view["name"] for view in listed["views"]], ["View 1", "View 0", "Later"]
)
async def test_create_rejects_invalid_bodies(self):
good = {"page": "images", "name": "Cats", "state": IMAGES_STATE}
cases = [
["not an object"],
{"page": "images", "name": "Cats"},
{**good, "extra": 1},
{**good, "page": "history"},
{**good, "page": 1},
{**good, "name": ""},
{**good, "name": " "},
{**good, "name": "x" * 101},
{**good, "name": 7},
{**good, "name": "bad\nname"},
{**good, "state": "q=cat"},
{**good, "state": None},
{**good, "state": {**IMAGES_STATE, "unknown": ""}},
{**good, "state": {k: v for k, v in IMAGES_STATE.items() if k != "q"}},
{**good, "state": {**IMAGES_STATE, "q": 5}},
{**good, "state": {**IMAGES_STATE, "q": "x" * 501}},
{**good, "state": {**IMAGES_STATE, "category": "x" * 101}},
{**good, "state": {**IMAGES_STATE, "sort": "random"}},
{**good, "state": {**IMAGES_STATE, "media": "audio"}},
{**good, "state": {**IMAGES_STATE, "source": "-1"}},
{**good, "state": {**IMAGES_STATE, "source": 0}},
{**good, "state": {**IMAGES_STATE, "folder": None}},
{**good, "state": {**IMAGES_STATE, "folder": "x" * 1025}},
{**good, "state": {**IMAGES_STATE, "tags": "cat"}},
{**good, "state": {**IMAGES_STATE, "tags": [1]}},
{**good, "state": {**IMAGES_STATE, "tags": [" "]}},
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
# Favorites (the Favorites page's filter) is a JSON boolean.
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
{**good, "state": {**IMAGES_STATE, "favorites": None}},
{
**good,
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
},
# Library has no source, folder or media, and its own sorts.
{"page": "library", "name": "L", "state": IMAGES_STATE},
{
"page": "library",
"name": "L",
"state": {**LIBRARY_STATE, "sort": "name-desc"},
},
]
for body in cases:
with self.subTest(body=body):
response, data = await self.send("POST", body=body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
self.assertIsInstance(data["error"], str)
response = await self.client.post(URL, data="{not json")
self.assertEqual(response.status, 400)
self.assertFalse((await response.json())["success"])
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [])
async def test_encoded_state_is_capped(self):
# Each value is within its own limit; together they pass 4 KB.
state = {
**IMAGES_STATE,
"q": "é" * 500,
"folder": "é" * 1024,
"tags": ["é" * 50 for _ in range(20)],
}
encoded = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
self.assertGreater(len(encoded.encode("utf-8")), MAX_STATE_BYTES)
response, data = await self.send(
"POST", body={"page": "images", "name": "Big", "state": state}
)
self.assertEqual(response.status, 400)
self.assertIn("4 KB", data["error"])
async def test_every_documented_value_is_accepted(self):
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
for media in ("all", "image", "video"):
await self.create(state={"sort": sort, "media": media, "source": "3"})
favorite = await self.create(state={"favorites": True})
self.assertIs(favorite["state"]["favorites"], True)
for sort in ("newest", "oldest", "name", "rating"):
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
_, listed = await self.send("GET")
self.assertEqual(len(listed["views"]), 20)
async def test_rename_and_delete_validate_id_and_body(self):
view = await self.create()
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
for method, body in (("PATCH", {"name": "X"}), ("DELETE", None)):
with self.subTest(path=path, method=method):
response, data = await self.send(method, path, body)
self.assertEqual(response.status, 400)
self.assertFalse(data["success"])
for body in ({}, {"name": ""}, {"name": "X", "page": "library"}, ["X"]):
with self.subTest(body=body):
response, _ = await self.send("PATCH", f"/{view['id']}", body)
self.assertEqual(response.status, 400)
_, listed = await self.send("GET")
self.assertEqual(listed["views"], [view])
async def test_database_failure_is_a_500_envelope(self):
def broken():
raise sqlite3.OperationalError("disk I/O error")
self.api.db.list_saved_views = broken
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send("GET")
self.assertEqual(response.status, 500)
self.assertEqual(
data, {"success": False, "error": "Saved view operation failed"}
)
async def test_internal_value_errors_are_a_generic_500(self):
# Only the request's own validation is a 400; a ValueError or
# TypeError raised by the database layer is a server fault (M4).
for error in (
ValueError("secret parser detail"),
TypeError("secret type detail"),
json.JSONDecodeError("secret json detail", "{", 0),
):
def broken(*_args, error=error):
raise error
for method, path, body, attr in (
("GET", "", None, "list_saved_views"),
(
"POST",
"",
{"page": "library", "name": "L", "state": LIBRARY_STATE},
"create_saved_view",
),
("PATCH", "/1", {"name": "X"}, "rename_saved_view"),
("DELETE", "/1", None, "delete_saved_view"),
):
with self.subTest(error=type(error).__name__, method=method):
setattr(self.api.db, attr, broken)
try:
with self.assertLogs("prompt_manager.api", level="ERROR"):
response, data = await self.send(method, path, body)
finally:
delattr(self.api.db, attr)
self.assertEqual(response.status, 500)
self.assertEqual(
data,
{"success": False, "error": "Saved view operation failed"},
)
async def test_a_corrupt_state_is_skipped_by_the_listing(self):
kept = await self.create(page="library", name="Kept", state={})
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Broken', '{', 0, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
self.assertEqual(data["views"], [kept])
# Hidden from the listing, it is not found by rename either.
broken_id = kept["id"] + 1
response, _ = await self.send("PATCH", f"/{broken_id}", {"name": "X"})
self.assertEqual(response.status, 404)
response, _ = await self.send("DELETE", f"/{broken_id}")
self.assertEqual(response.status, 200)
async def test_a_row_saved_under_older_rules_lists_normalized(self):
stale = json.dumps(
{
"q": "cat",
"sort": "random",
"media": "video",
"tags": ["ok", 7],
"retired": True,
}
)
with self.api.db.model.get_connection() as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', ' Old\tview ', ?, 0, 'x', 'x')",
(stale,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', '', '{}', 1, 'x', 'x')"
)
response, data = await self.send("GET")
self.assertEqual(response.status, 200, data)
images, library = data["views"]
self.assertEqual(images["name"], "Oldview")
self.assertEqual(
images["state"],
{**IMAGES_DEFAULTS, "q": "cat", "media": "video"},
)
self.assertEqual(library["name"], "Saved view")
self.assertEqual(library["state"], LIBRARY_DEFAULTS)
# A stale view still renames and deletes.
response, data = await self.send(
"PATCH", f"/{images['id']}", {"name": "Renamed"}
)
self.assertEqual(response.status, 200, data)
self.assertEqual(data["view"]["state"], images["state"])
class TestSavedViewsSchema(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "prompts.db")
self.db = PromptDatabase(self.path)
self.addCleanup(self._cleanup)
def _cleanup(self):
PromptModel.close_all_instances()
assert_closed(self, self.tmpdir)
for name in os.listdir(self.tmpdir):
os.unlink(os.path.join(self.tmpdir, name))
os.rmdir(self.tmpdir)
def _source(self, name="source.db", views=()):
"""A closed database file holding a prompt and the given views."""
path = os.path.join(self.tmpdir, name)
src = PromptDatabase(path)
try:
src.save_prompt(text="kept", prompt_hash=generate_prompt_hash("kept"))
for page, view_name, state in views:
src.create_saved_view(page, view_name, state)
finally:
src.close_all()
return path
def test_current_database_has_a_valid_table(self):
conn = self.db.model.get_connection()
self.assertIsNone(saved_views_schema_error(conn, allow_missing=False))
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_migration_is_idempotent(self):
conn = self.db.model.get_connection()
self.db.create_saved_view("images", "Cats", IMAGES_STATE)
migrate_saved_views(conn)
self.assertEqual(len(self.db.list_saved_views()), 1)
def test_backup_restore_round_trip_keeps_saved_views(self):
self.db.create_saved_view("library", "Live", LIBRARY_STATE)
src_path = self._source(
views=[
("images", "Cats", IMAGES_STATE),
("library", "Best", LIBRARY_STATE),
]
)
# The views may still sit in the WAL; the backup must carry them.
backup = os.path.join(self.tmpdir, "backup.db")
reopened = PromptDatabase(src_path)
try:
self.assertTrue(reopened.model.backup_database(backup))
finally:
reopened.close_all()
previous = self.db.model.restore_from_file(backup)
self.assertEqual(
[
(view["page"], view["name"], view["state"])
for view in self.db.list_saved_views()
],
[("images", "Cats", IMAGES_STATE), ("library", "Best", LIBRARY_STATE)],
)
created = self.db.create_saved_view("images", "After", IMAGES_STATE)
self.assertEqual(self.db.list_saved_views()[-1], created)
with closing(sqlite3.connect(previous)) as conn:
self.assertEqual(
conn.execute(f"SELECT name FROM {TABLE}").fetchall(), [("Live",)]
)
def test_restore_old_backup_without_saved_views(self):
self.db.create_saved_view("images", "Live", IMAGES_STATE)
src = self._source()
with closing(sqlite3.connect(src)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.commit()
self.assertTrue(PromptModel.verify_database_file(src)[0])
ok, _ = PromptModel.verify_database_file(src, require_library_state=True)
self.assertFalse(ok)
self.db.model.restore_from_file(src)
self.assertEqual(self.db.list_saved_views(), [])
self.db.create_saved_view("library", "New", LIBRARY_STATE)
self.assertEqual(len(self.db.list_saved_views()), 1)
with closing(sqlite3.connect(src)) as conn: # the source is untouched
self.assertIsNone(
conn.execute(
"SELECT 1 FROM sqlite_master WHERE name=?", (TABLE,)
).fetchone()
)
def _tampered(self, *statements, views=(("images", "Cats", IMAGES_STATE),)):
src = self._source(
name=f"tampered{len(os.listdir(self.tmpdir))}.db", views=views
)
with closing(sqlite3.connect(src)) as conn:
for statement in statements:
conn.execute(statement)
conn.commit()
return src
def test_restore_rejects_tampered_saved_views(self):
cases = {
"definition": (
f"DROP TABLE {TABLE}",
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, page TEXT, "
"name TEXT, state TEXT, position INTEGER, created_at TEXT, "
"updated_at TEXT)",
),
"index": (f"CREATE INDEX saved_views_extra ON {TABLE}(name)",),
"page": (
"PRAGMA ignore_check_constraints = ON",
f"UPDATE {TABLE} SET page = 'history'",
),
"long name": (f"UPDATE {TABLE} SET name = '{'x' * 401}'",),
"blob name": (f"UPDATE {TABLE} SET name = CAST('Cats' AS BLOB)",),
"state json": (f"UPDATE {TABLE} SET state = '{{'",),
"state array": (f"UPDATE {TABLE} SET state = '[]'",),
"state string": (f"UPDATE {TABLE} SET state = '\"q\"'",),
"state size": (
f"UPDATE {TABLE} SET state = '{'x' * (MAX_STATE_BYTES + 1)}'",
),
"position": (f"UPDATE {TABLE} SET position = 'first'",),
"timestamp": (f"UPDATE {TABLE} SET created_at = X'00'",),
}
for label, statements in cases.items():
with self.subTest(label=label):
src = self._tampered(*statements)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertIn("Saved views", reason)
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def _insert_old_rows(self, path):
"""Rows written under older value rules, straight into the file."""
with closing(sqlite3.connect(path)) as conn:
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('images', 'Golden hour', ?, 5, 'x', 'x')",
(OLD_IMAGES_STATE,),
)
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
)
conn.commit()
def _assert_old_rows_normalized(self, views):
self.assertEqual(
[(view["page"], view["name"], view["state"]) for view in views],
[
(
"images",
"Golden hour",
{
**IMAGES_DEFAULTS,
"q": "sunset",
"media": "image",
"tags": ["golden"],
},
),
("library", "n" * 100, LIBRARY_DEFAULTS),
],
)
def test_a_database_with_rows_from_older_rules_still_opens(self):
self.db.close_all()
self._insert_old_rows(self.path)
PromptModel.reset_schema_cache()
reopened = PromptDatabase(self.path)
try:
self._assert_old_rows_normalized(reopened.list_saved_views())
finally:
reopened.close_all()
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
def test_restore_accepts_rows_from_older_rules(self):
src = self._source()
self._insert_old_rows(src)
ok, reason = PromptModel.verify_database_file(src, require_library_state=True)
self.assertTrue(ok, reason)
self.db.model.restore_from_file(src)
self._assert_old_rows_normalized(self.db.list_saved_views())
def _insert_corrupt_rows(self, path):
"""A valid view between two rows whose state is no JSON object."""
with closing(sqlite3.connect(path)) as conn:
ids = []
for name, state in (
("Broken", "{not json"),
("Kept", json.dumps(LIBRARY_STATE)),
("Array", "[1, 2]"),
):
ids.append(
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, "
"created_at, updated_at) VALUES ('library', ?, ?, 0, "
"'x', 'x')",
(name, state),
).lastrowid
)
conn.commit()
return ids
def test_startup_tolerates_a_corrupt_state_and_list_skips_it(self):
self.db.close_all()
broken, kept, array = self._insert_corrupt_rows(self.path)
PromptModel.reset_schema_cache()
with self.assertLogs("prompt_manager.database", level="WARNING") as logs:
reopened = PromptDatabase(self.path)
try:
self.assertEqual(
[(view["id"], view["name"]) for view in reopened.list_saved_views()],
[(kept, "Kept")],
)
finally:
reopened.close_all()
output = "\n".join(logs.output)
self.assertIn(f"id {broken}", output)
self.assertIn(f"id {array}", output)
self.assertNotIn("not json", output)
# The rows stay in place, untouched.
with closing(sqlite3.connect(self.path)) as conn:
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 3
)
def test_restore_rejects_a_backup_with_a_corrupt_state(self):
src = self._source()
self._insert_corrupt_rows(src)
ok, reason = PromptModel.verify_database_file(src)
self.assertFalse(ok)
self.assertEqual(reason, "Saved views contains invalid values")
with self.assertRaises(ValueError):
self.db.model.restore_from_file(src)
def test_normalize_state_keeps_valid_values_and_defaults_the_rest(self):
self.assertEqual(normalize_state("images", IMAGES_STATE), IMAGES_STATE)
self.assertEqual(normalize_state("library", LIBRARY_STATE), LIBRARY_STATE)
self.assertEqual(
normalize_state("images", json.dumps(IMAGES_STATE)), IMAGES_STATE
)
for raw in (None, "{", "[]", '"q"', 7, ["q"], b"{}"):
with self.subTest(raw=raw):
self.assertEqual(normalize_state("library", raw), LIBRARY_DEFAULTS)
self.assertEqual(
normalize_state(
"images",
{
"q": "bad\x00",
"source": 3,
"favorites": "true",
"tags": [f"t{n}" for n in range(21)],
"folder": "kept",
},
),
{**IMAGES_DEFAULTS, "folder": "kept"},
)
self.assertEqual(normalize_state("history", {}), {})
def test_validation_errors_are_saved_view_invalid(self):
for call in (
lambda: validate_name(""),
lambda: validate_name(3),
lambda: encode_state("history", {}),
lambda: encode_state("library", {"q": ""}),
lambda: encode_state("library", {**LIBRARY_STATE, "sort": "x"}),
):
with self.assertRaises(SavedViewInvalid):
call()
self.assertTrue(issubclass(SavedViewInvalid, ValueError))
def test_migration_refuses_an_unsupported_table(self):
path = os.path.join(self.tmpdir, "foreign.db")
with closing(sqlite3.connect(path)) as conn:
conn.execute(f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY)")
with self.assertRaises(sqlite3.DatabaseError):
migrate_saved_views(conn)
# The savepoint rolled back, so the foreign table is untouched.
self.assertEqual(
conn.execute(
f"SELECT COUNT(*) FROM pragma_table_info('{TABLE}')"
).fetchone()[0],
1,
)
self.assertTrue(Path(path).exists())
if __name__ == "__main__":
unittest.main()
+40 -11
View File
@@ -73,11 +73,41 @@ test('overlay rules on concept selectors carry a decision marker', () => {
assert.deepEqual(violations, []);
});
// 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 ['library.css']) {
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
assert.deepEqual(violations, [], name);
// #249 PR F-b Task 3: every page is rebuilt, so no rule waits for a page PR any more and the history page's
// production-only rules live in the overlay in the concept's tokens (D48). A comment that opens with a page PR
// ("PR F: ...", "PR E/F: ...") or defers to one ("until ... PR", "retired in PR F") is a waiting rule; a provenance
// label inside a heading ("(#249 PR E)") is not.
const WAITING_RULE = [
/\/\*\s*PR [A-F](\/[A-F]|-[ab])?\b/i,
/\buntil\b[^*]*\b(PR [A-F]?|rebuild)/i,
/\bretired in PR\b/i,
/\binterim page guards\b/i,
];
const waitingRules = css => WAITING_RULE.filter(pattern => pattern.test(css)).map(String);
test('the waiting-rule guard catches the retired comments and spares provenance labels', () => {
// The exact comments this PR retired (workspace.css and library.css at 10a4847).
for (const retired of [
'/* 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. */',
'.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */',
'.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */',
' .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */',
'/* Until their pages rebuild (PR F history): the history page\'s and the recorded run\'s prompt blocks keep their',
'/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */',
" keeps the concept's) and are retired in PR F (history). */",
]) assert.notDeepEqual(waitingRules(retired), [], retired);
for (const kept of [
"/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */",
'/* History variants panel (#249 PR F). */',
]) assert.deepEqual(waitingRules(kept), [], kept);
});
test('the overlay keeps no rule waiting for a page rebuild', () => {
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
assert.deepEqual(waitingRules(css), []);
for (const retired of ['.library-chip', '--library-positive', '--library-negative', '--prompt-role']) {
assert.equal(css.includes(retired), false, `${retired} is retired`);
}
});
@@ -100,22 +130,21 @@ test('only workspace.html loads workspace CSS', () => {
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
});
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
// retire, then the production overlay last.
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept), then the production overlay last; the page
// stylesheets that waited for their PRs are all retired (PR F-b deleted the last, library.css).
test('workspace.html loads controls, viewer, concept and the overlay in order', () => {
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
assert.deepEqual(hrefs, [
'js/shared/toolkit/controls.css',
'lib/viewerjs/viewer.min.css',
'js/workspace/concept.css',
'js/workspace/library.css',
'js/workspace/workspace.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']) {
test('the retired shell, vocabulary, editor, Images, inspector and library stylesheets stay deleted', () => {
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css', 'library.css']) {
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
}
});
+6
View File
@@ -305,6 +305,12 @@
"functions": 77,
"statements": 95
},
"workspace/saved-views.mjs": {
"lines": 98,
"branches": 95,
"functions": 95,
"statements": 98
},
"workspace/state.mjs": {
"lines": 98,
"branches": 98,
+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
+46 -5
View File
@@ -17,8 +17,9 @@ ComfyUI, database or configuration dependencies; its only file read is the
inspection profile's node semantics table (``utils.inspection_semantics``).
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
monitor, admin import) keeps the historical behavior exactly, including the
500-node gate, and only adds ``reason_code: None``. ``INSPECTION_PROFILE`` bounds
monitor, admin import) keeps the historical behavior, including the 500-node
gate, and only adds ``reason_code: None``; both profiles resolve several sampler
branches that share one exact pair to that pair (#249). ``INSPECTION_PROFILE`` bounds
work instead of graph size: a per-candidate visit allowance, a total visit budget
shared by every candidate and attribution walk, a node ceiling backstop, and
bounds on built provenance and text. It also names why a result is not resolved
@@ -187,6 +188,8 @@ _MAX_BRANCH_CANDIDATES = 32
_RUNTIME_REASON = "Text is decided at run time"
# Uncoded outcomes the inspector explains by their exact reason.
EMPTY_POSITIVE_REASON = "Positive prompt is empty"
# Several sampler branches, every one resolved to the same pair (#249).
IDENTICAL_BRANCHES_REASON = "Every sampler branch uses the same prompt pair"
MULTISTAGE_UNATTRIBUTED_REASON = "Multistage sampler ancestry is ambiguous"
_INCOMPATIBLE_SLOT = "Output slot has an incompatible type"
# Before 3.2.3 a save patch overwrote every PromptManager node's saved ``text``
@@ -1656,8 +1659,10 @@ def resolve_prompt_pair(
Accepts ``{"prompt": api_graph, "parameters": optional_a1111_text}`` or a
direct API graph. An optional saver/sampler node ID anchors branch selection.
Unanchored multiple samplers are ambiguous, even when their texts are equal
(the inspection profile first drops stages that only reach previews).
Unanchored multiple samplers are ambiguous unless every branch resolves to
the same texts and recipes; then that shared pair is resolved with no
``sampler_id`` (the inspection profile first drops stages that only reach
previews).
``profile`` selects the bounds; explicit ``max_*`` keywords override it.
"""
profile = _profile_with(profile, max_visits, max_depth, max_text_length)
@@ -1781,14 +1786,50 @@ def _resolve(
if not sampler_ids:
return _no_sampler(normalized, profile)
if len(sampler_ids) > 1:
candidates = [resolver.pair(node_id) for node_id in sampler_ids]
shared = _shared_pair(candidates)
if shared is not None:
return shared
return _result(
"ambiguous",
reason="Multiple sampler branches",
candidates=[resolver.pair(node_id) for node_id in sampler_ids],
candidates=candidates,
)
return resolver.pair(sampler_ids[0])
def _recipe_key(result: Dict[str, Any]) -> tuple:
"""What a reader sees of a result, plus each side's stored recipe."""
return _outcome_key(result) + tuple(
result[role]["storage_text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
def _shared_pair(candidates: list) -> Optional[Dict[str, Any]]:
"""The one pair every sampler branch resolved to, or None (#249).
A compare grid runs several samplers on one positive and one negative: the
prompt is certain even though no single branch made the image. Every branch
must be resolved with identical texts and recipes; any other mix stays
ambiguous. No sampler is named, since none alone made the output; the sides
(and their provenance) are the first branch's, and every branch stays listed
in ``candidates`` as the evidence.
"""
first = candidates[0]
if first["state"] != "resolved":
return None
if len({_recipe_key(item) for item in candidates}) != 1:
return None
return _result(
"resolved",
positive=first["positive"],
negative=first["negative"],
reason=IDENTICAL_BRANCHES_REASON,
candidates=candidates,
)
# Bounds for walking a UI workflow's subgraph instances (R1 only).
_MAX_SUBGRAPH_DEPTH = 8
_MAX_WORKFLOW_NODES = 100_000
+34 -6
View File
@@ -212,12 +212,17 @@
return undefined;
}
/** The sampler the legacy parse reads: a custom sampler's guider, else the first sampler. */
function selectSampler(ctx, nodes) {
const custom = nodes.find(isCustomSampler);
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
return { custom, sampler: isSampler(guider) ? guider : nodes.find(isSampler) };
}
function fromPromptGraph(graph, workflow) {
const ctx = createContext(graph, workflow);
const nodes = Object.values(graph);
const custom = nodes.find(isCustomSampler);
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
const sampler = isSampler(guider) ? guider : nodes.find(isSampler);
const { custom, sampler } = selectSampler(ctx, nodes);
if (!sampler) {
const loose = nodes.find((n) => n && n.inputs && /ksampler/i.test(n.class_type || ""));
const inputs = (loose && loose.inputs) || {};
@@ -402,6 +407,26 @@
return merged;
}
/**
* Where `extractGenerationParams` reads its negative from (#249): the graph it used
* (the API graph, else the workflow converted the same way) and the read sampler's
* `negative` link, or null when it found no negative. It changes no reading.
*/
function legacyNegativeSource(comfyData) {
const data = comfyData || {};
const sources = [];
if (data.prompt && typeof data.prompt === "object") sources.push([data.prompt, data.workflow]);
if (data.workflow) sources.push([workflowToGraph(data.workflow), null]);
for (const [graph, workflow] of sources) {
const ctx = createContext(graph, workflow);
const { sampler } = selectSampler(ctx, Object.values(graph));
if (sampler && hasValue(traceConditioning(ctx, sampler.inputs.negative, "negative"))) {
return { graph, link: sampler.inputs.negative };
}
}
return null;
}
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
// These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section
@@ -631,6 +656,8 @@
* until PR3), and unresolved or absent over several listed samplers, merge the same
* way across every listed sampler and its stages. Otherwise (no resolution, or one
* listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous.
* A resolved pair without a named sampler (identical branches, #249) merges like
* ambiguous when several samplers are listed.
*/
function graphFacts(graph, source, resolution) {
if (!object(graph)) return factSet("unresolved", source);
@@ -650,8 +677,9 @@
}
const candidates = entries.filter(([id]) => samplerEntry(graph, id));
// Review I-1: an unresolved or absent prompt does not make the run's settings
// unknown, so several listed samplers merge the same way.
if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
// unknown, so several listed samplers merge the same way. So does a pair the
// server resolved across several branches without naming one (#249).
if (state === "ambiguous" || (["unresolved", "absent", "resolved"].includes(state) && candidates.length > 1)) {
// The server's branches: every listed sampler, a detailer judged with its stages.
const stages = new Set();
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
@@ -758,7 +786,7 @@
return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved");
}
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
const api = { extractGenerationParams, extractGenerationFacts, legacyNegativeSource, PLACEHOLDERS };
// For the semantics sync test only; not part of the legacy page contract.
Object.defineProperty(api, "_factsTables", {
value: Object.freeze({
+4 -3
View File
@@ -206,15 +206,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
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.metadata_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
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, title});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
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.
const filterable = onTagFilter && library.metadata_scope === 'positive';
// 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;}
+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};
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { countOf, noun } from './plural.mjs';
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
*/
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
export const CATEGORY_NONE_LINKED = "None of the selected images is linked to a saved prompt, so there is no prompt category to set.";
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
export const CATEGORY_MAX_LENGTH = 100;
+4
View File
@@ -36,6 +36,10 @@ function queryFor(filters, batchSize) {
const sort = filters.sort ?? 'newest';
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
// The Favorites page (#249 D51): the listing keeps only favorites.
const favorites = filters.favorites ?? false;
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
if (favorites) query.set('favorites', '1');
return query.toString();
}
+67 -19
View File
@@ -13,6 +13,7 @@ import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
@@ -95,8 +96,23 @@ function deliverFavorite(key, favorite) {
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
for (const target of embeddedFavoriteTargets) target(key, favorite);
}
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
const favoriteListeners = new Set();
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
export function onFavoriteChange(listener) {
favoriteListeners.add(listener);
return () => favoriteListeners.delete(listener);
}
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
/**
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit.
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, onSaveView = () => {}} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -109,7 +125,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
const visit = embedded ? null : lastVisit;
const saved = embedded || !savedState ? null : imagesFilters(savedState);
const visit = embedded || saved || favorites ? null : lastVisit;
let density = visit?.density || 'comfortable';
let metrics = {...INITIAL_METRICS};
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
@@ -118,11 +135,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let roots = [], folders = [];
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
favorites: favorites || saved?.favorites === true };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
// The first discovery settled (found or failed): only then may a pending saved scope be kept or dropped (#249 M3).
let discoverySettled = false;
scope.cleanup(() => {
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -153,7 +178,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
Object.assign(filters, next);
reset();
}, onFilters:openFilters});
}, onFilters:openFilters, onSaveView:trigger => {
const value = toolbar.value();
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
}});
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
@@ -192,17 +220,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const body = el('div', undefined, 'gallery-layout image-workspace');
const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
// The concept's caption replaces the old "All images" heading; the embedded gallery keeps its own heading.
// The concept's caption replaces the old "All images" heading.
const caption = el('div', undefined, 'section-caption');
const collectionHeading = embedded ? el('h2', 'Linked images') : null;
if (collectionHeading) { collectionHeading.tabIndex = -1; caption.append(collectionHeading); }
const count = el('span', 'Loading images…');
if (!embedded) count.id = 'result-count';
count.id = 'result-count';
const captionDay = el('span');
captionDay.hidden = true;
caption.append(count);
if (!embedded) caption.append(captionDay);
const focusFallback = collectionHeading || heading?.h1 || main;
caption.append(count, captionDay);
const focusFallback = focusTarget || heading?.h1 || main;
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'infinite-continuation');
@@ -213,10 +238,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const retry = createButton('Retry');
continuation.append(message, more, retry);
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
const empty = el('div', 'No matching images. Try another search or category.', 'empty');
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
const showsEmpty = !embedded || typeof emptyText === 'string';
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
empty.hidden = true;
collection.append(caption, grid);
if (!embedded) collection.append(empty);
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
if (!embedded) collection.append(caption);
collection.append(grid);
if (showsEmpty) collection.append(empty);
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
@@ -355,6 +384,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
favoritePending.delete(key);
deliverFavorite(key, saved ? favorite : !favorite);
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
if (saved) for (const listener of favoriteListeners) listener();
}
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
@@ -508,8 +539,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
state = next;
setText(count, embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next));
if (!embedded) {
setText(count, countLine(next));
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
@@ -524,7 +555,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (!embedded) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
@@ -588,9 +619,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
bar?.clear();
for (const video of details.querySelectorAll('video')) releaseVideo(video);
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
// then lists the view with every current value (#249 M3).
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
function settleSavedScope() {
if (!savedScopePending) return;
savedScopePending = false;
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
}
/** 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;
@@ -739,7 +781,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
updateScope();
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
if (scope.signal.aborted) return;
discoverySettled = true;
renderLoadStatus();
if (savedScopePending) reset();
});
if (visit) {
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
@@ -748,7 +795,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
});
if (visit.state.loading || visit.state.error) stream.loadMore();
} else reset();
} else if (savedScopePending) updateScope();
else reset();
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
function selectedPair() {
const {record, prompts, loading, error, refreshRequired} = inspection.state;
+115 -10
View File
@@ -1,15 +1,16 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading } from './heading.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -31,6 +32,7 @@ function mountWorkspace() {
const api = createApiClient();
const main = document.getElementById('workspace-main');
const primaryNav = document.getElementById('primary-nav');
const savedNav = document.getElementById('saved-nav');
const systemNav = document.getElementById('system-nav');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
@@ -61,6 +63,35 @@ function mountWorkspace() {
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
const sidebar = document.querySelector('aside.sidebar');
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
let currentKey = null;
const markCurrent = () => {
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
};
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
const [favoritesLink] = navLinks([['favorites', 'star']]);
savedNav.replaceChildren(favoritesLink);
const savedViews = createSavedViews(api, scope.signal);
scope.cleanup(savedViews.subscribe(({ views }) => {
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
const link = element('a');
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
link.href = savedViewHash(view.id);
link.dataset.route = `saved/${view.id}`;
return link;
}));
markCurrent();
}));
savedViews.load();
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
onSave: name => savedViews.create(page, name, state) });
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
const countRequests = new Map();
function refreshCounts(ids) {
@@ -71,7 +102,7 @@ function mountWorkspace() {
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (request.signal.aborted) return;
const link = primaryNav.querySelector(`[data-route="${id}"]`);
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
}, [id]);
@@ -80,6 +111,14 @@ function mountWorkspace() {
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
// after a run of quick toggles settles rather than once per click (#249 M1).
let favoriteCountTimer = 0;
scope.cleanup(onFavoriteChange(() => {
clearTimeout(favoriteCountTimer);
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
}));
scope.cleanup(() => clearTimeout(favoriteCountTimer));
refreshCounts(NAV_COUNT_ROUTES);
let viewScope;
let firstRender = true;
@@ -92,10 +131,75 @@ function mountWorkspace() {
images = null;
viewScope = createScope();
const view = viewScope;
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
currentKey = routeKey(route);
markCurrent();
if (route.id === 'saved') {
openSavedView(route, view);
return;
}
renderPage(route, view);
}
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
async function openSavedView(route, view) {
main.replaceChildren();
await savedViews.settled();
if (view.signal.aborted) return;
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
await savedViews.load();
if (view.signal.aborted) return;
}
const saved = savedViews.find(route.savedId);
if (saved) {
const page = routes.find(item => item.id === saved.page);
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
mountEditAction(saved, view);
return;
}
if (savedViews.status === 'ready') {
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
window.location.replace('#/images');
return;
}
const {description} = renderHeading(main, {title:route.label});
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
description.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', () => {
savedViews.load();
openSavedView(route, view);
});
main.append(retry);
}
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
function mountEditAction(saved, view) {
// renderPage has just written the heading.
const edit = createButton('Edit');
pageHeading(main).actions.prepend(edit);
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
onRename: async name => {
const renamed = await savedViews.rename(saved.id, name);
if (view.signal.aborted) return;
saved = renamed;
retitle(renamed.name);
},
onDelete: async () => {
await savedViews.remove(saved.id);
window.location.replace(`#/${saved.page}`);
} }));
}
function retitle(name) {
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
pageHeading(main).h1.textContent = name;
document.getElementById('workspace-breadcrumb').textContent = name;
document.title = `${name} · Prompt Manager`;
}
function renderPage(route, view, saved = null) {
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
@@ -103,13 +207,14 @@ function mountWorkspace() {
if (!firstRender) h1.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images') {
if (route.id === 'images' || route.id === 'favorites') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
onSaveView:saveView('images', view)});
return;
}
if (route.id === 'library') {
mountLibrary(main, api, view, {positiveId:route.positiveId});
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
return;
}
if (route.id === 'tags') {
+64 -3
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');
@@ -172,6 +174,55 @@ function comfyMetadata() {
return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata;
}
// #249: the legacy parse follows ConditioningZeroOut back to its input, so it reads the
// positive text as the negative. Only the negative it actually read is checked: a direct
// zero-out shows the server's zeroed state; a zero-out elsewhere on
// that path, or a subgraph instance whose definition holds one, leaves the negative
// unfilled. Any other negative keeps the legacy fill, so this page never shows less.
const ZERO_OUT = 'ConditioningZeroOut';
const ZEROED_NEGATIVE = Object.freeze({ status: 'resolved', text: '', source: 'zeroed-conditioning' });
const MAX_NEGATIVE_VISITS = 2000;
const MODE_MUTED = 2, MODE_BYPASSED = 4;
const isLink = value => Array.isArray(value) && value.length === 2 && ['string', 'number'].includes(typeof value[0]);
/** Subgraph definition IDs that run a zero-out, directly or through a nested instance. */
function zeroOutSubgraphs(workflow) {
const definitions = Array.isArray(workflow?.definitions?.subgraphs) ? workflow.definitions.subgraphs.filter(object) : [];
const active = item => Array.isArray(item.nodes) ? item.nodes.filter(node => object(node) && ![MODE_MUTED, MODE_BYPASSED].includes(node.mode)) : [];
const found = new Set();
for (let changed = true; changed;) {
changed = false;
for (const item of definitions) {
if (found.has(item.id) || !active(item).some(node => node.type === ZERO_OUT || found.has(node.type))) continue;
found.add(item.id); changed = true;
}
}
return found;
}
/** 'zeroed', 'undecided' (do not fill), or null (the legacy fill applies). */
function zeroOutNegative(source, workflow) {
if (!source || !object(source.graph) || !isLink(source.link)) return null;
const { graph } = source, subgraphs = zeroOutSubgraphs(workflow);
const zeroing = node => node.class_type === ZERO_OUT || subgraphs.has(node.class_type);
if (!Object.values(graph).some(node => object(node) && zeroing(node))) return null;
const node = graph[String(source.link[0])];
if (!object(node)) return 'undecided';
if (node.class_type === ZERO_OUT) return 'zeroed';
// One bounded walk upstream; an index pointer keeps it linear in the nodes visited.
const queue = [node], seen = new Set();
for (let index = 0; index < queue.length; index++) {
const current = queue[index];
if (zeroing(current) || seen.size >= MAX_NEGATIVE_VISITS) return 'undecided';
for (const value of object(current.inputs) ? Object.values(current.inputs) : []) {
const id = isLink(value) ? String(value[0]) : null;
if (id === null || seen.has(id) || !object(graph[id])) continue;
seen.add(id); queue.push(graph[id]);
}
}
return null;
}
function withWorkflowParams(record, parser = comfyMetadata()) {
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
@@ -182,14 +233,24 @@ function withWorkflowParams(record, parser = comfyMetadata()) {
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
const prompts = { ...record.prompts };
const read = prompts.negative?.status === 'resolved' || typeof parser.legacyNegativeSource !== 'function' ? null :
parser.legacyNegativeSource({ prompt: graph, workflow });
const zeroed = zeroOutNegative(read, workflow);
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
const current = prompts[role];
if (!found(key) || current?.status === 'resolved') continue;
if (current?.status === 'resolved') continue;
if (role === 'negative' && zeroed !== null) {
if (zeroed === 'zeroed') prompts.negative = { ...ZEROED_NEGATIVE };
continue;
}
if (!found(key)) continue;
prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' };
}
record.prompts = prompts;
for (const [field, key] of LEGACY_FACTS) {
// Only open answers are filled: a known value or a definite "not recorded" stays.
// An ambiguous field (e.g. a compare grid's per-branch model) is filled too, on
// purpose: the legacy pages show this value, and this page never shows less.
if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) continue;
record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null };
}
-111
View File
@@ -1,111 +0,0 @@
main.library-main { --library-positive: #73dda9; }
.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-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-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; } }
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
/* Role colors and branch geometry follow the approved history surface. */
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-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; }
.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; }
.history-scope summary { cursor: pointer; }
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; }
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); color: #38151b; font-size: 12px; }
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-border); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
.recorded-run-dialog::backdrop { background: #0009; }
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.recorded-run-header h2 { margin: 0; font-size: 20px; }
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
.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: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%; }
.recorded-outputs { margin-block: 24px; }
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
.recorded-receipt > div { margin-block-end: 10px; }
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
@media (max-width: 700px) {
.library-main .library-surface { padding: 22px; }
.recorded-run-dialog { padding: 16px; }
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
}
@media (pointer: coarse) {
.recorded-history :where(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
.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; }
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
}
@media (forced-colors: active) {
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
/* 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 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; }
.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 .library-history .row-actions button { min-block-size: 40px; } }
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } }
+160 -41
View File
@@ -10,9 +10,11 @@ import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './libr
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} from './workspace-dialogs.mjs';
import {libraryFilters,libraryViewState} from './saved-views.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.
@@ -132,7 +134,8 @@ export function mountNewPromptAction(main,api,parentScope) {
return button;
}
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
/** `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view. */
export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,onSaveView=()=>{}}={}) {
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
main.classList.add('library-main');
const sender=createPromptSender({scope});
@@ -143,10 +146,11 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
scope.cleanup(()=>main.classList.remove('library-main'));
function mountShelf() {
const visit=shelfVisit, mountedGeneration=writeGeneration;
const saved=savedState?libraryFilters(savedState):null;
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
// 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.
@@ -154,7 +158,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
// 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});
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
// 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;
@@ -408,7 +413,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
scope.cleanup(()=>{
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;
// A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites).
if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
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();
}
@@ -416,8 +422,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
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);
@@ -460,21 +466,62 @@ 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) {
// The concept's `.compare-item` holds the block itself; the blocks mount in a transparent child (D25).
const item=el('div',undefined,'compare-item'),mount=el('div');item.append(mount);
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
compareBlocks.push(mountPromptBlocks(mount,{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;
@@ -511,13 +558,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;
}
@@ -528,9 +586,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;
@@ -545,22 +606,28 @@ 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();updateGalleryContext();
};
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 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 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);
// The concept's shared gallery (app.js a616-621): its heading names the selected variant; the embedded
// collection renders the PR C media cards. Recorded executions (#209) stay below it.
const context=el('span','','small');context.setAttribute('role','status');
const galleryTitle=el('h2','Shared gallery');galleryTitle.tabIndex=-1;
const galleryHeading=el('div',undefined,'panel-heading');galleryHeading.append(galleryTitle,context);
const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading);
const images=el('div',undefined,'library-linked-images');gallery.append(images);
const recorded=el('div',undefined,'library-recorded-container');
body.append(summary,variants,recorded,gallery);
body.append(summary,variants,gallery,recorded);
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
function rolesFor(pairId) {
@@ -589,7 +656,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';
@@ -608,23 +675,31 @@ 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)});
/** The gallery heading's "<name> · same positive prompt" with the lane's own name, renamed with it (D47). */
// With no saved variant the page shows every image of the positive, a state the concept never reaches.
function updateGalleryContext() {
if(activePairId===undefined) return;
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId);
const archived=activePairId!==null && archivedSelection(activePairId);
context.textContent=activePairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
}
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));
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`;
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));
const archived=pairId!==null && archivedSelection(pairId);
updateGalleryContext();
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();
// An archived pair's images are read in the archived scope; the gallery stays authoritative. The
// embedded collection has no caption: the gallery heading names it (D47).
mountImages(images,api,galleryScope,{embedded:true,focusTarget:galleryTitle,emptyText:pairId===null?undefined:'Saved variant · no runs yet.',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();updateCompare();
}
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
async function rereadSelectedPair() {
@@ -688,7 +763,51 @@ 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;}});
// D47: the dialog's own title names its images, as in the concept (a1282-1304), so the collection has no
// "Linked images" heading or count; a stranded focus returns to the dialog's close button.
mountImages(grid,api,dialogScope,{embedded:true,focusTarget:grid.closest('dialog')?.querySelector('.dialog-header > button') ?? null,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);
+14 -4
View File
@@ -38,8 +38,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`;
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${title ? `: ${title}` : ''}`;
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
// 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 => {
@@ -66,9 +70,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
}
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();
@@ -119,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()};
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Saved views (#249 D16, maintainer ruling 2026-10-21): a named toolbar value plus its "Refine this view" filters, for
* Images or the Prompt library, persisted through `/prompt_manager/saved-views`.
*
* - The toolbar's Save view opens the concept's dialog (app.js a1330–1346 through `simpleInput` a1138): "Save this
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
* accepts is dropped to that key's default, so an opened view applies what it can.
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
*/
import { createButton } from '../shared/toolkit/core.js';
import { openInputDialog, openContentDialog, closeWorkspaceDialog, field } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
export const SAVED_TOAST = 'Saved view added to the sidebar.';
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
export const RENAMED_TOAST = 'Saved view renamed.';
export const DELETED_TOAST = 'Saved view deleted.';
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
export const TOO_LARGE_TOAST = 'The view is too large to save. Shorten its search, folder or tags and try again.';
// The API's cap on a view's encoded state (database/saved_views.py MAX_STATE_BYTES), in UTF-8 bytes of compact JSON.
export const MAX_STATE_BYTES = 4096;
const MAX_NAME_LENGTH = 100;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
const CONTROL = /[\u0000-\u001f\u007f]/;
// The API's whitelist (database/saved_views.py _STATE_FIELDS), with each key's default.
const text = (limit, fallback = '') => ({ fallback, accepts: value => typeof value === 'string' && value.length <= limit && !CONTROL.test(value) });
const choice = (choices, fallback) => ({ fallback, accepts: value => choices.includes(value) });
const tags = { fallback: [], accepts: value => Array.isArray(value) && value.length <= MAX_TAGS && value.every(tag =>
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
const FIELDS = Object.freeze({
images: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
}),
});
export const SAVED_VIEW_PAGES = Object.freeze(Object.keys(FIELDS));
/** The page's full state: every key, each saved value its page accepts, the default otherwise. */
export function normalizeViewState(page, state) {
const fields = FIELDS[page];
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
const value = field.accepts(source[key]) ? source[key] : field.fallback;
return [key, Array.isArray(value) ? [...value] : value];
}));
}
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
export function stateFits(state) {
return new TextEncoder().encode(JSON.stringify(state)).length <= MAX_STATE_BYTES;
}
/** The error `create()` rejects with when the state is over the cap; nothing is sent. */
export class SavedViewTooLarge extends RangeError {
constructor() { super('Saved view state is over 4 KB'); this.name = 'SavedViewTooLarge'; }
}
/** The concept's default name (a1336): `Search: <q>` while there is a search, else "My workspace view". */
export function defaultViewName(q) {
const search = typeof q === 'string' ? q.trim() : '';
return (search ? `Search: ${search}` : 'My workspace view').slice(0, MAX_NAME_LENGTH);
}
/** One listed view, or null when the response row is not a usable view. */
export function parseSavedView(row) {
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !SAVED_VIEW_PAGES.includes(row.page)) return null;
if (typeof row.name !== 'string' || !row.name.trim()) return null;
return { id: row.id, page: row.page, name: row.name, state: normalizeViewState(row.page, row.state) };
}
/** The listing's views in the server's order, skipping any row that is not a usable view. */
export function parseSavedViews(data) {
if (!Array.isArray(data?.views)) throw new TypeError('Invalid saved views');
return data.views.map(parseSavedView).filter(Boolean);
}
/** The route of one view. */
export const savedViewHash = id => `#/saved/${id}`;
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
export function imagesFilters(state) {
const view = normalizeViewState('images', state);
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
tags: view.tags, favorites: view.favorites };
}
export function imagesViewState(filters) {
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
}
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
export function libraryFilters(state) {
const view = normalizeViewState('library', state);
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags };
}
export function libraryViewState(filters) {
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags });
}
/**
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
*/
export function createSavedViews(api, signal) {
let views = [], status = 'loading', request = null;
const listeners = new Set();
let waiting = [];
const emit = () => {
const snapshot = { views, status };
for (const listener of listeners) listener(snapshot);
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
};
function load() {
request?.abort();
const controller = new AbortController();
request = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
status = 'loading';
emit();
return api.get(SAVED_VIEWS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
if (request !== controller) return;
views = parseSavedViews(data);
status = 'ready';
}).catch(() => {
if (request !== controller) return;
status = 'error';
}).finally(() => {
signal?.removeEventListener('abort', abort);
if (request !== controller || signal?.aborted) return;
request = null;
emit();
});
}
async function create(page, name, state) {
const full = normalizeViewState(page, state);
if (!stateFits(full)) throw new SavedViewTooLarge();
const data = await api.post(SAVED_VIEWS_URL, { page, name, state: full }, { signal });
const view = parseSavedView(data?.view);
if (!view) throw new TypeError('Invalid saved view');
await load();
return view;
}
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
async function change(send) {
try { return await send(); } finally { await load(); }
}
const viewUrl = id => {
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
return `${SAVED_VIEWS_URL}/${id}`;
};
function rename(id, name) {
const url = viewUrl(id);
return change(async () => {
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
if (!view) throw new TypeError('Invalid saved view');
return view;
});
}
function remove(id) {
const url = viewUrl(id);
return change(async () => {
try {
await api.del(url, { signal });
} catch (error) {
// Already gone (another tab deleted it): the outcome the user asked for.
if (error?.status !== 404) throw error;
}
return id;
});
}
return {
load, create, rename, remove,
get views() { return views; },
get status() { return status; },
find: id => views.find(view => view.id === id) ?? null,
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ views, status }),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/**
* The concept's Save view (a1330–1346): the name dialog, then the save. `onSave(name)` returns the save's promise;
* the toast reports either outcome.
*/
export function openSaveViewDialog({ scope, trigger, q, onSave }) {
openInputDialog({ scope, trigger, title: 'Save this view', label: 'View name', action: 'Save', value: defaultViewName(q),
maxLength: MAX_NAME_LENGTH, fallback: 'Saved view', onApply: async name => {
try {
await onSave(name);
showWorkspaceToast(SAVED_TOAST);
} catch (error) {
if (!scope.signal.aborted) showWorkspaceToast(error instanceof SavedViewTooLarge ? TOO_LARGE_TOAST : SAVE_FAILED_TOAST);
}
} });
}
/**
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
* outcome, and the shell navigates after a delete.
*/
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
const input = document.createElement('input');
input.value = view.name;
input.maxLength = MAX_NAME_LENGTH;
const remove = createButton('Delete…', 'danger');
const cancel = createButton('Cancel');
const save = createButton('Save', 'ca-primary');
const actions = document.createElement('div');
actions.className = 'dialog-actions';
actions.append(remove, cancel, save);
cancel.addEventListener('click', () => closeWorkspaceDialog());
save.addEventListener('click', async () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
closeWorkspaceDialog();
if (name === view.name) return;
try {
await onRename(name);
showWorkspaceToast(RENAMED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
}
});
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
input.focus();
input.select();
}
function confirmDelete({ scope, trigger, view, onDelete }) {
const text = document.createElement('p');
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
const actions = document.createElement('div');
actions.className = 'dialog-actions';
const cancel = createButton('Cancel');
const confirmButton = createButton('Delete', 'danger');
actions.append(cancel, confirmButton);
cancel.addEventListener('click', () => closeWorkspaceDialog());
confirmButton.addEventListener('click', async () => {
closeWorkspaceDialog();
try {
await onDelete();
showWorkspaceToast(DELETED_TOAST);
} catch {
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
}
});
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
cancel.focus();
}
+22 -8
View File
@@ -9,11 +9,16 @@ export const routes = [
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
{ id: 'overview', label: 'Overview' },
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
{ id: 'favorites', label: 'Favorites' },
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview']);
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']);
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
export function routeTitle(route) {
@@ -26,6 +31,8 @@ export function isHandoff(route) {
}
export function resolveRoute(hash) {
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
if (match && Number.isSafeInteger(Number(match[1]))) {
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
@@ -42,14 +49,16 @@ export function summarizeStats(stats) {
});
}
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
// Favorites page's own listing, so its count is what that page lists.
const NAV_COUNT_SOURCES = Object.freeze([
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
['library', '/prompt_manager/library?limit=1'],
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
]);
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
export function summarizeNavCounts(pages = {}) {
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
const total = pages?.[id]?.total;
@@ -58,16 +67,21 @@ export function summarizeNavCounts(pages = {}) {
}
/**
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
*/
/** The WORKSPACE routes that carry a count. */
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
/** The count a confirmed favorite change can change. */
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
export const FAVORITE_COUNT_DELAY_MS = 300;
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
const pages = {};
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
@@ -96,7 +110,7 @@ export function mountRouter(win, onRoute) {
const route = resolveRoute(win.location.hash);
const hash = route.hash || `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current?.id === route.id && current?.positiveId === route.positiveId) return;
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId) return;
current = route;
onRoute(route);
};
+11 -9
View File
@@ -1,9 +1,9 @@
/**
* 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); `Filters` reads
* "Filters · N" while N "Refine this view" filters are active (D44).
* `select#sort`, `Save view`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options
* first and a page's extra ones after them (D14); `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';
@@ -55,16 +55,16 @@ 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";
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
* value after a select change or, debounced, after typing; `onSaveView(trigger)` opens the page's Save view dialog;
* `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 = {}, activeFilters = 0, onChange, onFilters }) {
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters, onSaveView }) {
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
throw new TypeError('The toolbar needs the page scope');
}
if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
throw new TypeError('The toolbar needs onChange and onFilters');
if (typeof onChange !== 'function' || typeof onFilters !== 'function' || typeof onSaveView !== 'function') {
throw new TypeError('The toolbar needs onChange, onFilters and onSaveView');
}
const root = document.createElement('search');
root.className = 'toolbar';
@@ -91,8 +91,9 @@ 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 saveView = createButton('Save view');
const filters = createButton(filtersLabel(activeFilters));
root.append(label, category, sort, filters);
root.append(label, category, sort, saveView, filters);
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
function setValue(next) {
@@ -114,6 +115,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
scope.listen(category, 'change', report);
scope.listen(sort, 'change', report);
scope.listen(saveView, 'click', () => onSaveView(saveView));
scope.listen(filters, 'click', () => onFilters(filters));
scope.cleanup(() => root.remove());
+31 -9
View File
@@ -11,7 +11,9 @@
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
* action; used by the Images selection bar's "Set category" (#249 D8).
* action; used by the Images selection bar's "Set category" (#249 D8) and the toolbar's Save view.
* - 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.
*/
@@ -30,7 +32,8 @@ function el(tag, text, className) {
return node;
}
function field(label, control, id) {
/** A labelled `.field` row: `label` names `control`, which takes `id` (shared with the saved-view Edit dialog). */
export function field(label, control, id) {
const wrapper = el('div', undefined, 'field');
const caption = el('label', label);
caption.htmlFor = id;
@@ -69,9 +72,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 +101,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();
@@ -262,15 +269,18 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
/**
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
* A positive `maxLength` limits the field.
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
requireScope(scope);
requireCallbacks({ onApply });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
if (fallback !== undefined && (typeof fallback !== 'string' || !fallback.trim())) {
throw new TypeError('The input dialog fallback must be text');
}
const input = el('input');
input.value = typeof value === 'string' ? value : '';
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
@@ -278,7 +288,7 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());
apply.addEventListener('click', () => {
const entered = input.value.trim();
const entered = input.value.trim() || fallback;
if (!entered) { input.focus(); return; }
finish();
onApply(entered);
@@ -287,3 +297,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 });
}
+129 -43
View File
@@ -137,18 +137,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
#viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
}
/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text
that keep their own design until their page PR rebuilds them in concept markup. */
.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 */
.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. */
@@ -177,38 +166,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #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
@@ -216,6 +173,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space
.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 D25: each compared negative's block sits in the concept's `.compare-item` (app.js a600), so its mount point is
transparent and adds no margin. */
.compare-item > .prompt-inspector { display: contents; /* D25 */ }
/* #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 */ }
@@ -224,3 +187,126 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space
/* #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 */ }
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's
`min-inline-size: 0` is load-bearing: it keeps cards out of the concept's ≤700px `1fr` track's min-content. */
.library-main .library-grid { overflow-anchor: none; /* D3 */ }
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; /* D3 */ }
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; /* D3 */ }
/* #249 D40: the shelf's and history's production-only load, failure and continuation states. */
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; /* D40 */ }
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); /* D40 */ }
/* History variants panel (#249 PR F). */
/* D45: the panel's actions wrap their labels at 390px and 200% zoom (the toolkit's buttons never wrap). */
.ca-tools .library-main .library-history .row-actions button { max-inline-size: 100%; white-space: normal; /* D45 */ }
/* 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; /* D45 */ }
/* 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; /* D45 */ }
.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; /* D45 */ }
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; /* D45 */ } }
/* 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 */ }
/* 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; /* D45 */ }
.ca-tools .library-history .timeline-lower > button { max-inline-size: 100%; white-space: normal; /* D45 */ }
@media (pointer: coarse) {
/* 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; }
}
/* #249 D48: production surfaces the concept never shows keep their own rules, in the concept's tokens: recorded
executions and the Recorded run dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
prompt sender (spec §2.2). The dialog rules name `dialog` so they win over the concept's `.ca-tools dialog`. */
/* D48: recorded executions (#209) below the shared gallery. */
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; /* D48 */ }
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; /* D48 */ }
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; /* D48 */ }
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; /* D48 */ }
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; /* D48 */ }
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); /* D48 */ }
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; /* D48 */ }
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--green); color: var(--green); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; /* D48 */ }
.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(--green); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; /* D48 */ }
.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--green); /* D48 */ }
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; /* D48 */ }
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--red) 60%, transparent); /* D48 */ }
.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(--red); border-block-end: 2px solid var(--red); border-end-start-radius: 22px; /* D48 */ }
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; /* D48 */ }
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; /* D48 */ }
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; /* D48 */ }
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); /* D48 */ }
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--red); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; /* D48 */ }
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--red); color: #38151b; font-size: 12px; /* D48 */ }
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; /* D48 */ }
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; /* D48 */ }
/* D48: the Recorded run dialog (#209, D26). */
.ca-tools dialog.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border-radius: 7px; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; /* D48 */ }
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; /* D48 */ }
.recorded-run-header h2 { font-size: 20px; /* D48 */ }
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; /* D48 */ }
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; /* D48 */ }
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; /* D48 */ }
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; /* D48 */ }
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; /* D48 */ }
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; /* D48 */ }
.recorded-run-dialog :where(.muted:not(.prompt-text), .recorded-output-status) { font-size: 11px; line-height: 1.65; /* D48 */ }
/* D48: the current file's prompt blocks sit below the dialog's evidence line (elsewhere their mount point is `display: contents`). */
.recorded-run-dialog .prompt-inspector { margin-block-start: 22px; /* D48 */ }
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; /* D48 */ }
.recorded-outputs { margin-block: 24px; /* D48 */ }
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; /* D48 */ }
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; /* D48 */ }
.recorded-receipt > div { margin-block-end: 10px; /* D48 */ }
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; /* D48 */ }
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; /* D48 */ }
/* D48: the frozen field provenance and the Archive confirmation show stored text in `pre.prompt-text`, not as the
concept's code `pre`. */
pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-block: 10px; background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; /* D48 */ }
/* D48: the Archive negative confirmation's negative block (#235). */
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; /* D48 */ }
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; /* D48 */ }
.prompt-block.prompt-negative h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--red); /* D48 */ }
.prompt-empty { font-size: 10px; /* D48 */ }
/* D48: the acknowledged prompt sender (prompt-sender.mjs) and its labelled pair send button. */
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); /* D48 */ }
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; /* D48 */ }
.ca-tools dialog.prompt-sender { inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; /* D48 */ }
.ca-tools dialog.prompt-sender::backdrop { background: #000b; /* D48 */ }
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; /* D48 */ }
.prompt-sender-header h2 { font-size: 20px; /* D48 */ }
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; /* D48 */ }
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; /* D48 */ }
.prompt-sender select { inline-size: 100%; min-block-size: 38px; /* D48 */ }
.prompt-sender-status { font-size: 13px; line-height: 1.6; /* D48 */ }
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); /* D48 */ }
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; /* D48 */ }
.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%; /* D48 */ }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; /* D48 */ }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; /* D48 */ }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; /* D48 */ }
@media (max-width: 780px) {
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); /* D48 */ }
}
@media (max-width: 700px) {
.ca-tools dialog.recorded-run-dialog { padding: 16px; /* D48 */ }
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); /* D48 */ }
}
@media (pointer: coarse) {
/* D48: the recorded toolbar, its scope disclosure and the dialog's controls keep 44px targets (concept coarse buttons are 40px). */
.ca-tools .recorded-history :is(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
.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(--red); 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(--red); }
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
}
@media (forced-colors: active) {
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
+3 -1
View File
@@ -8,7 +8,6 @@
<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/library.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>
@@ -22,6 +21,9 @@
<a class="brand" href="#/images"><span class="brand-mark">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
<div class="sidebar-label">WORKSPACE</div>
<nav id="primary-nav" aria-label="Workspace"></nav>
<!-- D49: the concept's SAVED VIEWS group without its "PROPOSED" badge (saved views are persisted, #249 D16). -->
<div class="sidebar-label">SAVED VIEWS</div>
<nav id="saved-nav" aria-label="Saved views"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>