diff --git a/TESTING.md b/TESTING.md
index b7f56fa..e77e01e 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -292,8 +292,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
-chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
-own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
+chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
+tags first, D88, then the linked tags; image tags and the linked positive's own tags
+filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
content without the cover, then "File & provenance" (the honest file facts, the
@@ -769,8 +770,12 @@ Recorded shell deviations:
"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
- slice.
+ On the Images cards (#249 C2) a card's info row stacks its text above Info and Tag
+ once the row is narrower than the narrowest bounded card (120px; a list row's info
+ is what its art leaves), and the two buttons wrap when even that row cannot hold
+ them (`stacksCardInfo`, `tests/js/workspace-image-layout.test.js`).
+- **#245 D2 (retired, #249 C2)**: the Tags heading shows the concept's "Auto-tag images";
+ it opens the Tag image dialog (D115 when nothing is selected).
- **Brand name**: not a deviation. The brand link's accessible name matches the
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
either. The inventory compares `brand.name` and `brand.mark.hidden`.
@@ -793,13 +798,15 @@ zoom, the 2nd card opens the viewer titled "
· ", 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),
-D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
+D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
sorted alphabetically). Removed toolbar and filter-row items flow again from the
concept's own boxes (`reflow`), checked against the concept page with those items
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
unit per card above in the column (D3). `one footer height for the collection (D3)`
-seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
-that every row reserves the tallest footer.
+seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
+`short_seedless` so their "96 × 72 · PNG" subtitle stays on one line beside Info and
+Tag) and asserts that every row reserves the tallest footer and that their own footer
+is shorter than the row it reserves.
### Images page controls and recorded deviations (#249 PR C)
@@ -885,7 +892,9 @@ Recorded deviations on this page (spec §8):
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
-- **D9**: no Tag button until the image-annotations slice.
+- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or
+ auto-tag ") after Info; it opens the Tag image dialog without selecting
+ the card (see "Tag image dialog" below).
- **D10**: the card title is the linked positive's display title, else the filename;
the `h3` keeps the relative path in `title`.
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
@@ -943,6 +952,8 @@ Recorded deviations on this page (spec §8):
history appear only with resolved prompt roles or a saved link; a video file keeps
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
+ Amended by D88 (#249 PR C): the file's own current image tags lead the same row
+ and are filters too; a linked tag already shown (case-insensitive) is left out.
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
@@ -1049,6 +1060,186 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
+### Tag image dialog and recorded deviations (#249 PR C)
+
+Every Images card and every Library-embedded gallery card shows Info then the
+concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog
+(`web/js/workspace/image-tagging.mjs` over the C1 controller
+`image-tagging-state.mjs`) in the shared modal shell with the concept's
+`tagging-modal` class, so its `.dialog-actions` stay sticky while the content
+scrolls. Focus starts on × and returns to the Tag button. The preview is the
+capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a
+`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel,
+Save, a route change (the shell closes its dialog) and `pagehide`, which closes the
+dialog (a page restored from the back/forward cache shows no dead dialog); the
+release `DELETE` and the Save `PUT` are sent with `keepalive` (`ApiClient`'s
+additive option), so they finish while the page unloads. A save still in flight
+when the dialog closes keeps its capture until the `PUT` settles, then releases it
+and still reports the outcome: the saved toast and every page's refresh, or the
+failure as a toast. Generate is explicit and uses the server's real defaults
+(`GET /prompt_manager/autotag/models`), and the fields show exactly those values
+(they are re-rendered when the observation lands and on a model change, never while
+typing); an invalid field reports native validity and sends nothing. A tag conflict
+shows the current set at once: the status line appends "Saved now: ." and the
+manual field holds the current set ahead of the draft (repeats dropped), so the next
+Save sends what is shown against the current revision. Save writes the manual
+names, then the remaining chips, deduplicated case-insensitively (first spelling
+wins), and toasts the mock's copy verbatim, " tags saved for ." even
+for one tag, plus " Prompt association still awaits review." from Needs review.
+Tagging one file never changes another file (an equal-byte copy included), a
+prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
+changed storage or file, expired capture, unconfirmed save, unavailable model,
+video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
+actions while the controller allows it, where the other workspace dialogs place
+their Retry. The Tags
+page re-reads its lists after every confirmed save, so the Images counts follow.
+
+The image-tagging plan's deviation numbers were renumbered by +23 because the
+Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 → D91).
+
+- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
+ checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
+ checked-row tokens (accent border, `--soft-blue`), each with the name, the
+ confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove
+ ". A chip has the concept `.chip`'s 4px corner (the later `.tag-suggestion`
+ row rule would give 5px) around the row's own 12px text. At most the requested
+ count, all preselected. While chips exist, ruling T4's "Remove anything you do not
+ want to add." (`p.small`) leads them inside `#tag-suggestions`; removing the last
+ chip shows "All suggestions removed. Generate again or enter tags manually." The
+ opening sentence is the concept's on-screen copy, "Generate suggestions with the
+ selected model before approving.": the concept's markup carries another ("Choose a
+ model… Uncheck anything…"), but its `renderTagModelOptions` (a1062–1076) replaces it
+ as soon as the dialog opens, so the opened state is compared unmapped.
+- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four
+ engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
+ MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
+ mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
+ from the server's `default_prompt`.
+- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
+ every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
+ change keeps it.
+- **D88** (plan D65, ruling T7/C2): the inspector's chip row (side panel and viewer
+ details, after the prompt blocks) shows the file's current image tags first (the
+ B3 envelope's `image_tags`, `state: "current"`, S1), then D21's linked tags not
+ already shown (case-insensitive, the image tag's spelling wins). In the Images
+ panel every image tag is a Tags filter `button.chip` (the filter matches image
+ tags too) and linked tags keep D21's rule; in the viewer, and wherever no Tags
+ filter exists (the Library-embedded gallery, Activity and recorded runs), every
+ chip is a plain `span.chip`. `none` and `unavailable` add no chips and no other
+ chrome; a malformed facet fails the inspection like any malformed field. The
+ concept has one row of the image's own tags (`i.tags`, app.js a402), so with the
+ concept image's tags saved as its image tags production still equals the concept.
+ After a Tag save the selected file's inspection is re-read, so the panel redraws
+ the row without reselecting and the viewer, which shares that inspection, shows it.
+- **D115** (beyond the plan's list): the concept always has a selected image, so
+ its "Auto-tag images" always opens the dialog. Production opens it for the image
+ selected in Images when it was last left; with none it is `aria-disabled` and
+ pressing it toasts "Select an image in Images first." (no new chrome).
+- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
+ primary) while a failed observation, a failed Generate, an unconfirmed save or an
+ answer that needs a fresh observation (changed storage or file, expired capture)
+ can be retried; the concept has no failure states, so it has no Retry. After a
+ failed Generate it takes Generate's own path (an invalid field reports its
+ validity); an unconfirmed save resends the identical body; after a changed storage,
+ file or expired capture it observes again and keeps the typed draft (when the saved
+ set changed meanwhile, the current set leads the draft and the status line says
+ "The tags of this image changed since the dialog opened. Saved now: .").
+ Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
+ Retry after an expired capture observes again and keeps the typed draft (M5)" and
+ the paired `production-unknown-outcome` capture.
+- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
+ simulated…" note, the status reads "Suggestions from . Approve tags to save."
+ (not "Sample suggestions from …"), and the toast has no " in this tab".
+
+After a confirmed save the selected file's inspection is re-read quietly: the panel
+and the viewer keep the current details until the new answer lands, with no "Loading
+file details…" in between (`createInspectionController().refresh()`). While a Tags
+filter or a search is active the saved file may start or stop matching, so Images
+re-reads its loaded range in place (`createImageStream().refresh()`: the same query
+from the first page until the loaded count, published once). The cards, the scroll
+position and the selection stay while the selected file still matches; when it no
+longer does, the panel follows the collection (its first file), as after a reload,
+and the selection bar keeps only the checked files still listed, so its count is what
+Set category and Export metadata apply to. A refresh never runs beside a page load
+already in flight: asked during one, it is pending and runs once that page settles
+(success or failure; a reset drops it), and Load more waits for a running refresh
+(it neither loads nor moves focus). The re-read asks for pages as large as the rest of
+the loaded range (at most 200 files, at least one batch), one after another (each
+cursor comes from the page before); a failed re-read toasts "Images could not be
+refreshed after the tag save. Refresh image collection to retry." and keeps the
+cards. A save made while Images is left (Tags › Auto-tag images) marks a saved Images
+visit with a Tags filter or a search stale: returning lists it again with its
+filters instead of restoring the old cards. Browser tests: "the post-save re-read
+keeps the panel content until the new read lands", "a save under a Tags filter
+re-reads the listing: …", "a save under a search keeps the scroll position and the
+loaded range", "a failed listing re-read after a save says so and keeps the cards
+(M2)" and "a tag save from Tags reloads a filtered Images visit instead of restoring
+it (M6)"; the pending refresh and the page sizes are unit-tested.
+
+Concept comparison (`tests/browser/tagging-concept.spec.mjs` with
+`tagging-canonical-comparison.mjs`, plan Task C4): production runs on the concept
+fixture with the concept image's own tags (`cinematic, blue_hour, architecture`)
+saved as its image tags, and the fake model answers the concept's own sample
+(`/__test__/tagging/suggestions`: mountain 0.98, observatory 0.95, snow 0.88,
+blue_hour 0.81), so the dialog's inventory (order, text, field attributes, tokens,
+preview box, chips, status, sticky actions) must equal the concept's at 1600×1100 and
+390×844, dark and light: opened, generated with WD14 and generated with a language
+model, plus the empty result (the concept above its own threshold), the review-scoped
+dialog with its scope note and the card's Info and Tag group (M1). `mapConceptTagging`
+applies only D29, D83, D84 and D91; a mutation check (the model panel's border
+removed, the primary relabelled) shows the comparison notices a change. Every
+production-versus-concept difference of PR C and its record:
+
+| Difference | Record |
+| --- | --- |
+| Removable chips with × instead of checked checkbox rows; "Remove anything…" while chips exist; the all-removed copy | D83 (ruling T4) |
+| Legacy engine labels and values, GGUF in the Ollama slot, server default instructions | D84 (rulings T1/T2) |
+| Suggestions count field | D91 (ruling T3-bulk) |
+| No prototype note, "Suggestions from …", no " in this tab" | D29 |
+| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
+| Auto-tag images `aria-disabled` without a selected image | D115 |
+| Retry leads the actions when the controller allows it | D116 (ruling T5) |
+| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
+
+Native image tagging (#249 PR B routes, PR C UI): `POST
+/prompt_manager/image-annotations/observe` (capture one listed file, its saved set and
+`tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/`
+(the preview and its release), `POST /prompt_manager/image-annotations/suggest` (the
+gated top-N suggestions, at most the requested count) and `PUT
+/prompt_manager/image-annotations/tags` (re-hash, then save one byte record's set).
+Cheap reads follow spec amendment S1 (the verified `(size, mtime_ns)` fingerprint;
+residual risk above, under "Image annotations schema"). No automated test downloads
+or loads a model: the browser server injects `TestModel`, whose `download_model`
+raises, and every tagging spec asserts no `/autotag/download/` request. Capture
+limits are those of the capture store above (256 MiB per file, 8 completed captures,
+512 MiB reserved, 900 s idle, 1800 s hard). The browser fixtures release every capture
+on reset. PR C is HTML, CSS and JS only (no restart); the PR B routes it calls need a
+maintainer ComfyUI restart once.
+
+Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
+`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and
+pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
+no model files; every test asserts no download request),
+`tests/js/workspace-image-tagging.test.js`,
+`tests/js/workspace-image-tagging-state.test.js`,
+`tests/js/workspace-images-lifecycle.test.js`, and for D88
+`tests/js/workspace-file-inspector.test.js`,
+`tests/js/workspace-inspection-state.test.js` and `tests/browser/inspector-concept.spec.mjs`
+("image tags saved on the concept image keep the inspector equal to the concept"), and
+for the comparison `tests/browser/tagging-concept.spec.mjs` and
+`tests/js/workspace-images-state.test.js` (the in-place refresh), and on all four
+projects `tests/browser/workspace-tagging-lifecycle.spec.mjs` (leaving the page with
+the dialog open releases the capture with no console error; closing during a save
+releases it after the save and still reports it).
+
+```sh
+node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
+npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs tagging-concept.spec.mjs \
+ workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch
+npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec.mjs tagging-concept.spec.mjs \
+ --project firefox --project webkit
+```
+
### Prompt library cards and recorded deviations (#249 PR E)
The shelf uses the shared concept toolbar and caption (E1) and the concept's
@@ -1747,8 +1938,8 @@ page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
-distinctions are documented in the comparison helper. The Images card tag
-button (#249 D9) remains outside #209; every prompt block now renders the concept's
+distinctions are documented in the comparison helper. The gallery cards carry the
+concept's Tag button beside Info (#249 C2 retired D9); 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
@@ -1912,7 +2103,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
-Images shown as "—" with hidden "Not available yet", and Actions with Browse and
+Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
Rename / merge), and a Categories panel whose category rows end with the Manage
category row (Review). Lists load 100 rows at a time, append when scrolled into view
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
@@ -1953,8 +2144,7 @@ npm run --prefix tools/vendor test:workflows -- \
--project chromium --project touch --project firefox --project webkit
```
-Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
-button and an Images "—" cell until the image-annotation slice; bounded lists with
+Recorded deviations from the concept (maintainer acceptance): bounded lists with
Load more; title "Tags"; Manage categories adds a Category select and Preview before
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index 0bc9dfb..862ad30 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -78,6 +78,23 @@ export async function seedConcept(request,data={}) {
return response.json();
}
+/**
+ * Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
+ * routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
+ */
+export async function saveImageTags(request,path,tags) {
+ const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
+ const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path};
+ const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json();
+ expect(observed.success).toBe(true);
+ try {
+ const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token,
+ sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json();
+ expect(receipt.success).toBe(true);
+ return receipt.tags;
+ } finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);}
+}
+
async function selectTheme(page,theme) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
}
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index a22fb3a..f1268b1 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -92,7 +92,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
const inspector=page=>page.locator('.detail-panel');
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
-const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
+const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).click();
async function fullFixture(page,request) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
@@ -460,7 +460,7 @@ test('full inspector labels container representations and releases action URLs',
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
await fullFixture(page,request);const panel=inspector(page);
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
- await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
+ await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
await selectFile(page,'control-heavy.png');
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
@@ -637,7 +637,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
// #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();
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('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').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:');
});
@@ -930,7 +930,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
- await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
+ await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).click();await openCollection(page);
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
});
diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs
index e7c829c..9e1ae70 100644
--- a/tests/browser/history-canonical-comparison.mjs
+++ b/tests/browser/history-canonical-comparison.mjs
@@ -17,7 +17,6 @@ export const canonicalDifferences = {
// 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 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',
],
};
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
index 309badc..e7e219c 100644
--- a/tests/browser/images-concept.spec.mjs
+++ b/tests/browser/images-concept.spec.mjs
@@ -59,12 +59,12 @@ function mapBulkbar(bulkbar,rise) {
}
/**
- * A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
+ * A list row's info with its actions `actionsWidth` wide, from the concept's boxes:
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
- * - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
+ * - the text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
*/
function listInfo(card,actionsWidth) {
const {info}=card,{copy,p}=info;
@@ -109,21 +109,16 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const cards=reference.cards.map(card=>{
const filename=card.open.label.replace(/^Open /,'');
const actions=card.info.actions;
- const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
- // D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
- const freed=tag?tag.width+parseFloat(actions.columnGap):0;
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
- const actionsWidth=actions.width-freed;
- const listRow=list?listInfo(card,actionsWidth):{};
+ const listRow=list?listInfo(card,actions.width):{};
return {...card,
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
h3:{...card.info.h3,title:paths.get(filename)??null},
- actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
- buttons:actions.buttons.filter(button=>button!==tag)}}};
+ actions:{...actions,left:listRow.actionsLeft??actions.left}}};
});
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
// D35: the count line uses the singular for one.
@@ -538,8 +533,9 @@ test('one image appended by scrolling reads in the singular',async({page,request
});
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
-// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
-// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
+// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line: a short "96 × 72" beside Info and Tag)
+// form the first row at 390px, where every concept subtitle wraps: their row is as tall as the wrapped rows, and every
+// concept row moves down by one.
test('one footer height for the collection (D3)',async({page,request})=>{
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
test.setTimeout(120_000);
@@ -548,7 +544,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
const reference=await inventoryOf(page,'#main');
expect(reference.grid.columns).toBe(2);
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
- const manifest=await seedConcept(request,{seedless_images:2});
+ const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
const actual=await inventoryOf(page,'#workspace-main');
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
@@ -557,7 +553,10 @@ test('one footer height for the collection (D3)',async({page,request})=>{
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
const seedless=actual.cards.slice(0,2);
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
- expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
+ expect(seedless.map(card=>card.info.p.text)).toEqual(['96 × 72 · PNG','96 × 72 · PNG']);
// Their own footer is one line shorter than the row it reserves.
- for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
+ for(const card of seedless) {
+ expect(card.info.p.height).toBeLessThanOrEqual(parseFloat(card.info.p.lineHeight)+0.5);
+ expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
+ }
});
diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs
index 951c833..1c8f77e 100644
--- a/tests/browser/inspector-concept.spec.mjs
+++ b/tests/browser/inspector-concept.spec.mjs
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
-import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
+import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -319,6 +319,27 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
+// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
+// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
+// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
+for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
+ test.setTimeout(120_000);
+ const viewport={width,height};await page.setViewportSize(viewport);
+ await openConceptPanel(page,'dark');
+ const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
+ expect(names).toEqual(['cinematic','blue_hour','architecture']);
+ await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
+ const reference=await inventory(page,true);
+ const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
+ expect(await saveImageTags(request,path,names)).toEqual(names);
+ const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
+ await openProductionPanel(page,'dark');
+ expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
+ await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
+ await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
+ assertInspectorMatch(reference,await inventory(page),viewport);
+});
+
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
diff --git a/tests/browser/server.py b/tests/browser/server.py
index ae67018..4ff976d 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -35,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
register_library_editor_commands,
)
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
+from tests.browser.workspace_tagging_fixture import register_tagging_commands
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
@@ -57,11 +58,19 @@ class TestModel:
wd14_character_threshold = 0.85
custom_prompt = ""
loaded = None
+ # Models the Tag image dialog workflows mark as not downloaded.
+ unavailable: set = set()
+ # Scored names the concept comparisons answer instead of the fixed list
+ # (``/__test__/tagging/suggestions``); None keeps the fixed list.
+ suggestions: list | None = None
def get_models_status(self) -> dict:
return {
- name: {"downloaded": True, "loaded": self.loaded == name}
- for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
+ name: {
+ "downloaded": name not in self.unavailable,
+ "loaded": self.loaded == name,
+ }
+ for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
}
def is_model_loaded(self) -> bool:
@@ -85,6 +94,11 @@ class TestModel:
) -> list[tuple[str, float | None]]:
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
scored = self.loaded is not None and self.loaded.startswith("wd14")
+ if self.suggestions is not None:
+ return [
+ (name, confidence if scored else None)
+ for name, confidence in self.suggestions[:limit]
+ ]
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
names += [f"tag_{index:02d}" for index in range(100)]
return [
@@ -178,6 +192,7 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
+ register_tagging_commands(routes, api, root, model)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
@@ -471,7 +486,8 @@ def create_app(root: Path) -> web.Application:
if (
request.query
or not isinstance(data, dict)
- or set(data) - set(limits)
+ or set(data) - set(limits) - {"short_seedless"}
+ or not isinstance(data.get("short_seedless", False), bool)
or any(
isinstance(data.get(key, 0), bool)
or not isinstance(data.get(key, 0), int)
@@ -490,6 +506,7 @@ def create_app(root: Path) -> web.Application:
root,
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
+ short_seedless=data.get("short_seedless", False),
)
)
return web.json_response({"success": True, **manifest})
diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs
index 8249311..a4076d7 100644
--- a/tests/browser/shell-concept.spec.mjs
+++ b/tests/browser/shell-concept.spec.mjs
@@ -119,10 +119,7 @@ function mapShellConcept(reference) {
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
})(),
- },
- heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
- // #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
- buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
+ }};
}
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs
new file mode 100644
index 0000000..d9ff0fd
--- /dev/null
+++ b/tests/browser/tagging-canonical-comparison.mjs
@@ -0,0 +1,142 @@
+// Concept-vs-production inventory of the Tag image dialog (plan Mock inventory M2: docs/concepts/workspace/app.js
+// tagReview a1037–1061, renderTagModelOptions a1062–1076, generateTags a1077–1135; style.css s1598–1694) and of the
+// card's Info and Tag group (M1: app.js mediaCard a398, style.css s1589–1597). The *Of functions run in the page.
+// The concept is never rewritten: mapConceptTagging applies only the recorded deviations, each named with its D-number
+// or ruling; everything else must be equal.
+export {diffInventory as diffTaggingInventory} from './library-editor-canonical-comparison.mjs';
+
+/** The open Tag image dialog (`dialog#modal`), its controls in order with their concept tokens. */
+export function taggingInventory(dialog) {
+ const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
+ const px=value=>Math.round(value*2)/2;
+ const box=node=>{const rect=node.getBoundingClientRect();return {width:px(rect.width),height:px(rect.height)};};
+ const font=node=>css(node,'fontSize','fontWeight','lineHeight','color','marginTop','marginBottom');
+ const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
+ 'borderTopLeftRadius','backgroundColor');
+ const buttonOf=node=>({text:node.textContent.trim(),label:node.getAttribute('aria-label'),primary:node.classList.contains('ca-primary'),
+ icon:node.querySelector('svg')!==null,disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,
+ ...css(node,'minHeight','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')});
+ const controlOf=node=>({tag:node.tagName.toLowerCase(),id:node.id,type:node.type??null,value:node.value,
+ min:node.getAttribute('min'),max:node.getAttribute('max'),step:node.getAttribute('step'),
+ describedBy:node.getAttribute('aria-describedby'),
+ options:node.tagName==='SELECT'?[...node.options].map(option=>[option.value,option.textContent]):null,
+ ...css(node,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft')});
+ const fieldOf=field=>{const label=field.querySelector('label'),control=field.querySelector('input,textarea,select');
+ return {label:label.textContent,labelled:label.htmlFor===control.id,...css(field,'display','rowGap','marginTop','marginBottom'),control:controlOf(control)};};
+ const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button');
+ const body=dialog.querySelector('#modal-body');
+ const heading=body.querySelector('.tag-image-heading');
+ const preview=heading.querySelector('.tag-image-preview'),copy=heading.querySelector(':scope > div');
+ const sizeChip=copy.querySelector('.chip');
+ const note=body.querySelector('.tag-scope-note');
+ const manual=body.querySelector('#manual-image-tags'),manualField=manual.closest('.field'),help=body.querySelector('#manual-tags-help');
+ const panel=body.querySelector('.tag-model-panel'),options=panel.querySelector('#image-tag-model-options');
+ const generate=[...panel.querySelectorAll('button')].at(-1);
+ const suggestions=body.querySelector('section.tag-suggestion-panel');
+ const list=suggestions.querySelector('#tag-suggestions'),status=suggestions.querySelector('#tag-generation-status');
+ const count=suggestions.querySelector('#suggestion-count');
+ const actions=body.querySelector('.dialog-actions');
+ // A suggestion is a concept checkbox row (`label.tag-suggestion`) or a production chip (`span.chip.tag-suggestion`).
+ const rows=[...list.querySelectorAll('.tag-suggestion')].map(row=>{
+ const name=row.querySelector('span:not(.tag-confidence)'),confidence=row.querySelector('.tag-confidence');
+ const checkbox=row.querySelector('input[type=checkbox]'),remove=row.querySelector('button');
+ return {name:name.textContent,confidence:confidence.textContent,
+ control:checkbox?{kind:'checkbox',checked:checkbox.checked}:remove?{kind:'remove',label:remove.getAttribute('aria-label')}:null,
+ ...css(row,'display','alignItems','borderTopColor','backgroundColor','borderTopWidth','borderTopStyle','borderTopLeftRadius','fontSize','paddingTop','paddingLeft'),
+ confidenceToken:css(confidence,'color','fontSize')};
+ });
+ return {
+ dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','backgroundColor','color','overflowY'),
+ width:px(dialog.getBoundingClientRect().width),modal:dialog.matches(':modal'),tagging:dialog.classList.contains('tagging-modal'),
+ labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title,
+ noHorizontalOverflow:dialog.scrollWidth<=dialog.clientWidth+1},
+ header:{title:{text:title.textContent,...font(title)},close:{label:close.getAttribute('aria-label'),text:close.textContent.trim()}},
+ demo:body.querySelector('.demo-note')?.textContent??null,
+ order:[...body.children].map(node=>node.className),
+ heading:{...css(heading,'display','columnGap','alignItems','marginBottom'),
+ preview:{tag:preview.tagName.toLowerCase(),alt:preview.getAttribute('alt'),...box(preview),...css(preview,'objectFit','borderTopLeftRadius','borderTopWidth','borderTopStyle','borderTopColor','flexShrink')},
+ h3:{text:copy.querySelector('h3').textContent,...font(copy.querySelector('h3'))},
+ filename:{text:copy.querySelector('p.small').textContent,...font(copy.querySelector('p.small'))},
+ size:sizeChip?{text:sizeChip.textContent,...css(sizeChip,'marginTop','fontSize','borderTopColor','borderTopLeftRadius','paddingLeft')}:null},
+ scopeNote:note?{text:note.textContent,...edges(note),...css(note,'borderLeftWidth','borderLeftStyle','borderLeftColor','fontSize','color')}:null,
+ manual:{...fieldOf(manualField),help:{text:help.textContent,...font(help)},described:help.id===manual.getAttribute('aria-describedby')},
+ modelPanel:{...edges(panel),...css(panel,'marginTop'),h3:{text:panel.querySelector('h3').textContent,...font(panel.querySelector('h3'))},
+ fields:[...panel.querySelectorAll('.field')].map(fieldOf),
+ twoCol:options.querySelector('.two-col')?{columns:getComputedStyle(options.querySelector('.two-col')).gridTemplateColumns.split(' ').length,
+ ...css(options.querySelector('.two-col'),'display','columnGap')}:null,
+ generate:buttonOf(generate)},
+ suggestions:{labelled:suggestions.getAttribute('aria-labelledby')===suggestions.querySelector('h3').id,...css(suggestions,'marginTop'),
+ title:{text:suggestions.querySelector('h3').textContent,...font(suggestions.querySelector('h3'))},
+ count:{text:count.textContent,...font(count)},
+ copy:list.querySelector(':scope > p.small')?.textContent??null,
+ rows,
+ status:{text:status.textContent,role:status.getAttribute('role'),...css(status,'marginTop','fontSize','color')}},
+ actions:{...css(actions,'position','bottom','marginLeft','marginRight','marginBottom','paddingTop','paddingLeft','backgroundColor',
+ 'borderTopWidth','borderTopStyle','borderTopColor','zIndex','display','justifyContent','columnGap'),
+ buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
+ noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
+ };
+}
+
+/** The first image card's Info and Tag group (M1), with its card's filename. */
+export function cardActionsInventory(card) {
+ const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
+ const info=card.querySelector('.media-info'),actions=info.querySelector(':scope > .image-card-actions');
+ return {
+ info:css(info,'display','justifyContent','alignItems','columnGap'),
+ actions:{...css(actions,'display','alignItems','columnGap','flexShrink','minWidth'),lastInInfo:info.lastElementChild===actions},
+ buttons:[...actions.querySelectorAll(':scope > button')].map(node=>{const rect=node.getBoundingClientRect();return {
+ className:node.className,label:node.getAttribute('aria-label'),icon:node.querySelector('svg')?.innerHTML.replace(/\s+/g,' ').trim()??null,
+ width:Math.round(rect.width),height:Math.round(rect.height),
+ ...css(node,'minWidth','minHeight','paddingLeft','paddingTop','borderTopLeftRadius','borderTopColor','backgroundColor','color')};}),
+ };
+}
+
+// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
+const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
+ ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
+const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV2 · ~400 MB','WD14 ViT · visual tags':'WD14 ViT · ~400 MB',
+ 'Ollama · local language model':'GGUF JoyCaption (Recommended) · ~4.5 GB','HuggingFace · language model':'HuggingFace JoyCaption · ~16 GB'});
+
+/**
+ * The concept inventory with the recorded deviations applied:
+ * - no prototype note (Global Constraints: `Prototype · suggestions are simulated…` is not shipped);
+ * - D83 (ruling T4): while suggestions exist, "Remove anything you do not want to add." leads them, and every
+ * suggestion is a chip with a `Remove ` × instead of a checked checkbox row: the concept's own `.chip` corner
+ * (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
+ * row's colours (accent border, `--soft-blue`);
+ * - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
+ * - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
+ * (`numberField`: the concept's General confidence field) with the configured count (`limit`);
+ * - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
+ * The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
+ * model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
+ */
+export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
+ const [select,...own]=reference.modelPanel.fields;
+ const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
+ value:String(limit),min:'1',max:'100',step:'1'}};
+ const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
+ const rows=reference.suggestions.rows.map(row=>({...row,
+ control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
+ borderTopLeftRadius:chip.borderTopLeftRadius}));
+ const copy=rows.length&&reference.suggestions.copy===null?'Remove anything you do not want to add.':reference.suggestions.copy;
+ const status=reference.suggestions.status.text.replace(/^Sample suggestions from (.+)\. Approve tags to save\.$/,
+ (_,label)=>`Suggestions from ${CONCEPT_LABELS[label]??label}. Approve tags to save.`);
+ return {...reference,
+ demo:null,
+ order:reference.order.filter(name=>name!=='demo-note'),
+ modelPanel:{...reference.modelPanel,
+ fields:[{...select,control:{...select.control,value:MODEL_LABELS[select.control.value]?.[0]??select.control.value,
+ options:select.control.options.map(([value])=>MODEL_LABELS[value])}},...mappedOwn,suggestionsField]},
+ suggestions:{...reference.suggestions,copy,rows,status:{...reference.suggestions.status,text:status}},
+ };
+}
+
+/**
+ * Production's own chip geometry beyond the concept's `.chip` (D83): the chip's padding hugs its × button. Only the
+ * leaves D83 replaces are dropped from both sides; names, confidences, colours, radius and font stay compared.
+ */
+export function withoutChipGeometry(inventory) {
+ return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}};
+}
diff --git a/tests/browser/tagging-concept.spec.mjs b/tests/browser/tagging-concept.spec.mjs
new file mode 100644
index 0000000..ac15236
--- /dev/null
+++ b/tests/browser/tagging-concept.spec.mjs
@@ -0,0 +1,307 @@
+// The Tag image dialog and the card's Info and Tag group against the approved concept (#249 PR C, plan Task C4, Mock
+// inventory M1 and M2). Production runs on the concept-shaped fixture with the concept image's own tags saved as its
+// image tags and the injected fake model answering the concept's own sample (alpine: mountain 98%, observatory 95%,
+// snow 88%, blue_hour 81%), so every remaining difference is a recorded deviation applied by mapConceptTagging
+// (D29, D83, D84, D91) or a test failure. No model is ever downloaded: every test checks no download request was sent.
+import { test, expect } from './fixtures.mjs';
+import { seedConcept, openConcept, openProduction, saveImageTags, mutate, SIZES, THEMES } from './concept-inventory.mjs';
+import { taggingInventory, cardActionsInventory, mapConceptTagging, diffTaggingInventory, withoutChipGeometry } from './tagging-canonical-comparison.mjs';
+
+const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
+// The concept's first image (app.js images[0]) and its sample suggestions (generateTags, scene "alpine").
+const CONCEPT_FILE = 'alpine_00001.png';
+const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture'];
+const ALPINE = [['mountain', 0.98], ['observatory', 0.95], ['snow', 0.88], ['blue_hour', 0.81]];
+// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84.
+const INSTRUCTIONS = 'Describe the image';
+const CONCEPT_MODEL = { gguf: 'ollama' };
+
+const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
+const firstCard = (page, side) => page.locator(side === 'concept' ? '#image-grid > article.media-card' : '#workspace-main article[data-image-key]').first();
+
+test.beforeEach(async ({ page, request }) => {
+ const manifest = await seedConcept(request);
+ expect(manifest).toBeTruthy();
+ await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
+ page.downloads = [];
+ page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
+ page.errors = [];
+ page.on('pageerror', error => page.errors.push(error.message));
+});
+test.afterEach(async ({ page, request }) => {
+ await leave(page);
+ expect(page.downloads).toEqual([]);
+ expect(page.errors).toEqual([]);
+ await request.post('/__test__/tagging/suggestions', { data: { scored: null } });
+});
+
+async function conceptPath(request) {
+ const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
+ const image = listing.images.find(item => item.filename === CONCEPT_FILE);
+ expect(image).toBeTruthy();
+ return image.path;
+}
+
+/**
+ * Close an open Tag dialog before opening the next page, so each comparison starts from a settled page. Leaving with
+ * the dialog open is covered on its own (workspace-tagging-lifecycle.spec.mjs: the keepalive release on pagehide).
+ */
+async function leave(page) {
+ if (await tagDialog(page).count()) {
+ await page.keyboard.press('Escape');
+ await expect(tagDialog(page)).toHaveCount(0);
+ }
+}
+/** Open `side`'s Tag image dialog from its first card, settled (production: the capture preview and saved tags). */
+async function openTagDialog(page, side, theme) {
+ await leave(page);
+ if (side === 'concept') await openConcept(page, 'images', theme);
+ else await openProduction(page, 'images', theme);
+ await firstCard(page, side).getByRole('button', { name: `Tag or auto-tag ${CONCEPT_FILE}`, exact: true }).click();
+ await expect(tagDialog(page)).toBeVisible();
+ await settled(page, side);
+}
+async function settled(page, side) {
+ if (side === 'production') {
+ await expect(tagDialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
+ await expect(tagDialog(page).locator('#manual-image-tags')).toHaveValue(CONCEPT_TAGS.join(', '));
+ }
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await Promise.all([...document.querySelectorAll('#modal img')].map(image => image.decode().catch(() => {})));
+ });
+}
+async function generateWith(page, side, model) {
+ const select = tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
+ if (model) await select.selectOption(side === 'concept' ? CONCEPT_MODEL[model] ?? model : model);
+ await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
+ await expect(tagDialog(page).locator('#suggestion-count')).not.toHaveText('Not generated');
+}
+// The pointer rests outside the dialog, so no control is measured in its hover state.
+const inventory = async page => {
+ await page.mouse.move(0, 0);
+ return page.locator('dialog#modal').evaluate(taggingInventory);
+};
+
+/** The concept and production inventories of one dialog state, the concept mapped (D29, D83, D84, D91). */
+async function compareState(page, { theme, act = async () => {} }) {
+ await openTagDialog(page, 'concept', theme);
+ const conceptOpened = await inventory(page);
+ await act(page, 'concept');
+ const concept = await inventory(page);
+ await openTagDialog(page, 'production', theme);
+ await act(page, 'production');
+ const actual = await inventory(page);
+ const numberField = conceptOpened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
+ const reference = mapConceptTagging(concept, { instructions: INSTRUCTIONS, chip: concept.heading.size, numberField });
+ return { reference: withoutChipGeometry(reference), actual: withoutChipGeometry(actual) };
+}
+
+for (const [width, height] of SIZES) for (const theme of THEMES) {
+ test(`concept and production Tag dialog match at ${width}x${height} ${theme}`, async ({ page, request }) => {
+ test.setTimeout(90_000);
+ await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
+ await page.setViewportSize({ width, height });
+ // Opened, generated with WD14 (the concept's default), then generated with a language model.
+ for (const [state, act] of [
+ ['opened', async () => {}],
+ ['generated WD14', (page, side) => generateWith(page, side)],
+ ['generated GGUF', (page, side) => generateWith(page, side, 'gguf')],
+ ]) {
+ const { reference, actual } = await compareState(page, { theme, act });
+ expect(diffTaggingInventory(reference, actual), `${state} at ${width}x${height} ${theme}`).toEqual([]);
+ }
+ // Pinned concept geometry (M2), so a mapping mistake on both sides cannot pass unseen.
+ const { actual } = await compareState(page, { theme, act: (page, side) => generateWith(page, side) });
+ const small = width <= 700;
+ expect([actual.heading.preview.width, actual.heading.preview.height]).toEqual(small ? [86, 86] : [112, 100]);
+ expect(actual.heading.columnGap).toBe('16px');
+ expect([actual.modelPanel.paddingTop, actual.modelPanel.borderTopLeftRadius]).toEqual(['16px', '6px']);
+ expect(actual.actions.position).toBe('sticky');
+ expect(actual.suggestions.rows.map(row => [row.name, row.confidence, row.control.label])).toEqual(
+ ALPINE.map(([name, confidence]) => [name, `${Math.round(confidence * 100)}%`, `Remove ${name}`]));
+ const accent = await page.evaluate(() => {
+ const probe = document.createElement('i');
+ probe.style.cssText = 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)';
+ document.body.append(probe);
+ const style = getComputedStyle(probe), tokens = [style.borderTopColor, style.backgroundColor];
+ probe.remove();
+ return tokens;
+ });
+ for (const row of actual.suggestions.rows) expect([row.borderTopColor, row.backgroundColor]).toEqual(accent);
+ expect(actual.dialog.noHorizontalOverflow).toBe(true);
+ expect(actual.noPageScroll).toBe(true);
+ });
+}
+
+test('an empty result matches the concept below its threshold', async ({ page, request }) => {
+ await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
+ await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
+ const { reference, actual } = await compareState(page, { theme: 'dark', act: async (page, side) => {
+ // The concept filters its sample by the General confidence; above every sample nothing is shown.
+ if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
+ await generateWith(page, side);
+ if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.35');
+ } });
+ expect(actual.suggestions.copy).toBe('No suggestions met this threshold. Adjust it or enter tags manually.');
+ expect(diffTaggingInventory(reference, actual)).toEqual([]);
+});
+
+test('review-scoped dialog shows the scope note', async ({ page, request }) => {
+ const path = await conceptPath(request);
+ await saveImageTags(request, path, CONCEPT_TAGS);
+ await page.setViewportSize({ width: 1600, height: 1100 });
+ await openConcept(page, 'review', 'dark');
+ await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
+ await expect(tagDialog(page)).toBeVisible();
+ await settled(page, 'concept');
+ const opened = await inventory(page);
+ // Needs review ships with PR F; until then the review source opens the same dialog from the module.
+ await leave(page);
+ await openProduction(page, 'images', 'dark');
+ await page.evaluate(async ({ module, path }) => {
+ const { openImageTagging } = await import(module);
+ const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
+ const api = window.ApiClient.createApiClient();
+ const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
+ openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
+ }, { module: MODULE, path });
+ await expect(tagDialog(page)).toBeVisible();
+ await settled(page, 'production');
+ const actual = await inventory(page);
+ expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.');
+ expect(actual.order).toEqual(['tag-image-heading', 'tag-scope-note small', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
+ const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
+ const reference = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField });
+ expect(diffTaggingInventory(reference, actual)).toEqual([]);
+});
+
+for (const [width, height] of SIZES) test(`card action group matches M1 at ${width}x${height}`, async ({ page }) => {
+ await page.setViewportSize({ width, height });
+ await openConcept(page, 'images', 'dark');
+ const reference = await firstCard(page, 'concept').evaluate(cardActionsInventory);
+ await openProduction(page, 'images', 'dark');
+ const actual = await firstCard(page, 'production').evaluate(cardActionsInventory);
+ expect(actual.buttons.map(button => button.label)).toEqual([`Inspect ${CONCEPT_FILE}`, `Tag or auto-tag ${CONCEPT_FILE}`]);
+ expect(diffTaggingInventory(reference, actual)).toEqual([]);
+});
+
+test('the comparison notices a changed panel border or primary label', async ({ page, request }) => {
+ await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
+ const { reference, actual } = await compareState(page, { theme: 'dark' });
+ expect(diffTaggingInventory(reference, actual)).toEqual([]);
+ const undo = await mutate(page, { css: '.tag-model-panel { border: 0 !important; }' });
+ const borderless = withoutChipGeometry(await inventory(page));
+ expect(diffTaggingInventory(reference, borderless).map(item => item.path)).toEqual(
+ expect.arrayContaining(['modelPanel.borderTopWidth', 'modelPanel.borderTopStyle']));
+ await undo();
+ await tagDialog(page).locator('.dialog-actions .ca-primary').evaluate(node => { node.textContent = 'Save'; });
+ const relabelled = withoutChipGeometry(await inventory(page));
+ expect(diffTaggingInventory(reference, relabelled).map(item => item.path)).toEqual(['actions.buttons.1.text']);
+});
+
+// Paired captures for the PR (no screenshot baselines): every production state beside the concept's nearest one. The
+// states the concept lacks (unavailable model, saving, conflict, file changed) pair with the concept's opened dialog.
+test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
+ test.setTimeout(120_000);
+ const path = await conceptPath(request);
+ await saveImageTags(request, path, CONCEPT_TAGS);
+ await page.setViewportSize({ width: 1600, height: 1100 });
+ const shot = async name => {
+ await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
+ const file = testInfo.outputPath(`${name}.png`);
+ await page.screenshot({ path: file });
+ await testInfo.attach(name, { path: file, contentType: 'image/png' });
+ };
+ const both = async (name, act) => {
+ for (const side of ['concept', 'production']) {
+ await openTagDialog(page, side, 'dark');
+ await act(page, side);
+ await shot(`${side}-${name}`);
+ }
+ };
+ await both('opened', async () => {});
+ await both('generated-wd14', (page, side) => generateWith(page, side));
+ await both('generated-gguf', (page, side) => generateWith(page, side, 'gguf'));
+ await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
+ await both('empty', async (page, side) => {
+ if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
+ await generateWith(page, side);
+ });
+ await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
+ const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
+ const status = () => tagDialog(page).locator('#tag-generation-status');
+
+ // Unavailable model.
+ await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
+ await openTagDialog(page, 'production', 'dark');
+ await generateWith(page, 'production').catch(() => {});
+ await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
+ await shot('production-unavailable');
+ await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
+
+ // Saving: the write is held until the capture is taken.
+ let release;
+ const held = new Promise(resolve => { release = resolve; });
+ const writes = url => url.pathname === '/prompt_manager/image-annotations/tags';
+ await page.route(writes, async route => { await held; await route.continue(); });
+ await openTagDialog(page, 'production', 'dark');
+ const sent = page.waitForRequest(item => writes(new URL(item.url())));
+ await save();
+ await sent;
+ await expect(tagDialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
+ await shot('production-saving');
+ release();
+ await expect(tagDialog(page)).toHaveCount(0);
+ await page.unroute(writes);
+
+ // Conflict: another writer saved this file's set after the dialog observed it.
+ await openTagDialog(page, 'production', 'dark');
+ await saveImageTags(request, path, ['changed_elsewhere']);
+ await save();
+ await expect(status()).not.toHaveText('');
+ await shot('production-conflict');
+ await page.keyboard.press('Escape');
+
+ // File changed: the server's own envelope for bytes replaced after the preview (B1 `file-changed`).
+ await page.route(writes, route => route.fulfill({ status: 409, contentType: 'application/json',
+ body: JSON.stringify({ success: false, code: 'file-changed', error: 'The file changed since it was previewed. Reopen the Tag dialog.' }) }));
+ await saveImageTags(request, path, CONCEPT_TAGS);
+ await openTagDialog(page, 'production', 'dark');
+ await save();
+ await expect(status()).not.toHaveText('');
+ await shot('production-file-changed');
+ await page.unroute(writes);
+ await page.keyboard.press('Escape');
+
+ // Unconfirmed save (a 504): D116's Retry leads the actions, before Cancel and the disabled primary.
+ await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
+ await openTagDialog(page, 'production', 'dark');
+ await save();
+ await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
+ expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
+ [['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
+ await shot('production-unknown-outcome');
+ await page.unroute(writes);
+ await page.keyboard.press('Escape');
+
+ // Review scope: the concept's Needs review dialog and production's review-sourced dialog.
+ await leave(page);
+ await openConcept(page, 'review', 'dark');
+ await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
+ await expect(tagDialog(page)).toBeVisible();
+ await shot('concept-review');
+ await leave(page);
+ await openProduction(page, 'images', 'dark');
+ await page.evaluate(async ({ module, path }) => {
+ const { openImageTagging } = await import(module);
+ const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
+ const api = window.ApiClient.createApiClient();
+ const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
+ openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
+ }, { module: MODULE, path });
+ await expect(tagDialog(page).locator('.tag-scope-note')).toBeVisible();
+ await settled(page, 'production');
+ await shot('production-review');
+ await leave(page);
+});
diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs
index ac8d0a3..5e7d2cf 100644
--- a/tests/browser/vocabulary-canonical-comparison.mjs
+++ b/tests/browser/vocabulary-canonical-comparison.mjs
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
export function mapVocabularyConcept(reference) {
return {...reference,
- heading:{...reference.heading,actions:{...reference.heading.actions,
- // D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
- buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
- // D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
- images:'—',imagesHidden:'Not available yet'}))},
+ // Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
+ // tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
+ images:'0'}))},
operations:reference.operations.map(row=>({...row,p:{...row.p,
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index ac7c336..e08a1a9 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -1019,7 +1019,7 @@ test('shared gallery uses concept cards, image opens the viewer, empty copy',asy
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}`);
+ await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).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();
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index 63d04bf..bd07349 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -545,7 +545,7 @@ async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
- await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
+ await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
@@ -1028,8 +1028,7 @@ async function cardInventory(page, concept) {
const walk = new Function(`return (${walkSource})()`)();
return nodes.map(card => {
const copy = card.cloneNode(true);
- // D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
- if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
+ // Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
const open = card.querySelector('.image-open'), img = open.querySelector('img');
const star = card.querySelector('.image-overlays > button.star-button');
const box = card.querySelector('.image-overlays > label > input');
@@ -1039,7 +1038,7 @@ async function cardInventory(page, concept) {
size: [img.getAttribute('width'), img.getAttribute('height')],
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
- inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
+ actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
@@ -1209,7 +1208,7 @@ async function zoomedGallery(page, request, { cssZoom }) {
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
await page.keyboard.press('Tab');
- await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
+ await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
await page.evaluate(() => {
const card = document.querySelector('article[aria-posinset="1"]');
const grid = document.querySelector('.image-grid');
diff --git a/tests/browser/workspace-tagging-lifecycle.spec.mjs b/tests/browser/workspace-tagging-lifecycle.spec.mjs
new file mode 100644
index 0000000..a255a9b
--- /dev/null
+++ b/tests/browser/workspace-tagging-lifecycle.spec.mjs
@@ -0,0 +1,71 @@
+// The Tag image dialog's capture lifetime when the page or the dialog goes away mid-flight (#249 PR C final review I4,
+// I5), run on all four projects: leaving the page with the dialog open releases the capture (a `keepalive` DELETE sent
+// on `pagehide`, which also closes the dialog), and closing the dialog while its save is in flight keeps the capture
+// until the save settles, then still reports the save. The fixture is tests/browser/workspace_tagging_fixture.py.
+import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
+import { test, expect } from './fixtures.mjs';
+
+const images = '/prompt_manager/workspace#/images';
+const FIRST = 'tagging/dunes_00001.png';
+const TAGS_URL = '**/prompt_manager/image-annotations/tags';
+const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
+const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
+const tagButton = page => page.locator(`article[data-image-key="0:${FIRST}"]`).getByRole('button', { name: 'Tag or auto-tag dunes_00001.png', exact: true });
+const openCaptures = async request => (await (await request.get('/__test__/tagging/captures')).json()).captures;
+
+test.beforeEach(async ({ page, request, browserName }) => {
+ await startCoverage(page, browserName);
+ await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
+ page.problems = [];
+ page.on('pageerror', error => page.problems.push(`pageerror: ${error.message}`));
+ page.on('console', message => { if (message.type() === 'error') page.problems.push(`console: ${message.text()}`); });
+});
+test.afterEach(async ({ page, browserName }) => {
+ await saveCoverage(page, browserName);
+ expect(page.problems).toEqual([]);
+});
+
+async function openTag(page) {
+ await page.goto(images);
+ const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
+ await tagButton(page).click();
+ await expect(dialog(page)).toBeVisible();
+ expect((await observed).status()).toBe(200);
+ await expect(manual(page)).toBeEditable();
+}
+
+test('leaving the page with the dialog open releases its capture without a console error (I4)', async ({ page, request }) => {
+ await openTag(page);
+ expect(await openCaptures(request)).toBe(1);
+ // Another document: the old one's pagehide closes the dialog and sends the release with keepalive.
+ await page.goto('/prompt_manager/workspace?left=1#/tags');
+ await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
+ await expect.poll(() => openCaptures(request)).toBe(0);
+ await expect(dialog(page)).toHaveCount(0);
+});
+
+test('closing the dialog during a save releases the capture after the save and still reports it (I5)', async ({ page, request }) => {
+ await openTag(page);
+ await manual(page).fill('saved_after_close');
+ const order = [];
+ page.on('response', item => { if (item.url().endsWith('/image-annotations/tags')) order.push(`PUT ${item.status()}`); });
+ page.on('request', item => { if (item.url().includes('/image-annotations/capture/') && item.method() === 'DELETE') order.push('DELETE'); });
+ let release;
+ const held = new Promise(resolve => { release = resolve; });
+ const sent = new Promise(resolve => {
+ page.route(TAGS_URL, async route => { resolve(); await held; await route.continue(); });
+ });
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await sent;
+ await page.keyboard.press('Escape');
+ await expect(dialog(page)).toHaveCount(0);
+ expect(await openCaptures(request)).toBe(1);
+ expect(order).toEqual([]);
+ release();
+ await expect(page.locator('#workspace-toast')).toHaveText('1 tags saved for dunes_00001.png.');
+ await expect.poll(() => order).toEqual(['PUT 200', 'DELETE']);
+ await expect.poll(() => openCaptures(request)).toBe(0);
+ // The page followed the late save: the selected file's details show the new image tag.
+ await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true }).getByText('saved_after_close', { exact: true })).toBeVisible();
+ await page.unroute(TAGS_URL);
+});
diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs
new file mode 100644
index 0000000..06d24a6
--- /dev/null
+++ b/tests/browser/workspace-tagging.spec.mjs
@@ -0,0 +1,749 @@
+// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
+// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
+// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
+import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
+import { test, expect } from './fixtures.mjs';
+import { saveImageTags } from './concept-inventory.mjs';
+import { taggingInventory } from './tagging-canonical-comparison.mjs';
+
+const images = '/prompt_manager/workspace#/images';
+const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
+const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
+// The concept's on-screen opening sentence (its renderTagModelOptions replaces the markup's at once, D83).
+const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
+const CHIP_HINT = 'Remove anything you do not want to add.';
+const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
+const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
+const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
+const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
+const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
+const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
+const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
+const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
+const status = page => dialog(page).locator('#tag-generation-status');
+const toast = page => page.locator('#workspace-toast');
+const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
+const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
+const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png';
+
+let manifest;
+test.beforeEach(async ({ page, request, browserName }) => {
+ await startCoverage(page, browserName);
+ const response = await request.post('/__test__/tagging/reset', { data: {} });
+ await expect(response).toBeOK();
+ manifest = await response.json();
+ page.taggingErrors = [];
+ page.on('pageerror', error => page.taggingErrors.push(error.message));
+ page.downloads = [];
+ page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
+});
+test.afterEach(async ({ page, browserName }) => {
+ await saveCoverage(page, browserName);
+ expect(page.taggingErrors).toEqual([]);
+ expect(page.downloads).toEqual([]);
+});
+
+/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
+async function openTag(page, path = FIRST, { goto = true } = {}) {
+ if (goto) await page.goto(images);
+ const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
+ await tagButton(page, path).click();
+ await expect(dialog(page)).toBeVisible();
+ expect((await observed).status()).toBe(200);
+ await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
+}
+async function generated(page) {
+ const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
+ await generate(page).click();
+ return answer;
+}
+/** Read the saved tags of `path` through the real observe route, then release that capture. */
+async function savedTags(request, path) {
+ const target = { root_index: 0, root_scope: await scopeOf(request), path };
+ const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
+ expect(observed.success).toBe(true);
+ await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
+ return observed.tags;
+}
+async function scopeOf(request) {
+ const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
+ return listing.images[0].root_scope;
+}
+
+test('tag dialog control inventory and order match M2', async ({ page }) => {
+ await openTag(page);
+ await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
+ await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
+ const body = dialog(page).locator('#modal-body');
+ expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
+ ['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
+ const heading = body.locator('.tag-image-heading');
+ await expect(heading.locator('h3')).toHaveText(manifest.title);
+ await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
+ await expect(heading.locator('span.chip')).toHaveText('96 × 64');
+ await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
+ await expect(body.locator('.tag-scope-note')).toHaveCount(0);
+ await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
+ await expect(manual(page)).toHaveValue('');
+ await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
+ const panel = body.locator('.tag-model-panel');
+ await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
+ await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
+ expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
+ await expect(model(page)).toHaveValue('wd14-swinv2');
+ const options = panel.locator('#image-tag-model-options');
+ const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
+ const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
+ const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
+ await expect(options.locator('.two-col .field')).toHaveCount(2);
+ await expect(general).toHaveValue('0.35');
+ await expect(character).toHaveValue('0.85');
+ await expect(limit).toHaveValue('10');
+ for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
+ expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
+ }
+ await expect(generate(page)).toHaveClass(/ca-primary/);
+ await expect(generate(page).locator('svg.icon')).toHaveCount(1);
+ const suggestions = body.locator('section.tag-suggestion-panel');
+ await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
+ await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
+ await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
+ await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
+ await expect(status(page)).toHaveAttribute('role', 'status');
+ await expect(status(page)).toHaveText('');
+ await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
+ await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
+ await model(page).selectOption('gguf');
+ await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
+ await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
+ await expect(options.locator('.two-col')).toHaveCount(0);
+});
+
+test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
+ await openTag(page);
+ const answer = await generated(page);
+ expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
+ await expect(chips(page)).toHaveCount(10);
+ // D83: ruling T4's instruction leads the chips, only while chips exist.
+ await expect(dialog(page).locator('#tag-suggestions > *')).toHaveCount(2);
+ await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(CHIP_HINT);
+ await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
+ await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
+ await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
+ await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
+ await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
+ await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
+ await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
+ for (const id of ['gguf', 'hf']) {
+ await model(page).selectOption(id);
+ await expect(chips(page)).toHaveCount(0);
+ await generated(page);
+ await expect(chips(page)).toHaveCount(10);
+ await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
+ await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
+ }
+ await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
+ const fewer = await generated(page);
+ expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
+ await expect(chips(page)).toHaveCount(3);
+ await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
+});
+
+test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
+ await openTag(page);
+ await manual(page).fill('Minimal, sky_blue');
+ await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
+ await generated(page);
+ await expect(chips(page)).toHaveCount(3);
+ const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
+ await remove.click();
+ await expect(chips(page)).toHaveCount(2);
+ // Focus moves to the next chip's ×.
+ await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
+ const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
+ await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
+ expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
+ await expect(dialog(page)).toHaveCount(0);
+ await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
+ await expect(tagButton(page, FIRST)).toBeFocused();
+ expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
+ await page.reload();
+ await openTag(page, FIRST, { goto: false });
+ await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
+ // Removing every chip shows the all-removed copy; the manual set still saves.
+ await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
+ await generated(page);
+ for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
+ await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
+ await expect(generate(page)).toBeFocused();
+ await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
+ await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
+});
+
+test('cancel escape and close write nothing', async ({ page, request }) => {
+ const writes = [];
+ page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
+ await page.goto(images);
+ for (const dismiss of [
+ () => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
+ () => page.keyboard.press('Escape'),
+ () => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
+ ]) {
+ const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
+ await openTag(page, FIRST, { goto: false });
+ await manual(page).fill('never_saved');
+ await dismiss();
+ await expect(dialog(page)).toHaveCount(0);
+ expect((await released).ok()).toBe(true);
+ await expect(tagButton(page, FIRST)).toBeFocused();
+ await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
+ }
+ expect(writes).toEqual([]);
+ expect(await savedTags(request, FIRST)).toEqual([]);
+});
+
+test('model change discards suggestions and relabels primary', async ({ page }) => {
+ await openTag(page);
+ await manual(page).fill('kept');
+ await generated(page);
+ await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
+ await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
+ await model(page).selectOption('wd14-vit');
+ await expect(chips(page)).toHaveCount(0);
+ await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
+ await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
+ await expect(status(page)).toHaveText('');
+ await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
+ await expect(manual(page)).toHaveValue('kept');
+ // Like the concept, the new model's fields start from the defaults.
+ await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
+});
+
+test('invalid threshold reports validity without request', async ({ page }) => {
+ const sent = [];
+ page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
+ await openTag(page);
+ const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
+ await general.fill('1.5');
+ await generate(page).click();
+ await expect(general).toBeFocused();
+ expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
+ await general.fill('');
+ await generate(page).click();
+ await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
+ await general.fill('0.35');
+ expect(await general.evaluate(node => node.validity.valid)).toBe(true);
+ await model(page).selectOption('gguf');
+ const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
+ await instructions.fill(' ');
+ await generate(page).click();
+ await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
+ await expect(status(page)).toHaveText('');
+ expect(sent).toEqual([]);
+});
+
+test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
+ await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
+ await openTag(page);
+ expect((await generated(page)).status()).toBe(409);
+ await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
+ await expect(chips(page)).toHaveCount(0);
+ await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
+ // D116: Retry leads the actions while the controller allows it, and generates again.
+ await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
+ await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
+ const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
+ await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
+ expect((await again).status()).toBe(200);
+ await expect(chips(page)).toHaveCount(10);
+ await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
+ await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
+});
+
+test('video tag is manual only', async ({ page, request }) => {
+ await openTag(page, VIDEO);
+ const preview = dialog(page).locator('video.tag-image-preview');
+ await expect(preview).toHaveAttribute('preload', 'metadata');
+ expect(await preview.evaluate(node => node.muted)).toBe(true);
+ await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
+ await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
+ await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
+ let suggested = false;
+ page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
+ // Pressing the aria-disabled Generate sends nothing.
+ await generate(page).focus();
+ await page.keyboard.press('Enter');
+ await manual(page).fill('clip_tag');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
+ expect(suggested).toBe(false);
+ expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
+});
+
+test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openTag(page);
+ await model(page).selectOption('gguf');
+ await generated(page);
+ await expect(chips(page)).toHaveCount(10);
+ const shell = page.locator('dialog#modal');
+ const actions = dialog(page).locator('.dialog-actions');
+ expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
+ expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
+ for (const top of [0, 10_000]) {
+ await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
+ const box = await actions.boundingBox(), frame = await shell.boundingBox();
+ expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
+ expect(box.y).toBeGreaterThanOrEqual(frame.y);
+ await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
+ }
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
+});
+
+test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
+ await openTag(page, SECOND);
+ await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
+ await page.keyboard.press('Escape');
+ await expect(dialog(page)).toHaveCount(0);
+ await expect(tagButton(page, SECOND)).toBeFocused();
+});
+
+test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
+ const expectCards = async locator => {
+ const labels = await locator.evaluateAll(nodes => nodes.map(node => {
+ const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
+ return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
+ buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
+ }));
+ expect(labels.length).toBeGreaterThan(0);
+ for (const { file, buttons } of labels) {
+ expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
+ }
+ return labels.map(({ file }) => file);
+ };
+ await page.goto(images);
+ await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
+ expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
+ await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
+ const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
+ await expect(gallery.first()).toBeVisible();
+ await expectCards(gallery);
+ const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
+ await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
+ await expect(dialog(page)).toBeVisible();
+ expect((await observed).status()).toBe(200);
+ await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
+ await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
+});
+
+test('Tag opens the dialog without selecting the card', async ({ page }) => {
+ const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
+ await page.goto(images);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ const before = await panel.innerText();
+ await openTag(page, SECOND, { goto: false });
+ await page.keyboard.press('Escape');
+ await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ expect(await panel.innerText()).toBe(before);
+});
+
+test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
+ const before = await (await request.get('/__test__/tagging/snapshot')).json();
+ expect(await savedTags(request, SECOND)).toEqual([]);
+ expect(await savedTags(request, COPY)).toEqual([]);
+ const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
+ await openTag(page, FIRST);
+ await manual(page).fill('only_this_file');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
+ expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
+ expect(await savedTags(request, SECOND)).toEqual([]);
+ expect(await savedTags(request, COPY)).toEqual([]);
+ expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
+ const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
+ expect(pairAfter.success).toBe(true);
+ expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
+});
+
+test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
+ await page.goto('/prompt_manager/workspace#/tags');
+ const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
+ await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
+ await expect(autoTag).toHaveClass(/ca-primary/);
+ await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
+ // An aria-disabled control still takes a press; it only explains.
+ await autoTag.focus();
+ await page.keyboard.press('Enter');
+ await expect(toast(page)).toHaveText('Select an image in Images first.');
+ await expect(dialog(page)).toHaveCount(0);
+ await nav(page, 'Images').click();
+ await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
+ await expect(card(page, SECOND)).toHaveClass(/selected/);
+ await nav(page, 'Tags & categories').click();
+ await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
+ const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
+ await autoTag.click();
+ expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
+ await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
+ await page.keyboard.press('Escape');
+ await expect(autoTag).toBeFocused();
+});
+
+test('Images column shows image_count and updates after save', async ({ page }) => {
+ await page.goto(images);
+ await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
+ await nav(page, 'Tags & categories').click();
+ const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
+ await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
+ await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
+ await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
+ await expect(manual(page)).toBeVisible();
+ await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
+ await manual(page).fill('minimal, editorial');
+ const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await reread;
+ await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
+ await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
+ await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
+});
+
+test('virtualized cards release listeners on unmount', async ({ page, request }) => {
+ const extra = 140;
+ await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
+ const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
+ await page.goto(images);
+ await expect(card(page, FIRST)).toBeVisible();
+ const mounted = await count();
+ // Leaving and returning to Images several times keeps one subscription per mounted page.
+ for (let round = 0; round < 3; round += 1) {
+ await nav(page, 'Tags & categories').click();
+ await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
+ await nav(page, 'Images').click();
+ await expect(card(page, FIRST)).toBeVisible();
+ }
+ expect(await count()).toBe(mounted);
+ // Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
+ const continuation = page.locator('#gallery-continuation');
+ await expect(async () => {
+ await continuation.scrollIntoViewIfNeeded();
+ await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
+ }).toPass();
+ await continuation.scrollIntoViewIfNeeded();
+ await expect(card(page, FIRST)).toHaveCount(0);
+ expect(await count()).toBe(mounted);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await expect(card(page, FIRST)).toBeVisible();
+ await openTag(page, FIRST, { goto: false });
+ await page.keyboard.press('Escape');
+ await expect(tagButton(page, FIRST)).toBeFocused();
+ expect(await count()).toBe(mounted);
+});
+
+// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
+// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
+const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
+const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
+const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
+const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
+
+test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
+ await page.goto(images);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ // D21/D39: the linked recipe's own tag, plain.
+ await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
+ await openTag(page, FIRST, { goto: false });
+ await manual(page).fill('dunes_tag, Editorial');
+ const reread = inspected(page, FIRST);
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
+ // The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
+ await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
+ await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
+});
+
+test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => {
+ expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']);
+ await page.goto(images);
+ const read = inspected(page, UNLINKED);
+ await infoButton(page, UNLINKED).click();
+ await read;
+ await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
+ await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
+ const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
+ await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
+ await listing;
+ await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
+ await expect(card(page, UNLINKED)).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
+});
+
+test('viewer details show the same chips', async ({ page, request }) => {
+ await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
+ await page.goto(images);
+ await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
+ await card(page, FIRST).locator('.image-open').click();
+ const details = page.locator('#viewer-dialog[open] #viewer-details');
+ await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
+ const row = details.locator('.tags').last();
+ await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
+ await expect(row.locator('span.chip')).toHaveCount(3);
+ await expect(row.locator('button')).toHaveCount(0);
+ // A file without image tags: the viewer shows only D21's linked tags.
+ await page.getByRole('button', { name: 'Next media', exact: true }).click();
+ await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
+ await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
+});
+
+test('search finds an image by its image tag', async ({ page, request }) => {
+ await saveImageTags(request, UNLINKED, ['Loose_Tag']);
+ await page.goto(images);
+ await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length);
+ const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t');
+ await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t');
+ await listing;
+ await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
+ await expect(card(page, UNLINKED)).toBeVisible();
+});
+
+// Task C4 rulings: a save re-reads the selected file's details without a loading flash, and under a Tags filter or a
+// search the loaded listing is re-read in place (scroll and selection kept while the file still matches).
+const cards = page => page.locator('.gallery-body article[data-image-key]');
+const listingOf = (page, check) => page.waitForResponse(item => item.url().includes('/images/output?') && check(new URL(item.url()).searchParams));
+
+test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
+ await page.goto(images);
+ await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
+ await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ await page.evaluate(() => {
+ const node = document.querySelector('aside[aria-label="Selected image details"]');
+ window.__loadingSeen = false;
+ window.__loadingWatch = new MutationObserver(() => { if (node.textContent.includes('Loading file details')) window.__loadingSeen = true; });
+ window.__loadingWatch.observe(node, { childList: true, subtree: true, characterData: true });
+ });
+ let release;
+ const held = new Promise(resolve => { release = resolve; });
+ const matcher = url => url.pathname === `/prompt_manager/images/inspect/${FIRST}`;
+ await page.route(matcher, async route => { await held; await route.continue(); });
+ await openTag(page, FIRST, { goto: false });
+ await manual(page).fill('held_tag');
+ const reread = page.waitForRequest(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${FIRST}`);
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await reread;
+ await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
+ // While the re-read is held, the panel still shows the file's details.
+ await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
+ await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
+ const landed = inspected(page, FIRST);
+ release();
+ await landed;
+ await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
+ expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
+ await page.unroute(matcher);
+});
+
+test('a save under a Tags filter re-reads the listing: the selection stays while it matches and follows when it leaves', async ({ page, request }) => {
+ await saveImageTags(request, UNLINKED, ['shared']);
+ await saveImageTags(request, SECOND, ['shared']);
+ await page.goto(images);
+ const read = inspected(page, SECOND);
+ await infoButton(page, SECOND).click();
+ await read;
+ const filtered = listingOf(page, query => query.get('tags') === 'shared');
+ await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await filtered;
+ await expect(cards(page)).toHaveCount(2);
+ // The newest match (SECOND) is selected once the filter applies.
+ await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
+ // Both matches checked: the selection bar keeps only what the refreshed result still lists (I7).
+ for (const path of [SECOND, UNLINKED]) await card(page, path).getByRole('checkbox', { name: `Select ${path.split('/').pop()}`, exact: true }).check();
+ const bulkbar = page.locator('#bulkbar .bulkbar');
+ await expect(bulkbar.locator('strong')).toHaveText('2 selected');
+ // Tagging the other match keeps both cards and SECOND selected; the listing is re-read.
+ await openTag(page, UNLINKED, { goto: false });
+ await manual(page).fill('shared, extra');
+ let reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await reread;
+ await expect(cards(page)).toHaveCount(2);
+ await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
+ await expect(tagButton(page, UNLINKED)).toBeFocused();
+ // SECOND loses the filter tag: it leaves the result and the panel follows the remaining match.
+ await openTag(page, SECOND, { goto: false });
+ await manual(page).fill('');
+ reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
+ const next = inspected(page, UNLINKED);
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await reread;
+ await next;
+ await expect(cards(page)).toHaveCount(1);
+ await expect(card(page, SECOND)).toHaveCount(0);
+ await expect(card(page, UNLINKED)).toHaveClass(/\bselected\b/);
+ await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
+ await expect(page.getByText('1 of 1 image', { exact: false }).first()).toBeVisible();
+ await expect(bulkbar.locator('strong')).toHaveText('1 selected');
+ await expect(card(page, UNLINKED).getByRole('checkbox')).toBeChecked();
+ // Export metadata acts on exactly the one file the bar counts.
+ const exported = [];
+ page.on('request', item => { if (item.url().includes('/prompt_manager/images/inspect/')) exported.push(new URL(item.url()).pathname); });
+ const download = page.waitForEvent('download');
+ await bulkbar.getByRole('button', { name: 'Export metadata', exact: true }).click();
+ await download;
+ expect(exported).toEqual([`/prompt_manager/images/inspect/${UNLINKED}`]);
+});
+
+test('a save under a search keeps the scroll position and the loaded range', async ({ page, request }) => {
+ await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 140 } })).toBeOK();
+ await page.goto(images);
+ const searched = listingOf(page, query => query.get('q') === 'bulk');
+ await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('bulk');
+ await searched;
+ const continuation = page.locator('#gallery-continuation');
+ // Load at least two pages (the search lists 40 a page), then rest a third of the way down.
+ const loadedCount = async () => {
+ const text = await continuation.textContent();
+ return /caught up/.test(text) ? 140 : Number(/^(\d+) of 140/.exec(text)?.[1] ?? 0);
+ };
+ await expect.poll(async () => { await continuation.scrollIntoViewIfNeeded(); return loadedCount(); }).toBeGreaterThanOrEqual(80);
+ await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight / 3)));
+ await expect(continuation).not.toBeInViewport();
+ const loaded = await continuation.textContent();
+ await expect.poll(() => continuation.textContent()).toBe(loaded);
+ const y = await page.evaluate(() => window.scrollY);
+ expect(y).toBeGreaterThan(0);
+ const visible = await cards(page).evaluateAll(nodes => nodes.find(node => { const box = node.getBoundingClientRect(); return box.top > 80 && box.bottom < innerHeight; })?.dataset.imageKey);
+ expect(visible).toBeTruthy();
+ const path = visible.slice(2);
+ await openTag(page, path, { goto: false });
+ await manual(page).fill('kept_in_place');
+ const reread = listingOf(page, query => query.get('q') === 'bulk' && !query.has('cursor'));
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await reread;
+ await expect(tagButton(page, path)).toBeFocused();
+ await expect(continuation).toHaveText(loaded);
+ expect(Math.abs(await page.evaluate(() => window.scrollY) - y)).toBeLessThanOrEqual(1);
+});
+
+// Final review round (I1, I2, I9, M5, M6).
+test('the option fields show the server defaults that Generate sends (I1)', async ({ page, request }) => {
+ await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
+ await openTag(page);
+ const field = name => dialog(page).getByRole('spinbutton', { name, exact: true });
+ await expect(field('General confidence')).toHaveValue('0.5');
+ await expect(field('Character confidence')).toHaveValue('0.9');
+ await expect(field('Suggestions')).toHaveValue('7');
+ const answer = await generated(page);
+ expect(answer.request().postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
+ await expect(chips(page)).toHaveCount(7);
+ // A model change re-renders the new model's fields from the server defaults, and keeps the count (D91).
+ await field('Suggestions').fill('4');
+ await model(page).selectOption('wd14-vit');
+ await expect(field('General confidence')).toHaveValue('0.5');
+ await expect(field('Suggestions')).toHaveValue('4');
+});
+
+test('a tag conflict shows the other writer\'s tags before Save, and a second Save keeps them (I2)', async ({ page, request }) => {
+ await openTag(page);
+ await manual(page).fill('mine');
+ // Another writer (a second tab, the legacy page) saves while the dialog is open.
+ expect(await saveImageTags(request, FIRST, ['changed_elsewhere'])).toEqual(['changed_elsewhere']);
+ const conflict = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ expect((await conflict).status()).toBe(409);
+ await expect(status(page)).toContainText('Saved now: changed_elsewhere.');
+ await expect(manual(page)).toHaveValue('changed_elsewhere, mine');
+ await expect(dialog(page)).toBeVisible();
+ const second = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ expect((await second).request().postDataJSON().tags).toEqual(['changed_elsewhere', 'mine']);
+ await expect(toast(page)).toHaveText('2 tags saved for dunes_00001.png.');
+ expect(await savedTags(request, FIRST)).toEqual(['changed_elsewhere', 'mine']);
+});
+
+test('D116: Retry leads the actions after an unconfirmed save and resends the same tags', async ({ page, request }) => {
+ await openTag(page);
+ await manual(page).fill('retried');
+ let lost = true;
+ await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
+ const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
+ expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
+ [['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
+ expect(inventory.actions.buttons[0].minHeight).toBe(inventory.actions.buttons[1].minHeight);
+ const resent = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
+ await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
+ expect((await resent).request().postDataJSON().tags).toEqual(['retried']);
+ await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
+ expect(await savedTags(request, FIRST)).toEqual(['retried']);
+ await page.unroute('**/prompt_manager/image-annotations/tags');
+});
+
+test('D116: Retry after an expired capture observes again and keeps the typed draft (M5)', async ({ page, request }) => {
+ await openTag(page);
+ await manual(page).fill('kept_draft');
+ // The store forgets every capture (as its idle sweep would), so Save answers capture-expired.
+ await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: null } })).toBeOK();
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
+ const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
+ await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
+ expect((await observed).status()).toBe(200);
+ await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
+ await expect(manual(page)).toHaveValue('kept_draft');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
+ expect(await savedTags(request, FIRST)).toEqual(['kept_draft']);
+});
+
+test('a tag save from Tags reloads a filtered Images visit instead of restoring it (M6)', async ({ page, request }) => {
+ await saveImageTags(request, SECOND, ['shared']);
+ await saveImageTags(request, UNLINKED, ['shared']);
+ await page.goto(images);
+ await infoButton(page, SECOND).click();
+ const filtered = listingOf(page, query => query.get('tags') === 'shared');
+ await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await filtered;
+ await expect(cards(page)).toHaveCount(2);
+ await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
+ // Tags › Auto-tag images tags the selected SECOND without the filter tag.
+ await nav(page, 'Tags & categories').click();
+ await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
+ await expect(manual(page)).toHaveValue('shared');
+ await manual(page).fill('other');
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ await expect(toast(page)).toHaveText('1 tags saved for dunes_00002.png.');
+ const reloaded = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
+ await nav(page, 'Images').click();
+ await reloaded;
+ await expect(cards(page)).toHaveCount(1);
+ await expect(card(page, UNLINKED)).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
+});
+
+test('a failed listing re-read after a save says so and keeps the cards (M2)', async ({ page, request }) => {
+ await saveImageTags(request, SECOND, ['shared']);
+ await saveImageTags(request, UNLINKED, ['shared']);
+ await page.goto(images);
+ await infoButton(page, SECOND).click();
+ const filtered = listingOf(page, query => query.get('tags') === 'shared');
+ await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await filtered;
+ await expect(cards(page)).toHaveCount(2);
+ await openTag(page, SECOND, { goto: false });
+ await manual(page).fill('');
+ const failing = url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('tags') === 'shared';
+ await page.route(failing, route => route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }));
+ const reread = page.waitForResponse(item => failing(new URL(item.url())));
+ await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
+ expect((await reread).status()).toBe(500);
+ await expect(toast(page)).toHaveText('Images could not be refreshed after the tag save. Refresh image collection to retry.');
+ await expect(cards(page)).toHaveCount(2);
+ await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
+ await page.unroute(failing);
+});
diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs
index 8896505..4adf0f6 100644
--- a/tests/browser/workspace-visual.spec.mjs
+++ b/tests/browser/workspace-visual.spec.mjs
@@ -107,7 +107,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
- await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
+ await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
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');
@@ -296,7 +296,7 @@ async function containedImageContent(page) {
const failures = [];
for (const card of nodes) {
const outer = card.getBoundingClientRect();
- for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
+ for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
const node = card.querySelector(selector), box = node.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
@@ -306,7 +306,7 @@ async function containedImageContent(page) {
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
}
}
- const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
+ const action = card.querySelector('.image-card-actions').getBoundingClientRect();
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
for (const text of range.getClientRects()) {
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
@@ -351,7 +351,7 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
expect(filename).toMatch(/layered-context-.*\.png/);
// D19: Info selects into the panel; the image itself opens the viewer.
- await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
+ await longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
// D19: the card image, not the panel, opens the viewer.
const fullPreview = longCard.locator('.image-open');
@@ -420,7 +420,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
- await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
+ await selected.locator('.image-card-actions button:first-child').click(); await selected.locator('.image-open').focus();
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
await page.evaluate(() => {
@@ -497,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(40);
- await cards.first().locator('.image-card-actions button').click();
+ await cards.first().locator('.image-card-actions button:first-child').click();
const selectedKey = await cards.first().getAttribute('data-image-key');
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
@@ -609,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
await page.setViewportSize({width,height});
for (const theme of ['dark','light']) {
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
- await page.locator('.media-card .image-card-actions button').first().click();
+ await page.locator('.media-card .image-card-actions button:first-child').first().click();
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
for (const layout of ['Grid','List','Masonry']) {
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
@@ -621,7 +621,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
expect(longName).toMatch(/layered-context-.*\.png/);
- await longCard.locator('.image-card-actions button').click();
+ await longCard.locator('.image-card-actions button:first-child').click();
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
await orderedMedia(page);
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs
index 9cc0742..4424869 100644
--- a/tests/browser/workspace-vocabulary.spec.mjs
+++ b/tests/browser/workspace-vocabulary.spec.mjs
@@ -50,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
const actions=main(page).locator(':scope > .heading > .heading-actions button');
- await expect(actions).toHaveText(['+ Create tag']);
+ // The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
+ // Images it is aria-disabled (D115).
+ await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
await expect(actions.first()).not.toHaveClass(/ca-primary/);
- await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
+ await expect(actions.last()).toHaveClass(/ca-primary/);
+ await expect(actions.last()).toHaveAttribute('aria-disabled','true');
await expect(surfaces(page)).toHaveCount(2);
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
@@ -65,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
expect(manifest.tags).toEqual(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
- const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
- expect(images).toEqual(expected.map(()=>['—','Not available yet']));
- await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
+ // Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
+ await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
+ await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
const operations=surfaces(page).nth(1).locator('.operation');
diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py
index ea48be8..b22c362 100644
--- a/tests/browser/workspace_concept_fixture.py
+++ b/tests/browser/workspace_concept_fixture.py
@@ -158,6 +158,9 @@ MAX_EXTRA_IMAGES = 120
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
MAX_SEEDLESS_IMAGES = 2
SEEDLESS_SIZE = (1536, 1152)
+# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
+# 390px beside the card's Info and Tag (the D3 footer check).
+SHORT_SEEDLESS_SIZE = (96, 72)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
"execution_run_outputs",
@@ -326,9 +329,9 @@ def _record_run(
return start.run_id
-def _write_seedless_png(path: Path) -> None:
+def _write_seedless_png(path: Path, size: tuple[int, int] = SEEDLESS_SIZE) -> None:
"""A plain PNG with no metadata: no prompt, no seed, no link."""
- with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
+ with Image.new("RGB", size, "#2b3442") as image:
image.save(path)
@@ -345,13 +348,17 @@ def seed_workspace_concept(
*,
extra_images: int = 0,
seedless_images: int = 0,
+ short_seedless: bool = False,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
``extra_images`` continues the concept's rounds (same prompts, dimensions and
seed rule) after its 24 images, for bounded-rendering and Load more checks.
- ``seedless_images`` adds unlinked PNGs without metadata, newest of all.
+ ``seedless_images`` adds unlinked PNGs without metadata, newest of all;
+ ``short_seedless`` makes them ``SHORT_SEEDLESS_SIZE`` instead.
"""
+ if not isinstance(short_seedless, bool):
+ raise TypeError("short_seedless must be a boolean")
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
_clear(api, root)
@@ -459,7 +466,9 @@ def seed_workspace_concept(
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
path = folder / name
- _write_seedless_png(path)
+ _write_seedless_png(
+ path, SHORT_SEEDLESS_SIZE if short_seedless else SEEDLESS_SIZE
+ )
stamp = now - 10 - number * 10
os.utime(path, (stamp, stamp))
seedless.append({"filename": name, "path": f"concepts/{name}"})
diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py
new file mode 100644
index 0000000..31cbd2f
--- /dev/null
+++ b/tests/browser/workspace_tagging_fixture.py
@@ -0,0 +1,313 @@
+"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
+
+Two files share one positive prompt and one saved pair, an equal-byte copy of
+the first sits at another path, and a video and an unlinked file follow. The
+injected fake model (``tests/browser/server.py`` ``TestModel``) answers
+Generate; no model file exists and nothing is ever downloaded.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shutil
+import time
+from typing import TYPE_CHECKING
+
+from aiohttp import web
+from PIL import Image
+from PIL.PngImagePlugin import PngInfo
+
+if TYPE_CHECKING:
+ from py.api import PromptManagerAPI
+
+POSITIVE = (
+ "Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
+ "terracotta, late afternoon sun, architectural photography, quiet geometry"
+)
+NEGATIVE = "plastic, harsh shadows, noise, watermark"
+TITLE = "Quiet geometry"
+PROMPT_TAGS = ["minimal", "warm_light"]
+PAIR_TAGS = ["editorial"]
+# Newest first, so the Images order (Recently added) is the manifest's.
+FILES = (
+ ("tagging/dunes_00001.png", "linked"),
+ ("tagging/dunes_00002.png", "linked"),
+ ("copies/dunes_00001.png", "copy"),
+ ("tagging/clip.webm", "video"),
+ ("loose/unlinked.png", "unlinked"),
+)
+MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
+# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
+MAX_EXTRA_IMAGES = 160
+_TABLES = (
+ "saved_views",
+ "image_favorites",
+ "media_image_tags",
+ "media_annotation_bytes",
+ "media_annotation_files",
+ "image_prompt_pairs",
+ "prompt_pair_tags",
+ "prompt_pairs",
+ "generated_images",
+ "prompt_tags",
+ "tags",
+ "prompt_library_state",
+ "prompts",
+)
+# Every prompt-side owner row a Tag save could touch, for before/after
+# comparisons: tagging one image never changes a prompt, a pair or a link.
+_SNAPSHOT = {
+ "prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
+ "prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
+ "prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
+ " archived, updated_at FROM prompt_pairs ORDER BY id",
+ "prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
+ "generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
+ " ORDER BY id",
+ "image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
+ " ORDER BY id",
+}
+
+
+def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
+ graph = {
+ "1": {"class_type": "CLIPTextEncode", "inputs": {"text": POSITIVE}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}},
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {"positive": ["1", 0], "negative": ["2", 0], "seed": 7},
+ },
+ "4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
+ }
+ info = PngInfo()
+ info.add_text("prompt", json.dumps(graph))
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with Image.new("RGB", size, colour) as image:
+ image.save(path, pnginfo=info)
+
+
+def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
+ """Replace this server's disposable rows and files with the tagging set."""
+ db = api.db
+ with db.model.get_connection() as conn:
+ for table in _TABLES:
+ conn.execute(f"DELETE FROM {table}")
+ for name in ("output", "input"):
+ shutil.rmtree(root / name)
+ (root / name).mkdir()
+ output = root / "output"
+ _png(output / FILES[0][0], (196, 140, 92))
+ _png(output / FILES[1][0], (150, 110, 80))
+ (output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
+ shutil.copyfile(
+ Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
+ output / FILES[3][0],
+ )
+ _png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
+ now = time.time()
+ for index, (relative, _) in enumerate(FILES):
+ stamp = now - 60 - index * 10
+ os.utime(output / relative, (stamp, stamp))
+ for number in range(extra_images):
+ extra = output / "bulk" / f"bulk_{number:04}.png"
+ extra.parent.mkdir(exist_ok=True)
+ shutil.copyfile(output / FILES[4][0], extra)
+ stamp = now - 600 - number
+ os.utime(extra, (stamp, stamp))
+
+ receipt = db.create_library_recipe(
+ POSITIVE,
+ NEGATIVE,
+ expected_epoch=db.get_execution_epoch(),
+ parent_metadata={
+ "title": TITLE,
+ "category": "Architecture",
+ "tags": list(PROMPT_TAGS),
+ },
+ )
+ positive_id = receipt["positive"]["id"]
+ pair_id = receipt["pair"]["id"]
+ with db.model.get_connection() as conn:
+ tag_ids = []
+ for name in PAIR_TAGS:
+ conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
+ tag_ids.append(
+ conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
+ )
+ conn.executemany(
+ "INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
+ [(pair_id, tag_id) for tag_id in tag_ids],
+ )
+ for relative, kind in FILES:
+ if kind in {"linked", "video"}:
+ image_id = db.link_image_to_prompt(positive_id, str(output / relative))
+ if kind == "linked":
+ db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
+ api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
+ api.invalidate_gallery_cache()
+ return {
+ "positive_id": positive_id,
+ "pair_id": pair_id,
+ "title": TITLE,
+ "prompt_tags": list(PROMPT_TAGS),
+ "pair_tags": list(PAIR_TAGS),
+ "files": [
+ {"path": relative, "filename": Path(relative).name, "kind": kind}
+ for relative, kind in FILES
+ ],
+ }
+
+
+def tagging_snapshot(api: PromptManagerAPI) -> dict:
+ """Plain rows of every prompt, pair and link table."""
+ with api.db.model.get_connection() as conn:
+ return {
+ name: [list(row) for row in conn.execute(query)]
+ for name, query in _SNAPSHOT.items()
+ }
+
+
+def register_tagging_commands(
+ routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
+) -> None:
+ """Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
+
+ @routes.post("/__test__/tagging/reset")
+ async def reset(request: web.Request) -> web.Response:
+ data = await request.json() if request.can_read_body else {}
+ if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
+ raise web.HTTPBadRequest()
+ extra = data.get("extra_images", 0)
+ if isinstance(extra, bool) or not isinstance(extra, int):
+ raise web.HTTPBadRequest()
+ if not 0 <= extra <= MAX_EXTRA_IMAGES:
+ raise web.HTTPBadRequest()
+ model.unavailable = set()
+ model.suggestions = None
+ model.loaded = None
+ _set_defaults(model, DEFAULTS)
+ # A test that ends with the dialog open leaves its capture behind; drop
+ # every capture so the next test starts below the store's limits.
+ await api._run_in_executor(api.close_annotation_captures)
+ manifest = await api._run_in_executor(seed_tagging, api, root, extra)
+ return web.json_response({"success": True, **manifest})
+
+ @routes.get("/__test__/tagging/snapshot")
+ async def snapshot(request: web.Request) -> web.Response:
+ if request.query:
+ raise web.HTTPBadRequest()
+ return web.json_response(await api._run_in_executor(tagging_snapshot, api))
+
+ @routes.post("/__test__/tagging/models")
+ async def models(request: web.Request) -> web.Response:
+ """Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
+ data = await request.json() if request.can_read_body else None
+ if (
+ request.query
+ or not isinstance(data, dict)
+ or set(data) != {"unavailable"}
+ or not isinstance(data["unavailable"], list)
+ or any(name not in MODELS for name in data["unavailable"])
+ ):
+ raise web.HTTPBadRequest()
+ model.unavailable = set(data["unavailable"])
+ return web.json_response({"success": True})
+
+ @routes.post("/__test__/tagging/suggestions")
+ async def suggestions(request: web.Request) -> web.Response:
+ """Answer Generate with fixed scored names (the concept's own sample).
+
+ ``{"scored": [[name, confidence], ...]}`` (at most 100, confidence in
+ [0, 1]) or ``{"scored": null}`` for the default list. Every capture is
+ released too, so a concept comparison starts below the store's limits
+ whatever the previous test left open.
+ """
+ data = await request.json() if request.can_read_body else None
+ scored = data.get("scored") if isinstance(data, dict) else None
+ if (
+ request.query
+ or not isinstance(data, dict)
+ or set(data) != {"scored"}
+ or not (scored is None or _valid_scored(scored))
+ ):
+ raise web.HTTPBadRequest()
+ model.suggestions = (
+ None if scored is None else [(name, float(value)) for name, value in scored]
+ )
+ model.unavailable = set()
+ await api._run_in_executor(api.close_annotation_captures)
+ return web.json_response({"success": True})
+
+ @routes.post("/__test__/tagging/defaults")
+ async def defaults(request: web.Request) -> web.Response:
+ """Serve other tagging defaults from ``GET /autotag/models``.
+
+ ``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
+ (in [0, 1]) and the persisted suggestion count (1..100), so a test can
+ tell the server's values from the dialog's built-in ones. Reset
+ restores the standard 0.35 / 0.85 / 10.
+ """
+ data = await request.json() if request.can_read_body else None
+ if request.query or not _valid_defaults(data):
+ raise web.HTTPBadRequest()
+ _set_defaults(model, (data["general"], data["character"], data["limit"]))
+ return web.json_response({"success": True})
+
+ @routes.get("/__test__/tagging/captures")
+ async def captures(request: web.Request) -> web.Response:
+ """How many captures the store holds (a released capture is gone)."""
+ if request.query:
+ raise web.HTTPBadRequest()
+ store = api._captures
+ count = store.stats()["captures"] if store is not None else 0
+ return web.json_response({"captures": count})
+
+
+# The standard tagging defaults: WD14 general and character thresholds, and
+# the suggestion count (``AutotagConfig``).
+DEFAULTS = (0.35, 0.85, 10)
+
+
+def _set_defaults(model, values) -> None:
+ from py.config import AutotagConfig
+
+ general, character, limit = values
+ model.wd14_general_threshold = general
+ model.wd14_character_threshold = character
+ AutotagConfig.SUGGESTION_LIMIT = limit
+
+
+def _valid_defaults(data: object) -> bool:
+ unit = (
+ lambda value: isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and 0 <= value <= 1
+ )
+ return (
+ isinstance(data, dict)
+ and set(data) == {"general", "character", "limit"}
+ and unit(data["general"])
+ and unit(data["character"])
+ and type(data["limit"]) is int
+ and 1 <= data["limit"] <= 100
+ )
+
+
+def _valid_scored(scored: object) -> bool:
+ return (
+ isinstance(scored, list)
+ and len(scored) <= 100
+ and all(
+ isinstance(row, list)
+ and len(row) == 2
+ and isinstance(row[0], str)
+ and 0 < len(row[0]) <= 50
+ and isinstance(row[1], (int, float))
+ and not isinstance(row[1], bool)
+ and 0 <= row[1] <= 1
+ for row in scored
+ )
+ )
diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js
index 5aad215..01b45b4 100644
--- a/tests/js/api-client.test.js
+++ b/tests/js/api-client.test.js
@@ -296,3 +296,33 @@ test("get forwards an explicit cache mode and keeps the default request otherwis
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
await writes;
});
+
+test("ApiError exposes only a validated current tag set from a tag-conflict body", async () => {
+ const permitted = [{ tags: [], tag_revision: 0 }, { tags: ["warm", "Dune"], tag_revision: 7 }];
+ const denied = [undefined, null, { tags: ["a"] }, { tag_revision: 1 }, { tags: "a", tag_revision: 1 },
+ { tags: [1], tag_revision: 1 }, { tags: ["a"], tag_revision: -1 }, { tags: ["a"], tag_revision: 1.5 },
+ { tags: ["a"], tag_revision: true }, { tags: Array(101).fill("a"), tag_revision: 1 }];
+ assert.equal(new ApiError(409, "old caller").current, null);
+ for (const current of permitted.concat(denied)) {
+ const api = createApiClient(async () => jsonResponse(409, {
+ success: false, error: "changed", code: "tag-conflict", ...(current || {}),
+ }));
+ await assert.rejects(api.put("/x", {}), error => {
+ assert.ok(error instanceof ApiError);
+ assert.deepEqual(error.current, permitted.includes(current) ? current : null);
+ if (permitted.includes(current)) assert.notEqual(error.current.tags, current.tags, "server arrays must be copied");
+ return true;
+ });
+ }
+});
+
+test("keepalive is forwarded only when asked, so a request can outlive its page", async () => {
+ const fetch = fakeFetch();
+ const api = createApiClient(fetch);
+ const writes = [api.del("/x", { keepalive: true }), api.put("/x", {}, { keepalive: true }), api.post("/x", {}, { keepalive: true }),
+ api.del("/x"), api.put("/x", {}), api.get("/x")];
+ assert.deepEqual(fetch.calls.map(call => call.init.keepalive), [true, true, true, undefined, undefined, undefined]);
+ assert.equal("keepalive" in fetch.calls[3].init, false);
+ for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
+ await Promise.all(writes);
+});
diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js
index 16d5a17..15c512f 100644
--- a/tests/js/workspace-file-inspector.test.js
+++ b/tests/js/workspace-file-inspector.test.js
@@ -86,3 +86,34 @@ test('Show in folder outcomes use the D38 toasts',async()=>{
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
});
+
+// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
+function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
+ return {...record(), imageTags, library};
+}
+test('renders current image tags before linked tags without duplicates', async () => {
+ const {inspectorTagChips} = await load();
+ assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
+ [{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
+ // D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
+ [{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
+ // An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
+ assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
+});
+test('image tag chips filter in the panel and are plain in the viewer', async () => {
+ const {inspectorTagChips} = await load();
+ const source = tagged({state:'current', tags:['dunes']});
+ assert.deepEqual(inspectorTagChips(source, {filterable:true}).map(chip => chip.filter), [true, true, true, true]);
+ assert.deepEqual(inspectorTagChips(source, {filterable:false}).map(chip => chip.filter), [false, false, false, false]);
+});
+test('none and unavailable render no image chips', async () => {
+ const {inspectorTagChips} = await load();
+ for (const state of ['none', 'unavailable']) {
+ assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
+ assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
+ }
+});
diff --git a/tests/js/workspace-image-layout.test.js b/tests/js/workspace-image-layout.test.js
index 0b3d3dc..d152121 100644
--- a/tests/js/workspace-image-layout.test.js
+++ b/tests/js/workspace-image-layout.test.js
@@ -149,3 +149,15 @@ test('a narrow gallery caps the concept columns at the smallest readable card',
}
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
});
+
+test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
+ const {stacksCardInfo} = await source;
+ assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
+ assert.equal(stacksCardInfo(119, 'grid'), true);
+ assert.equal(stacksCardInfo(120, 'grid'), false);
+ assert.equal(stacksCardInfo(119, 'masonry'), true);
+ // A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
+ assert.equal(stacksCardInfo(195, 'list'), true);
+ assert.equal(stacksCardInfo(280, 'list'), false);
+ assert.equal(stacksCardInfo(358, 'list'), false);
+});
diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js
new file mode 100644
index 0000000..604716f
--- /dev/null
+++ b/tests/js/workspace-image-tagging-state.test.js
@@ -0,0 +1,769 @@
+// Run with: node --test tests/js/workspace-image-tagging-state.test.js
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { createApiClient } = require('../../web/js/api-client.js');
+
+// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
+const source = import('../../web/js/workspace/image-tagging-state.mjs').catch(error => {
+ if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
+ return {};
+});
+async function load() {
+ const module = await source;
+ assert.equal(typeof module.createImageTaggingController, 'function', 'image-tagging-state.mjs is not implemented');
+ return module;
+}
+
+const EPOCH = '11111111-1111-4111-8111-111111111111';
+const SCOPE = 'c'.repeat(64);
+const SHA = 'd'.repeat(64);
+const TOKEN = 'capture-token_1';
+const TOKEN2 = 'capture-token_2';
+const TARGET = Object.freeze({ root_index: 0, root_scope: SCOPE, path: 'dunes/dunes_00001.png' });
+const OBSERVE = '/prompt_manager/image-annotations/observe';
+const SUGGEST = '/prompt_manager/image-annotations/suggest';
+const TAGS = '/prompt_manager/image-annotations/tags';
+const MODELS = '/prompt_manager/autotag/models';
+const CAPTURE = '/prompt_manager/image-annotations/capture/';
+const DEFAULT_PROMPT = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).';
+
+const flush = () => new Promise(resolve => setImmediate(resolve));
+async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); }
+function deferred() {
+ let resolve, reject;
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
+ return { promise, resolve, reject };
+}
+function response(status, body) {
+ return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
+}
+const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra });
+
+function captureInfo(overrides = {}) {
+ return {
+ token: TOKEN, expires_at: '2026-10-22T12:00:00+00:00', preview_url: CAPTURE + (overrides.token || TOKEN),
+ sha256: SHA, size: 1234, width: 832, height: 1216, media_type: 'image',
+ generate: { supported: true, reason: null }, ...overrides,
+ };
+}
+function observed(overrides = {}, capture = {}) {
+ return {
+ success: true, library_epoch: EPOCH, target: { ...TARGET }, capture: captureInfo(capture),
+ title: 'Desert pavilion', filename: 'dunes_00001.png', tags: ['Minimal', 'warm_light'], tag_revision: 3,
+ ...overrides,
+ };
+}
+const models = (overrides = {}) => ({
+ success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
+ wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
+});
+const SANDSTONE = [
+ { name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
+ { name: 'sandstone', confidence: 0.981 }, { name: 'Editorial', confidence: 0.5 },
+];
+const suggested = (rows = SANDSTONE, overrides = {}) => ({ success: true, model_type: 'wd14-swinv2', options: {}, suggestions: rows, ...overrides });
+const receipt = (tags, overrides = {}) => ({ success: true, library_epoch: EPOCH, sha256: SHA, tags, tag_revision: 4, replayed: false, ...overrides });
+
+/** Real createApiClient over a fetch whose answers come from routes[METHOD URL](call). */
+function harness(routes = {}) {
+ const calls = [];
+ const fetch = (url, init = {}) => {
+ const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false, signal: init.signal };
+ calls.push(call);
+ return new Promise((resolve, reject) => {
+ init.signal?.addEventListener('abort', () => {
+ call.aborted = true;
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
+ });
+ const key = `${call.method} ${url.startsWith(CAPTURE) ? CAPTURE : url}`;
+ const route = routes[key];
+ Promise.resolve().then(() => {
+ if (!route) return response(200, { success: true });
+ return route(call);
+ }).then(resolve, reject);
+ });
+ };
+ return {
+ api: createApiClient(fetch), calls,
+ of: (method, url) => calls.filter(call => call.method === method && (url === CAPTURE ? call.url.startsWith(CAPTURE) : call.url === url)),
+ };
+}
+function defaultRoutes(extra = {}) {
+ return {
+ [`POST ${OBSERVE}`]: () => response(200, observed()),
+ [`GET ${MODELS}`]: () => response(200, models()),
+ [`POST ${SUGGEST}`]: () => response(200, suggested()),
+ [`PUT ${TAGS}`]: call => response(200, receipt(call.body.tags)),
+ ...extra,
+ };
+}
+async function opened(routes, options = {}) {
+ const { createImageTaggingController } = await load();
+ const h = harness(defaultRoutes(routes));
+ const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images', ...options });
+ await c.open();
+ await settle();
+ return { c, h };
+}
+
+test('MODEL_OPTIONS lists legacy engines in mock order with D84 labels', async () => {
+ const { MODEL_OPTIONS } = await load();
+ assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [
+ ['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'],
+ ['wd14-vit', 'WD14 ViT · ~400 MB'],
+ ['gguf', 'GGUF JoyCaption (Recommended) · ~4.5 GB'],
+ ['hf', 'HuggingFace JoyCaption · ~16 GB'],
+ ]);
+ assert.ok(Object.isFrozen(MODEL_OPTIONS) && Object.isFrozen(MODEL_OPTIONS[0]));
+});
+
+test('dedupeTags trims, drops blanks and keeps the first spelling case-insensitively', async () => {
+ const { dedupeTags, parseManualTags } = await load();
+ const input = [' Minimal ', 'minimal', '', 'warm_light', 'WARM_LIGHT', 7, 'dune'];
+ assert.deepEqual(dedupeTags(input), ['Minimal', 'warm_light', 'dune']);
+ assert.deepEqual(input[0], ' Minimal ', 'input is not mutated');
+ assert.deepEqual(dedupeTags(undefined), []);
+ assert.deepEqual(parseManualTags(' a, ,B , b,c '), ['a', 'B', 'c']);
+ assert.deepEqual(parseManualTags(null), []);
+});
+
+test('the controller rejects a malformed target, source or api', async () => {
+ const { createImageTaggingController } = await load();
+ const { api } = harness();
+ assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_index: true } }), TypeError);
+ assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_scope: 'x' } }), TypeError);
+ assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, path: '' } }), TypeError);
+ assert.throws(() => createImageTaggingController({ api, target: TARGET, source: 'elsewhere' }), TypeError);
+ assert.throws(() => createImageTaggingController({ api: {}, target: TARGET }), TypeError);
+ assert.throws(() => createImageTaggingController(), TypeError);
+});
+
+test('opens with saved tags joined by comma-space', async () => {
+ const { createImageTaggingController } = await load();
+ const h = harness(defaultRoutes());
+ const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images' });
+ const seen = [];
+ const unsubscribe = c.subscribe(state => seen.push(state.phase));
+ assert.equal(c.state.phase, 'observing');
+ const pending = c.open();
+ assert.equal(c.state.phase, 'observing');
+ await pending;
+ await settle();
+ assert.equal(c.state.phase, 'ready');
+ assert.equal(c.state.manual, 'Minimal, warm_light');
+ assert.deepEqual(c.state.savedTags, ['Minimal', 'warm_light']);
+ assert.equal(c.state.image.filename, 'dunes_00001.png');
+ assert.equal(c.state.image.title, 'Desert pavilion');
+ assert.equal(c.state.image.dimensions, '832 × 1216');
+ assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN);
+ assert.equal(c.state.model, 'wd14-swinv2');
+ assert.equal(c.state.countText, 'Not generated');
+ assert.equal(c.state.primaryLabel, 'Save manual tags');
+ assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
+ assert.equal(c.state.showScopeNote, false);
+ assert.equal(c.state.canSave, true);
+ assert.equal(c.state.canGenerate, true);
+ assert.ok(Object.isFrozen(c.state));
+ assert.deepEqual(h.of('POST', OBSERVE)[0].body, { target: { ...TARGET } });
+ assert.ok(seen.includes('ready'));
+ unsubscribe();
+ c.setManual('x');
+ assert.equal(seen.at(-1), 'ready', 'an unsubscribed listener is not called');
+ assert.throws(() => c.subscribe('nope'), TypeError);
+});
+
+test('an image without known dimensions shows none and an unseen file starts empty', async () => {
+ const { c } = await opened({
+ [`POST ${OBSERVE}`]: () => response(200, observed({ tags: [], tag_revision: 0, title: null }, { width: null, height: null })),
+ });
+ assert.equal(c.state.image.dimensions, null);
+ assert.equal(c.state.manual, '');
+ assert.equal(c.state.image.title, null);
+});
+
+test('instructions default to the server prompt', async () => {
+ const { c } = await opened({
+ [`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5 })),
+ });
+ assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
+ assert.equal(c.state.options.suggestion_limit, 7);
+ assert.equal(c.state.options.general_threshold, 0.5);
+ assert.equal(c.state.options.character_threshold, 0.85);
+ c.setModel('gguf');
+ assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
+ c.setOption('instructions', 'only colours');
+ c.setModel('hf');
+ assert.equal(c.state.options.instructions, DEFAULT_PROMPT, 'a model change re-renders the defaults like the mock');
+ assert.equal(c.state.options.suggestion_limit, 7, 'the suggestion count survives a model change');
+});
+
+test('an unreadable or malformed models read falls back to the documented defaults', async () => {
+ for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
+ const { c } = await opened({ [`GET ${MODELS}`]: route });
+ assert.equal(c.state.phase, 'ready');
+ assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
+ }
+});
+
+test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
+ const { createImageTaggingController } = await load();
+ let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
+ const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
+ const c = createImageTaggingController({ api: h.api, target: TARGET });
+ await c.open();
+ assert.equal(c.state.phase, 'observe-error');
+ assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
+ assert.equal(c.state.canSave, false);
+ assert.equal(c.state.canGenerate, false);
+ assert.equal(c.state.canRetry, true);
+ answer = () => response(200, observed({ tag_revision: -1 }));
+ await c.retry();
+ assert.equal(c.state.phase, 'observe-error');
+ assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
+ assert.equal(h.of('DELETE', CAPTURE).length, 1, 'a malformed answer still releases its capture');
+ answer = () => Promise.reject(new TypeError('Failed to fetch'));
+ await c.retry();
+ assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
+ answer = () => response(200, observed());
+ await c.retry();
+ await settle();
+ assert.equal(c.state.phase, 'ready');
+ assert.equal(h.of('POST', OBSERVE).length, 4);
+});
+
+test('model change clears suggestions count and status but keeps manual', async () => {
+ const { c } = await opened();
+ c.setManual('Minimal, dune');
+ await c.generate();
+ assert.equal(c.state.countText, '3 suggestions');
+ c.setModel('wd14-vit');
+ assert.deepEqual(c.state.suggestions, []);
+ assert.equal(c.state.countText, 'Not generated');
+ assert.equal(c.state.statusText, '');
+ assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
+ assert.equal(c.state.manual, 'Minimal, dune');
+ assert.equal(c.state.model, 'wd14-vit');
+ c.setModel('wd14-vit');
+ assert.equal(c.state.model, 'wd14-vit');
+ assert.throws(() => c.setModel('ollama'), TypeError);
+ assert.throws(() => c.setOption('model', 1), TypeError);
+});
+
+test('generate sends the model options and stages scored chips', async () => {
+ const { c, h } = await opened();
+ c.setOption('general_threshold', '0.4');
+ c.setOption('suggestion_limit', '3');
+ assert.equal(await c.generate(), true);
+ const sent = h.of('POST', SUGGEST)[0].body;
+ assert.deepEqual(sent, {
+ target: { ...TARGET }, capture_token: TOKEN, expected_epoch: EPOCH, model_type: 'wd14-swinv2',
+ options: { general_threshold: 0.4, character_threshold: 0.85, suggestion_limit: 3 },
+ });
+ assert.deepEqual(c.state.suggestions.map(row => [row.name, row.label]), [['minimal', '99%'], ['editorial', '97%'], ['sandstone', '98%']]);
+ assert.equal(c.state.statusText, 'Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
+ assert.equal(c.state.suggestionsText, '');
+ c.setModel('gguf');
+ await c.generate();
+ assert.deepEqual(h.of('POST', SUGGEST)[1].body.options, { instructions: DEFAULT_PROMPT, suggestion_limit: 3 });
+});
+
+test('language models label chips Suggested and the count is capped at the requested limit', async () => {
+ const rows = Array.from({ length: 12 }, (_, i) => ({ name: `tag${i}`, confidence: null }));
+ const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, suggested(rows, { model_type: 'gguf' })) });
+ c.setModel('gguf');
+ await c.generate();
+ assert.equal(c.state.suggestions.length, 10);
+ assert.ok(c.state.suggestions.every(row => row.label === 'Suggested' && row.confidence === null));
+ assert.equal(c.state.countText, '10 suggestions');
+});
+
+test('an empty result shows the threshold copy and a malformed one is a generate error', async () => {
+ let answer = () => response(200, suggested([]));
+ const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
+ await c.generate();
+ assert.equal(c.state.suggestionsText, 'No suggestions met this threshold. Adjust it or enter tags manually.');
+ assert.equal(c.state.countText, '0 suggestions');
+ assert.equal(c.state.primaryLabel, 'Approve selected tags');
+ answer = () => response(200, suggested([{ name: 4, confidence: 2 }]));
+ await c.generate();
+ assert.equal(c.state.phase, 'generate-error');
+ assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
+ answer = () => response(200, suggested([{ name: 'one', confidence: 1 }]));
+ assert.equal(await c.retry(), true, 'retry after a generate error generates again');
+ assert.equal(c.state.countText, '1 suggestion');
+ assert.equal(c.state.suggestions[0].label, '100%');
+});
+
+test('invalid options send nothing', async () => {
+ const { c, h } = await opened();
+ const { optionError } = await load();
+ for (const [key, value] of [['general_threshold', '1.5'], ['general_threshold', ''], ['character_threshold', 'abc'], ['suggestion_limit', '0'], ['suggestion_limit', '2.5'], ['suggestion_limit', '101']]) {
+ c.setOption('general_threshold', 0.35);
+ c.setOption('character_threshold', 0.85);
+ c.setOption('suggestion_limit', 10);
+ c.setOption(key, value);
+ assert.equal(await c.generate(), false, `${key}=${value}`);
+ assert.equal(optionError(c.state.model, c.state.options).field, key);
+ }
+ c.setModel('gguf');
+ c.setOption('instructions', ' ');
+ assert.equal(await c.generate(), false);
+ c.setOption('instructions', 'x'.repeat(4001));
+ assert.equal(await c.generate(), false);
+ assert.equal(optionError('gguf', c.state.options).field, 'instructions');
+ assert.equal(h.of('POST', SUGGEST).length, 0);
+ assert.equal(optionError('wd14-vit', { general_threshold: 0, character_threshold: 1, suggestion_limit: 100 }), null);
+});
+
+test('late generation for an old serial, closed or expired dialog is ignored', async () => {
+ const pending = [];
+ const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => { const d = deferred(); pending.push(d); return d.promise; } });
+ const first = c.generate();
+ assert.equal(c.state.phase, 'generating');
+ assert.equal(c.state.statusText, 'Generating suggestions…');
+ assert.equal(c.state.canSave, false);
+ c.setModel('wd14-vit');
+ assert.equal(c.state.phase, 'ready');
+ assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
+ await settle();
+ pending[0].resolve(response(200, suggested()));
+ assert.equal(await first, false);
+ assert.equal(c.state.phase, 'ready', 'the superseded request neither stages chips nor reports its abort');
+ assert.equal(c.state.statusText, '');
+ assert.deepEqual(c.state.suggestions, []);
+ assert.equal(c.state.countText, 'Not generated');
+
+ const second = c.generate();
+ c.cancel();
+ await settle();
+ pending[1].resolve(response(200, suggested()));
+ assert.equal(await second, false);
+ assert.deepEqual(c.state.suggestions, []);
+ assert.equal(c.state.closed, true);
+ assert.equal(await c.generate(), false, 'a closed dialog never generates');
+
+ const expired = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'capture-expired', 'The preview expired. Open the Tag dialog again.') });
+ await expired.c.generate();
+ assert.equal(expired.c.state.phase, 'capture-expired');
+ assert.equal(expired.c.state.canSave, false);
+ assert.equal(expired.c.state.canGenerate, false);
+});
+
+test('a reopen while observing ignores the older observation and releases its capture', async () => {
+ const { createImageTaggingController } = await load();
+ const answers = [deferred(), deferred()];
+ let n = 0;
+ const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
+ const c = createImageTaggingController({ api: h.api, target: TARGET });
+ const one = c.open();
+ const two = c.open();
+ answers[1].resolve(response(200, observed({}, { token: TOKEN2 })));
+ await two;
+ answers[0].resolve(response(200, observed()));
+ await one;
+ await settle();
+ assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN2);
+ assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
+ c.dispose();
+ assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN, CAPTURE + TOKEN2]);
+});
+
+test('primary label toggles Save manual tags / Approve selected tags', async () => {
+ const { c } = await opened();
+ assert.equal(c.state.primaryLabel, 'Save manual tags');
+ await c.generate();
+ assert.equal(c.state.primaryLabel, 'Approve selected tags'); c.setModel('wd14-vit'); assert.equal(c.state.primaryLabel, 'Save manual tags');
+ assert.equal(c.state.countText, 'Not generated');
+});
+
+test('remove drops one chip and the last removal shows the all-removed copy', async () => {
+ const { c } = await opened();
+ await c.generate();
+ c.remove('editorial');
+ assert.deepEqual(c.state.suggestions.map(row => row.name), ['minimal', 'sandstone']);
+ assert.equal(c.state.countText, '2 suggestions');
+ c.remove('missing');
+ assert.equal(c.state.suggestions.length, 2);
+ c.remove('minimal');
+ c.remove('sandstone');
+ assert.deepEqual(c.state.suggestions, []);
+ assert.equal(c.state.suggestionsText, 'All suggestions removed. Generate again or enter tags manually.');
+ assert.equal(c.state.primaryLabel, 'Approve selected tags');
+});
+
+test('save sends manual then remaining chips deduplicated case-insensitively keeping the manual spelling', async () => {
+ const { createImageTaggingController } = await load();
+ const h = harness(defaultRoutes({
+ [`POST ${SUGGEST}`]: () => response(200, suggested([
+ { name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
+ { name: 'sandstone', confidence: 0.96 }, { name: 'desert', confidence: 0.9 },
+ ])),
+ }));
+ const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
+ await c.open();
+ await settle();
+ assert.equal(c.state.showScopeNote, true);
+ await c.generate();
+ c.remove('desert');
+ assert.equal(c.state.primaryLabel, 'Approve selected tags');
+ assert.equal(await c.save(), true);
+ const sent = h.of('PUT', TAGS)[0].body;
+ assert.deepEqual(sent.tags, ['Minimal', 'warm_light', 'editorial', 'sandstone']); // manual "Minimal", chip "minimal" dropped
+ assert.deepEqual(sent, {
+ target: { ...TARGET }, capture_token: TOKEN, sha256: SHA, expected_epoch: EPOCH,
+ expected_tag_revision: 3, tags: sent.tags,
+ });
+ assert.equal(c.state.phase, 'saved');
+ assert.deepEqual(c.state.savedTags, sent.tags);
+ assert.equal(c.state.tagRevision, 4);
+ assert.equal(c.state.toast, '4 tags saved for dunes_00001.png. Prompt association still awaits review.');
+ assert.equal(c.state.canSave, false);
+ assert.equal(await c.save(), false, 'a saved dialog does not save twice');
+});
+
+test('toast matches the mock with the review suffix', async () => {
+ const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) });
+ c.setManual('one');
+ await c.save();
+ assert.equal(c.state.toast, '1 tags saved for dunes_00001.png.', 'the mock always says "tags"');
+ const many = await opened({}, { source: 'library' });
+ await many.c.save();
+ assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.');
+});
+
+test('empty manual and no chips sends the acknowledged empty set', async () => {
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt([])) });
+ c.setManual(' , ');
+ await c.save();
+ assert.deepEqual(h.of('PUT', TAGS)[0].body.tags, []);
+ assert.equal(c.state.toast, '0 tags saved for dunes_00001.png.');
+});
+
+test('conflict keeps draft and exposes current saved tags', async () => {
+ let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
+ { tags: ['night', 'rain'], tag_revision: 5 });
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
+ await c.generate();
+ c.setManual('Minimal, dune');
+ await c.save();
+ assert.equal(c.state.phase, 'conflict');
+ assert.deepEqual(c.state.currentTags, ['night', 'rain']);
+ assert.equal(c.state.manual, 'night, rain, Minimal, dune');
+ assert.equal(c.state.suggestions.length, 3);
+ assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened; review them first. Saved now: night, rain.');
+ assert.equal(c.state.canSave, true);
+ answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
+ await c.save();
+ assert.equal(h.of('PUT', TAGS)[1].body.expected_tag_revision, 5, 'the second save is against the revision the user has seen');
+ assert.equal(c.state.phase, 'saved');
+ assert.equal(c.state.currentTags, null);
+});
+
+test('lost response keeps draft and retry resends the identical body once', async () => {
+ const answers = [() => Promise.reject(new TypeError('Failed to fetch')), () => fail(504, null, 'Gateway Timeout'),
+ () => response(200, { success: true, tags: 'broken' })];
+ for (const lost of answers) {
+ let answer = lost;
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
+ c.setManual('Minimal, dune');
+ await c.save();
+ assert.equal(c.state.phase, 'unknown-outcome');
+ assert.equal(c.state.statusText, 'The save could not be confirmed. Retry sends the same tags again.');
+ assert.equal(c.state.canRetry, true);
+ assert.equal(c.state.canSave, false);
+ assert.equal(c.state.manual, 'Minimal, dune');
+ c.setManual('changed meanwhile');
+ const gate = deferred();
+ answer = call => gate.promise.then(() => response(200, receipt(call.body.tags, { replayed: true })));
+ const retried = c.retry();
+ assert.equal(await c.retry(), false, 'a retry in flight is not sent twice');
+ gate.resolve();
+ assert.equal(await retried, true);
+ const puts = h.of('PUT', TAGS);
+ assert.equal(puts.length, 2);
+ assert.deepEqual(puts[1].body, puts[0].body);
+ assert.equal(c.state.phase, 'saved');
+ assert.equal(await c.retry(), false);
+ }
+});
+
+test('write errors keep the draft and allow another save', async () => {
+ const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(400, 'tag-limit', 'An image can carry at most 100 tags.') });
+ await c.save();
+ assert.equal(c.state.phase, 'write-error');
+ assert.equal(c.state.statusText, 'An image can carry at most 100 tags.');
+ assert.equal(c.state.canSave, true);
+ assert.equal(c.state.canRetry, false);
+ const generic = await opened({ [`PUT ${TAGS}`]: () => fail(500, 'save-failed', '') });
+ await generic.c.save();
+ assert.equal(generic.c.state.phase, 'write-error');
+ assert.equal(generic.c.state.statusText, 'The tags could not be saved.');
+});
+
+test('storage-changed and file-changed disable save until reopen', async () => {
+ for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
+ const message = `server says ${code}`;
+ let answer = () => fail(409, code, message);
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
+ c.setManual('kept draft');
+ await c.save();
+ assert.equal(c.state.phase, phase);
+ assert.equal(c.state.statusText, message);
+ assert.equal(c.state.canSave, false);
+ assert.equal(c.state.canGenerate, false);
+ assert.equal(await c.save(), false);
+ assert.equal(h.of('PUT', TAGS).length, 1);
+ await c.open();
+ await settle();
+ assert.equal(c.state.phase, 'ready', 'reopen observes again');
+ assert.equal(c.state.manual, 'Minimal, warm_light');
+ assert.equal(h.of('DELETE', CAPTURE).length, 1, 'reopen releases the old capture');
+ }
+});
+
+test('video disables generate with the still-image message', async () => {
+ const reason = 'Auto-tagging is available for supported still images only.';
+ const { c, h } = await opened({
+ [`POST ${OBSERVE}`]: () => response(200, observed({ filename: 'clip.mp4' }, { media_type: 'video', generate: { supported: false, reason } })),
+ });
+ assert.equal(c.state.image.mediaType, 'video');
+ assert.equal(c.state.canGenerate, false);
+ assert.equal(c.state.canSave, true);
+ assert.equal(c.state.statusText, reason);
+ c.setModel('gguf');
+ assert.equal(c.state.statusText, reason, 'the reason survives a model change');
+ assert.equal(await c.generate(), false);
+ assert.equal(h.of('POST', SUGGEST).length, 0);
+});
+
+test('model-unavailable and model-busy set the status line only', async () => {
+ let answer = () => response(200, suggested());
+ const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
+ await c.generate();
+ const before = c.state;
+ for (const [code, message] of [
+ ['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'],
+ ['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'],
+ ['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'],
+ ]) {
+ answer = () => fail(409, code, message);
+ assert.equal(await c.generate(), false);
+ assert.equal(c.state.statusText, message);
+ assert.deepEqual(c.state.suggestions, before.suggestions);
+ assert.equal(c.state.countText, before.countText);
+ assert.equal(c.state.primaryLabel, before.primaryLabel);
+ assert.equal(c.state.manual, before.manual);
+ assert.equal(c.state.canSave, true);
+ assert.equal(c.state.canGenerate, true);
+ }
+ answer = () => Promise.reject(new TypeError('Failed to fetch'));
+ await c.generate();
+ assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
+ c.setModel('wd14-vit');
+ assert.equal(c.state.phase, 'ready');
+});
+
+test('a storage restore during generate requires a reopen', async () => {
+ const { c } = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reopen the Tag dialog.') });
+ c.setManual('kept');
+ await c.generate();
+ assert.equal(c.state.phase, 'storage-changed');
+ assert.equal(c.state.canSave, false);
+ assert.equal(c.state.canGenerate, false);
+ await c.retry();
+ await settle();
+ assert.equal(c.state.phase, 'ready', 'Retry observes again (D116)');
+ assert.equal(c.state.manual, 'kept');
+});
+
+test('dispose aborts requests and releases the capture once', async () => {
+ const pending = deferred();
+ const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => pending.promise });
+ const token = TOKEN;
+ const releases = [];
+ const generating = c.generate();
+ let notified = 0;
+ c.subscribe(() => { notified += 1; });
+ c.dispose();
+ c.dispose();
+ c.cancel();
+ for (const call of h.of('DELETE', CAPTURE)) releases.push(decodeURIComponent(call.url.slice(CAPTURE.length)));
+ assert.deepEqual(releases, [token]);
+ assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
+ pending.resolve(response(200, suggested()));
+ assert.equal(await generating, false);
+ assert.equal(notified, 0, 'a disposed controller never notifies');
+ c.setManual('ignored');
+ c.remove('x');
+ assert.notEqual(c.state.manual, 'ignored');
+ assert.equal(await c.save(), false);
+ assert.equal(await c.open(), undefined);
+ assert.equal(h.of('POST', OBSERVE).length, 1);
+});
+
+test('dispose while observing aborts the models read and releases a late capture', async () => {
+ const { createImageTaggingController } = await load();
+ const late = deferred();
+ const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => late.promise, [`GET ${MODELS}`]: () => new Promise(() => {}) }));
+ const c = createImageTaggingController({ api: h.api, target: TARGET });
+ const opening = c.open();
+ c.dispose();
+ assert.equal(h.of('GET', MODELS)[0].aborted, true);
+ late.resolve(response(200, observed()));
+ await opening;
+ await settle();
+ assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
+ assert.equal(c.state.phase, 'observing');
+});
+
+test('a failing capture release is not reported as a dialog error', async () => {
+ const { c, h } = await opened({ [`DELETE ${CAPTURE}`]: () => Promise.reject(new TypeError('offline')) });
+ c.dispose();
+ await settle();
+ assert.equal(h.of('DELETE', CAPTURE).length, 1);
+ assert.equal(c.state.phase, 'ready');
+});
+
+test('the opening sentence is the concept on-screen copy and the chip hint shows only with chips (D83)', async () => {
+ const { createImageTaggingController } = await load();
+ const h = harness(defaultRoutes());
+ const c = createImageTaggingController({ api: h.api, target: TARGET });
+ assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
+ assert.equal(c.state.chipHint, '');
+ await c.open();
+ await settle();
+ assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
+ assert.equal(c.state.chipHint, '');
+ await c.generate();
+ assert.equal(c.state.chipHint, 'Remove anything you do not want to add.');
+ for (const row of [...c.state.suggestions]) c.remove(row.name);
+ assert.equal(c.state.chipHint, '');
+});
+
+test('the option fields follow the server defaults: open and a model change bump the revision, typing does not', async () => {
+ const { c, h } = await opened({
+ [`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5, wd14_character_threshold: 0.9 })),
+ });
+ const opening = c.state.optionsRevision;
+ assert.ok(opening > 0, 'the observation that applies the server defaults bumps the revision');
+ assert.deepEqual({ ...c.state.options }, { general_threshold: 0.5, character_threshold: 0.9, instructions: DEFAULT_PROMPT, suggestion_limit: 7 });
+ c.setOption('general_threshold', '0.6');
+ assert.equal(c.state.optionsRevision, opening, 'typing never re-renders the fields');
+ c.setModel('wd14-vit');
+ assert.equal(c.state.optionsRevision, opening + 1);
+ assert.equal(c.state.options.general_threshold, 0.5);
+ await c.generate();
+ assert.deepEqual(h.of('POST', SUGGEST)[0].body.options, { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
+});
+
+test('a conflict shows the current set and merges it ahead of the draft, so a second Save never drops it unseen', async () => {
+ let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
+ { tags: ['changed_elsewhere', 'Minimal'], tag_revision: 5 });
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
+ const draft = c.state.draftRevision;
+ c.setManual('minimal, dune');
+ await c.save();
+ assert.equal(c.state.phase, 'conflict');
+ assert.equal(c.state.statusText,
+ 'The tags of this image changed since the dialog opened; review them first. Saved now: changed_elsewhere, Minimal.');
+ assert.equal(c.state.manual, 'changed_elsewhere, Minimal, dune', 'the current set leads, the draft follows, repeats dropped');
+ assert.equal(c.state.draftRevision, draft + 1, 'the dialog rewrites its field');
+ answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
+ await c.save();
+ const second = h.of('PUT', TAGS)[1].body;
+ assert.deepEqual(second.tags, ['changed_elsewhere', 'Minimal', 'dune']);
+ assert.equal(second.expected_tag_revision, 5);
+ const empty = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'tag-conflict', 'Changed.', { tags: [], tag_revision: 9 }) });
+ empty.c.setManual('a');
+ await empty.c.save();
+ assert.equal(empty.c.state.statusText, 'Changed. Saved now: no tags.');
+ assert.equal(empty.c.state.manual, 'a');
+});
+
+test('the capture release and the save survive leaving the page (keepalive)', async () => {
+ const { c, h } = await opened();
+ await c.save();
+ assert.equal(h.of('PUT', TAGS)[0].init.keepalive, true);
+ const other = await opened();
+ other.c.dispose();
+ const [release] = other.h.of('DELETE', CAPTURE);
+ assert.equal(release.init.keepalive, true);
+ assert.equal(h.of('POST', OBSERVE)[0].init.keepalive, undefined, 'reads are not kept alive');
+});
+
+test('closing during a save releases the capture only after the PUT settles and reports the outcome', async () => {
+ const outcomes = [];
+ const gate = deferred();
+ const { c, h } = await opened({ [`PUT ${TAGS}`]: call => gate.promise.then(() => response(200, receipt(call.body.tags))) },
+ { onLateSave: outcome => outcomes.push(outcome) });
+ c.setManual('dune');
+ const saving = c.save();
+ c.dispose();
+ await settle();
+ assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the capture stays while its save is in flight');
+ gate.resolve();
+ assert.equal(await saving, true);
+ await settle();
+ const order = h.calls.filter(call => call.method !== 'GET').map(call => call.method);
+ assert.deepEqual(order, ['POST', 'PUT', 'DELETE']);
+ assert.deepEqual(outcomes, [{ saved: true, filename: 'dunes_00001.png', tags: ['dune'], toast: '1 tags saved for dunes_00001.png.' }]);
+ assert.equal(c.state.closed, true);
+
+ for (const [route, message] of [
+ [() => fail(409, 'tag-conflict', 'Changed elsewhere.', { tags: ['x'], tag_revision: 5 }), 'Changed elsewhere.'],
+ [() => fail(400, 'tag-limit', 'Too many.'), 'Too many.'],
+ [() => Promise.reject(new TypeError('Failed to fetch')), 'The tag save could not be confirmed. Open Tag image again to check the saved tags.'],
+ ]) {
+ const late = [];
+ const held = deferred();
+ const run = await opened({ [`PUT ${TAGS}`]: () => held.promise.then(route) }, { onLateSave: outcome => late.push(outcome) });
+ const pending = run.c.save();
+ run.c.cancel();
+ held.resolve();
+ assert.equal(await pending, false);
+ await settle();
+ assert.deepEqual(late, [{ saved: false, message }]);
+ assert.equal(run.h.of('DELETE', CAPTURE).length, 1);
+ }
+ const quiet = await opened();
+ quiet.c.dispose();
+ assert.equal(quiet.h.of('DELETE', CAPTURE).length, 1, 'with no save in flight the capture is released at once');
+ const { createImageTaggingController } = await load();
+ assert.throws(() => createImageTaggingController({ api: h.api, target: TARGET, onLateSave: 'x' }), TypeError);
+});
+
+test('retry after a changed storage, file or expired capture observes again and keeps the draft', async () => {
+ for (const code of ['storage-changed', 'file-changed', 'capture-expired']) {
+ let revision = 3;
+ const { c, h } = await opened({
+ [`PUT ${TAGS}`]: () => fail(409, code, `server says ${code}`),
+ [`POST ${OBSERVE}`]: () => response(200, observed({ tag_revision: revision, tags: revision === 3 ? ['Minimal', 'warm_light'] : ['night'] })),
+ });
+ c.setManual('kept draft');
+ await c.save();
+ assert.equal(c.state.canRetry, true);
+ const draft = c.state.draftRevision;
+ await c.retry();
+ await settle();
+ assert.equal(c.state.phase, 'ready');
+ assert.equal(c.state.manual, 'kept draft', 'an unchanged tag set keeps the draft as typed');
+ assert.equal(c.state.draftRevision, draft + 1);
+ assert.equal(h.of('POST', OBSERVE).length, 2);
+ revision = 4;
+ await c.save();
+ await c.retry();
+ await settle();
+ assert.equal(c.state.manual, 'night, kept draft', 'a set changed meanwhile is shown ahead of the draft');
+ assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened. Saved now: night.');
+ assert.equal(c.state.tagRevision, 4);
+ }
+ // A first observation that failed has no draft to keep: its retry fills the saved tags.
+ const { createImageTaggingController } = await load();
+ let first = true;
+ const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => (first ? (first = false, fail(500, 'observe-failed')) : response(200, observed())) }));
+ const c = createImageTaggingController({ api: h.api, target: TARGET });
+ await c.open();
+ await c.retry();
+ await settle();
+ assert.equal(c.state.manual, 'Minimal, warm_light');
+});
diff --git a/tests/js/workspace-image-tagging.test.js b/tests/js/workspace-image-tagging.test.js
new file mode 100644
index 0000000..bce3e5d
--- /dev/null
+++ b/tests/js/workspace-image-tagging.test.js
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+// Guarded: before the dialog module exists every test fails on an assertion, not on the import.
+const loaded = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
+const module = () => { assert.ok(loaded, 'image-tagging.mjs is not implemented'); return loaded; };
+const SCOPE = 'a'.repeat(64);
+
+test('a listed file gives its tagging target, anything else none', () => {
+ const { imageTarget } = module();
+ assert.deepEqual(imageTarget({ root_index: 1, root_scope: SCOPE, path: 'a/b.png', filename: 'b.png' }),
+ { root_index: 1, root_scope: SCOPE, path: 'a/b.png' });
+ for (const image of [null, {}, { root_index: -1, root_scope: SCOPE, path: 'a.png' }, { root_index: 0, path: 'a.png' },
+ { root_index: 0, root_scope: 'A'.repeat(64), path: 'a.png' }, { root_index: 0, root_scope: SCOPE, path: '' },
+ { root_index: 1.5, root_scope: SCOPE, path: 'a.png' }]) {
+ assert.equal(imageTarget(image), null);
+ }
+});
+
+test('tag save listeners subscribe and unsubscribe once', () => {
+ const { onImageTagsSaved, imageTagsListenerCount } = module();
+ const before = imageTagsListenerCount();
+ const off = onImageTagsSaved(() => {});
+ assert.equal(imageTagsListenerCount(), before + 1);
+ off(); off();
+ assert.equal(imageTagsListenerCount(), before);
+ assert.throws(() => onImageTagsSaved('nope'), TypeError);
+});
diff --git a/tests/js/workspace-images-lifecycle.test.js b/tests/js/workspace-images-lifecycle.test.js
index a7511e4..bbca2e4 100644
--- a/tests/js/workspace-images-lifecycle.test.js
+++ b/tests/js/workspace-images-lifecycle.test.js
@@ -21,3 +21,12 @@ test('Images mounted after its parent is aborted creates no DOM, requests or obs
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
}
});
+
+test('Images mounted after its parent is aborted follows no tag saves', async () => {
+ const tagging = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
+ assert.ok(tagging, 'image-tagging.mjs is not implemented');
+ const before = tagging.imageTagsListenerCount();
+ const parent = createScope(); parent.dispose();
+ mountImages({}, {get() { assert.fail('Request started after parent disposal'); }}, parent, {embedded: true, fetchPage() {}}).dispose();
+ assert.equal(tagging.imageTagsListenerCount(), before);
+});
diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js
index f9f33c0..b17aa9b 100644
--- a/tests/js/workspace-images-state.test.js
+++ b/tests/js/workspace-images-state.test.js
@@ -352,3 +352,129 @@ test('selectionLabel counts the selected images as the concept bar does', async
assert.equal(selectionLabel(2), '2 selected');
assert.equal(selectionLabel(1234), '1,234 selected');
});
+
+test('refresh re-reads the loaded range in place and publishes once, replacing the items', async () => {
+ const {createImageStream,imageKey}=await source;const requests=[];const states=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push([s,hint]),batchSize:2});
+ assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
+ const first=stream.reset({q:'tag'});requests[0].resolve(page([image('a'),image('b')],5,'c1'));await first;
+ const second=stream.loadMore();requests[1].resolve(page([image('c'),image('d')],5,'c2'));await second;
+ const before=stream.state;const published=states.length;
+ const refresh=stream.refresh();
+ // The current collection stays shown while the re-read runs; Load more waits for it.
+ assert.equal(stream.state,before);assert.equal(states.length,published);
+ assert.equal(await stream.loadMore(),0);assert.equal(requests.length,3);
+ const query=new URL(requests[2].url,'http://local').searchParams;
+ // The loaded range is re-read in pages as large as it (at most 200), not in the listing's batches (M2).
+ assert.equal(query.get('q'),'tag');assert.equal(query.has('cursor'),false);assert.equal(query.get('limit'),'4');
+ requests[2].resolve(page([image('a'),image('new'),image('c')],6,'n1'));
+ await new Promise(resolve=>setImmediate(resolve));
+ const next=new URL(requests[3].url,'http://local').searchParams;
+ assert.equal(next.get('cursor'),'n1');assert.equal(next.get('limit'),'2','the rest, at least one batch');
+ assert.equal(stream.state,before);
+ requests[3].resolve(page([image('d')],6,'n2'));
+ assert.equal(await refresh,4);
+ assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d']);
+ assert.deepEqual([stream.state.total,stream.state.cursor,stream.state.hasMore,stream.state.loaded,stream.state.loading],[6,'n2',true,4,false]);
+ assert.equal(states.length,published+1);assert.deepEqual(states.at(-1)[1],{replaced:true});
+ // Load more continues from the refreshed cursor.
+ const more=stream.loadMore();assert.equal(new URL(requests[4].url,'http://local').searchParams.get('cursor'),'n2');
+ requests[4].resolve(page([image('e')],6));await more;
+ assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d','0:e']);
+});
+
+test('refresh stops early when the result shrank, and a reset or failure leaves the collection as it was', async () => {
+ const {createImageStream,imageKey}=await source;const requests=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
+ assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
+ const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
+ const shrink=stream.refresh();requests[1].resolve(page([image('b')],1));
+ assert.equal(await shrink,1);assert.deepEqual(stream.state.items.map(imageKey),['0:b']);assert.equal(stream.state.hasMore,false);
+ const before=stream.state;
+ const failing=stream.refresh();requests[2].reject(new Error('offline'));
+ await assert.rejects(failing,/offline/,'a failed re-read is reported to the page');assert.equal(stream.state,before);
+ const stale=stream.refresh();const fresh=stream.reset({tags:['y']});
+ assert.equal(requests[3].signal.aborted,true);
+ requests[4].resolve(page([image('y')]));await fresh;
+ requests[3].resolve(page([image('stale')]));assert.equal(await stale,0);
+ assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
+ stream.dispose();assert.equal(await stream.refresh(),0);
+});
+
+test('a refresh asked while a page loads runs once that page settles (I6)', async () => {
+ const {createImageStream,imageKey}=await source;const requests=[];const states=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
+ const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
+ for (const settle of ['resolve','reject']) {
+ const more=stream.loadMore();const sent=requests.length;
+ assert.equal(await stream.refresh(),0,'nothing is read beside the page in flight');
+ assert.equal(await stream.refresh(),0);
+ assert.equal(requests.length,sent);
+ if (settle==='resolve') requests[sent-1].resolve(page([image('c'),image('d')],4,'c2'));
+ else requests[sent-1].reject(new Error('offline'));
+ await more;
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(requests.length,sent+1,'exactly one pending refresh follows');
+ assert.equal(new URL(requests[sent].url,'http://local').searchParams.has('cursor'),false);
+ requests[sent].resolve(page([image('a'),image('b'),image('c'),image('d')],6,'r1'));
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.deepEqual(states.at(-1),{replaced:true});
+ assert.equal(states.filter(hint=>hint?.replaced).length,settle==='resolve'?1:2);
+ }
+ // A reset drops a pending refresh.
+ const more=stream.loadMore();const sent=requests.length;
+ assert.equal(await stream.refresh(),0);
+ const fresh=stream.reset({tags:['y']});
+ requests[sent].resolve(page([image('y')]));await fresh;
+ requests[sent-1].resolve(page([image('late')],1));await more;
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(requests.length,sent+1);assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
+ assert.equal(stream.refreshing,false);
+});
+
+test('refresh reads a long loaded range in at most 200-file pages and exposes refreshing (M2, M3)', async () => {
+ const {createImageStream}=await source;const requests=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:150});
+ const many=(prefix,n)=>Array.from({length:n},(_,i)=>image(`${prefix}${i}`));
+ const first=stream.reset({q:'x'});requests[0].resolve(page(many('a',150),1000,'c1'));await first;
+ const second=stream.loadMore();requests[1].resolve(page(many('b',150),1000,'c2'));await second;
+ const refresh=stream.refresh();
+ assert.equal(stream.refreshing,true);
+ assert.equal(new URL(requests[2].url,'http://local').searchParams.get('limit'),'200');
+ requests[2].resolve(page(many('r',200),1000,'n1'));
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(new URL(requests[3].url,'http://local').searchParams.get('limit'),'150');
+ requests[3].resolve(page(many('s',150),1000,'n2'));
+ assert.equal(await refresh,350);assert.equal(requests.length,4);
+ assert.equal(stream.refreshing,false);
+});
+
+test('a second refresh supersedes the first, repeats in a re-read are dropped, and dispose abandons one in flight', async () => {
+ const {createImageStream,imageKey}=await source;const requests=[];const states=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
+ const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],2));await first;
+ const older=stream.refresh();const newer=stream.refresh();
+ assert.equal(requests[1].signal.aborted,true,'the older re-read is aborted');
+ requests[2].resolve(page([image('b'),image('b'),image('a')],2));
+ assert.equal(await newer,2);
+ requests[1].resolve(page([image('old')],1));assert.equal(await older,0);
+ assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
+ assert.equal(states.filter(hint=>hint?.replaced).length,1);
+ const pending=stream.refresh();
+ stream.dispose();
+ assert.equal(requests[3].signal.aborted,true);
+ requests[3].resolve(page([image('late')],1));
+ assert.equal(await pending,0);assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
+});
+
+test('a refresh deferred behind a page load is dropped when the stream is disposed meanwhile', async () => {
+ const {createImageStream}=await source;const requests=[];
+ const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
+ const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
+ const more=stream.loadMore();
+ assert.equal(await stream.refresh(),0);
+ stream.dispose();
+ requests[1].resolve(page([image('c')],4,'c2'));await more;
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(requests.length,2,'no refresh runs after dispose');
+});
diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js
index 6fca178..ac33dae 100644
--- a/tests/js/workspace-inspection-state.test.js
+++ b/tests/js/workspace-inspection-state.test.js
@@ -702,3 +702,57 @@ test('a workflow the legacy parser throws on keeps the server result instead of
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.status, 'ambiguous');
});
+
+// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
+test('image_tags facet is kept exactly and absent stays null', () => {
+ const entry = image(), project = contract('toInspectionRecord');
+ assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null);
+ for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) {
+ const data = { ...envelope(entry), image_tags: facet };
+ const record = project(data, entry, extractGenerationFacts);
+ assert.deepEqual(record.imageTags, facet);
+ assert.notEqual(record.imageTags.tags, facet.tags);
+ }
+ const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`);
+ assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred);
+});
+
+test('rejects a malformed image_tags facet', () => {
+ const entry = image();
+ for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 },
+ { state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' },
+ { state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] },
+ { state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] },
+ { state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) {
+ assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet));
+ }
+ assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50);
+});
+
+test('refresh re-reads the selected file and keeps the shown record until the new read lands', async () => {
+ const { api, requests } = pendingApi(), updates = [];
+ const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
+ assert.equal(typeof controller.refresh, 'function', 'refresh is part of the controller');
+ controller.subscribe(state => updates.push(state));
+ const load = controller.select(image()); requests[0].resolve(envelope()); await load;
+ const shown = controller.state, count = updates.length;
+ const refresh = controller.refresh();
+ assert.equal(requests.length, 2);
+ // No loading state is published: the panel keeps its content.
+ assert.equal(controller.state, shown); assert.equal(updates.length, count);
+ requests[1].resolve({ ...envelope(), image_tags: { state: 'current', tags: ['fresh'] } }); await refresh;
+ assert.deepEqual(controller.state.record.imageTags, { state: 'current', tags: ['fresh'] });
+ assert.equal(controller.state.loading, false); assert.equal(updates.length, count + 1);
+ // A failed quiet re-read reports the failure as a read would.
+ const failing = controller.refresh(); requests[2].reject(new Error('offline')); await failing;
+ assert.equal(controller.state.error, true);
+ // Without a shown record a refresh is an ordinary retry (loading published).
+ const again = controller.refresh(); assert.equal(controller.state.loading, true);
+ requests[3].resolve(envelope()); await again; assert.equal(controller.state.error, false);
+ // A newer selection supersedes a refresh in flight.
+ const stale = controller.refresh(); const other = controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
+ requests[5].resolve(envelope({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') })); await other;
+ requests[4].resolve(envelope()); await stale;
+ assert.equal(controller.state.record.identity.path, 'folder/b.png');
+ controller.dispose(); assert.equal(await controller.refresh(), undefined);
+});
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index df2ed5d..d80511f 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -221,6 +221,18 @@
"functions": 98,
"statements": 98
},
+ "workspace/image-tagging-state.mjs": {
+ "lines": 98,
+ "branches": 94,
+ "functions": 98,
+ "statements": 98
+ },
+ "workspace/image-tagging.mjs": {
+ "lines": 97,
+ "branches": 88,
+ "functions": 87,
+ "statements": 97
+ },
"workspace/images-category.mjs": {
"lines": 98,
"branches": 98,
@@ -233,6 +245,12 @@
"functions": 98,
"statements": 98
},
+ "workspace/images-favorites.mjs": {
+ "lines": 98,
+ "branches": 94,
+ "functions": 85,
+ "statements": 98
+ },
"workspace/images-selection.mjs": {
"lines": 98,
"branches": 92,
@@ -245,6 +263,12 @@
"functions": 98,
"statements": 98
},
+ "workspace/images-tagging.mjs": {
+ "lines": 98,
+ "branches": 98,
+ "functions": 98,
+ "statements": 98
+ },
"workspace/images.mjs": {
"lines": 97,
"branches": 89,
@@ -259,7 +283,7 @@
},
"workspace/inspection-state.mjs": {
"lines": 98,
- "branches": 93,
+ "branches": 94,
"functions": 98,
"statements": 98
},
diff --git a/web/js/api-client.js b/web/js/api-client.js
index 4fe4039..df1f7a0 100644
--- a/web/js/api-client.js
+++ b/web/js/api-client.js
@@ -15,7 +15,7 @@
"use strict";
class ApiError extends Error {
- constructor(status, message, code = null, recovery = null) {
+ constructor(status, message, code = null, recovery = null, current = null) {
super(message);
this.name = "ApiError";
this.status = status;
@@ -32,6 +32,14 @@
this.recovery = { action: "smaller-limit", limit: recovery.limit };
}
}
+ // A tag-conflict answer carries the image's current tag set; only a
+ // well-formed one is kept, copied, so the dialog can show it.
+ this.current = null;
+ if (current && Array.isArray(current.tags) && current.tags.length <= 100 &&
+ current.tags.every(tag => typeof tag === "string") &&
+ Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) {
+ this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision };
+ }
}
}
@@ -56,9 +64,11 @@
function createApiClient(fetchImpl) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
- async function request(method, url, { body, signal, cache } = {}) {
+ async function request(method, url, { body, signal, cache, keepalive } = {}) {
const init = { method, headers: {} };
if (cache !== undefined) init.cache = cache;
+ // keepalive lets a write or a release finish while its page unloads (pagehide).
+ if (keepalive) init.keepalive = true;
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
@@ -76,11 +86,11 @@
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
if (!response.ok) {
- throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery);
+ throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery, data);
}
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
if (data && typeof data === "object" && data.success === false) {
- throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery);
+ throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery, data);
}
return data;
}
@@ -88,11 +98,11 @@
return {
// cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default.
get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }),
- post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
- put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
+ post: (url, body, { signal, keepalive } = {}) => request("POST", url, { body, signal, keepalive }),
+ put: (url, body, { signal, keepalive } = {}) => request("PUT", url, { body, signal, keepalive }),
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
// A DELETE body is optional; the favorites store names its file in one.
- del: (url, { signal, body } = {}) => request("DELETE", url, { body, signal }),
+ del: (url, { signal, body, keepalive } = {}) => request("DELETE", url, { body, signal, keepalive }),
};
}
diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs
index b7b592d..13d21b5 100644
--- a/web/js/workspace/file-inspector.mjs
+++ b/web/js/workspace/file-inspector.mjs
@@ -143,6 +143,25 @@ function evidenceBadge(prompts) {
function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
+/**
+ * The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
+ * shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
+ * a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
+ */
+export function inspectorTagChips(record, {filterable}) {
+ const shown = new Set(), chips = [];
+ const add = (names, filter) => {
+ for (const name of names) {
+ const key = name.toLowerCase();
+ if (shown.has(key)) continue;
+ shown.add(key); chips.push({name, filter});
+ }
+ };
+ if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
+ const {library} = record;
+ if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
+ return chips;
+}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
function prefixedChip(prefix, text) {
const chip = el('span', undefined, 'chip');
@@ -211,15 +230,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
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. With the
- // D39 fallback a recipe without tags shows its positive's, which are filter buttons again.
- const filterable = onTagFilter && library.tags_scope === 'positive';
+ // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
+ // not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
+ // D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
+ // image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
+ const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
+ if (chips.length) {
const tags = el('div', undefined, 'tags');
- for (const tag of library.tags) {
- if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
- const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
+ for (const {name, filter} of chips) {
+ if (!filter) {tags.append(el('span', name, 'chip')); continue;}
+ const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
}
diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs
index a17bf1f..ab10ac3 100644
--- a/web/js/workspace/image-layout.mjs
+++ b/web/js/workspace/image-layout.mjs
@@ -4,6 +4,17 @@ const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
export const MINIMUM_CARD_WIDTH = 120;
const AUTOMATIC_CARD_WIDTH = 240;
+// A list row's art: the concept's 160px, yielding to 45% of the row at extreme zoom (the overlay's D31).
+const LIST_ART_WIDTH = 160, LIST_ART_SHARE = 0.45;
+
+/**
+ * Whether a card's info row stacks its text above Info and Tag (D31): the row would be narrower than the narrowest
+ * bounded card, which only extreme zoom reaches. A list row's info is what its art leaves of the row.
+ */
+export function stacksCardInfo(width, mode) {
+ const info = mode === 'list' ? width - Math.min(LIST_ART_WIDTH, width * LIST_ART_SHARE) : width;
+ return info < MINIMUM_CARD_WIDTH;
+}
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
export function gridMetrics(grid) {
diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs
new file mode 100644
index 0000000..e37b6b6
--- /dev/null
+++ b/web/js/workspace/image-tagging-state.mjs
@@ -0,0 +1,484 @@
+/**
+ * Tag image dialog state: observe, staged suggestions and byte-exact saves (#249 C1).
+ *
+ * UI-agnostic. A dialog renders `controller.state` (a frozen snapshot replaced
+ * on every change) and calls its methods. open() captures the file and reads its
+ * saved tags; generate() stages the top suggestions as removable chips, written
+ * only by save(). Every read is guarded by a serial: a model change, a reopen,
+ * cancel() or dispose() supersedes older requests, so a late answer can never
+ * stage chips or supply a token. Writes are sent once, without an abort signal
+ * (aborting cannot undo a write); a lost answer is resent only by retry(), with
+ * the identical body (the server answers a duplicate as `replayed`). A save in
+ * flight when the dialog closes still settles: the capture is released after it,
+ * and `onLateSave(outcome)` reports what happened. Writes and the capture release
+ * are sent with `keepalive`, so they finish while the page unloads.
+ */
+import {noun} from './plural.mjs';
+
+export const MODEL_OPTIONS = Object.freeze([
+ Object.freeze({id: 'wd14-swinv2', label: 'WD14 SwinV2 · ~400 MB', classifier: true}),
+ Object.freeze({id: 'wd14-vit', label: 'WD14 ViT · ~400 MB', classifier: true}),
+ Object.freeze({id: 'gguf', label: 'GGUF JoyCaption (Recommended) · ~4.5 GB', classifier: false}),
+ Object.freeze({id: 'hf', label: 'HuggingFace JoyCaption · ~16 GB', classifier: false}),
+]);
+
+const OBSERVE_URL = '/prompt_manager/image-annotations/observe';
+const SUGGEST_URL = '/prompt_manager/image-annotations/suggest';
+const TAGS_URL = '/prompt_manager/image-annotations/tags';
+const CAPTURE_URL = '/prompt_manager/image-annotations/capture/';
+const MODELS_URL = '/prompt_manager/autotag/models';
+const SOURCES = new Set(['images', 'library', 'review', 'tags', 'bulk']);
+const OPTION_KEYS = new Set(['general_threshold', 'character_threshold', 'instructions', 'suggestion_limit']);
+const DEFAULTS = Object.freeze({general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10});
+const MAX_SUGGESTIONS = 100;
+const MAX_INSTRUCTION_CHARS = 4000;
+const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
+// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
+const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
+const REOBSERVE = new Set(Object.values(REOPEN));
+const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
+// Generic failure envelopes whose fixed sentence the dialog words itself.
+const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
+// A proxy timeout or bad gateway cannot tell whether the server committed the write.
+const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
+
+const COPY = Object.freeze({
+ // The concept's on-screen opening sentence (renderTagModelOptions a1062 replaces its markup's at once).
+ modelChanged: 'Generate suggestions with the selected model before approving.',
+ chipHint: 'Remove anything you do not want to add.', // D83
+ changedElsewhere: 'The tags of this image changed since the dialog opened.',
+ allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D83
+ empty: 'No suggestions met this threshold. Adjust it or enter tags manually.',
+ generating: 'Generating suggestions…',
+ observeFailed: 'The image could not be opened for tagging.',
+ generateFailed: 'Auto-tagging failed. Check server logs for details.',
+ saveFailed: 'The tags could not be saved.',
+ unknown: 'The save could not be confirmed. Retry sends the same tags again.',
+ unknownClosed: 'The tag save could not be confirmed. Open Tag image again to check the saved tags.',
+ reviewSuffix: ' Prompt association still awaits review.',
+});
+
+const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
+const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
+const captureToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
+const count = value => Number.isSafeInteger(value) && value >= 0;
+const dimension = value => value === null || (Number.isSafeInteger(value) && value > 0);
+const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string');
+const unit = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
+const limit = value => Number.isInteger(value) && value >= 1 && value <= MAX_SUGGESTIONS;
+
+function requireValue(valid, message) {if (!valid) throw new TypeError(message);}
+function freeze(value) {
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
+ return value;
+}
+// Duck-typed by name: this ES module cannot import the UMD api-client.js.
+function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
+function messageOf(error, fallback) {
+ return isApiError(error) && error.code && !GENERIC_CODES.has(error.code) && error.message ? error.message : fallback;
+}
+const modelOf = id => MODEL_OPTIONS.find(option => option.id === id);
+// An input's value arrives as text; blank text is not zero.
+const numberOf = value => (typeof value === 'string' ? (value.trim() === '' ? NaN : Number(value)) : value);
+
+/** Trimmed, non-blank names with case-insensitive repeats dropped; the first spelling and position win. */
+export function dedupeTags(names) {
+ const seen = new Set();
+ const result = [];
+ for (const value of Array.isArray(names) ? names : []) {
+ if (typeof value !== 'string') continue;
+ const name = value.trim();
+ const key = name.toLowerCase();
+ if (!name || seen.has(key)) continue;
+ seen.add(key);
+ result.push(name);
+ }
+ return result;
+}
+
+/** The manual field's comma-separated names. */
+export function parseManualTags(text) {
+ return dedupeTags(typeof text === 'string' ? text.split(',') : []);
+}
+
+/** The server's current set ahead of the user's draft: what the dialog shows after another writer saved. */
+function mergeCurrent(current, draft) {
+ return dedupeTags([...current, ...parseManualTags(draft)]).join(', ');
+}
+const savedNow = tags => `Saved now: ${tags.length ? tags.join(', ') : 'no tags'}.`;
+
+/** `null` when *options* are valid for *model*, else `{field, message}` (nothing is sent). */
+export function optionError(model, options) {
+ const option = modelOf(model);
+ requireValue(option, 'Unknown tagging model');
+ if (option.classifier) {
+ for (const field of ['general_threshold', 'character_threshold']) {
+ if (!unit(numberOf(options[field]))) return {field, message: 'Enter a confidence from 0 to 1.'};
+ }
+ } else {
+ const text = options.instructions;
+ if (typeof text !== 'string' || !text.trim() || text.length > MAX_INSTRUCTION_CHARS) {
+ return {field: 'instructions', message: `Enter tagging instructions of at most ${MAX_INSTRUCTION_CHARS} characters.`};
+ }
+ }
+ if (!limit(numberOf(options.suggestion_limit))) {
+ return {field: 'suggestion_limit', message: `Enter a whole number of suggestions from 1 to ${MAX_SUGGESTIONS}.`};
+ }
+ return null;
+}
+
+function requestOptions(model, options) {
+ const body = {suggestion_limit: numberOf(options.suggestion_limit)};
+ if (modelOf(model).classifier) {
+ return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body};
+ }
+ return {instructions: options.instructions, ...body};
+}
+
+function validateTarget(target) {
+ requireValue(object(target) && Number.isSafeInteger(target.root_index) && target.root_index >= 0 &&
+ hex64(target.root_scope) && typeof target.path === 'string' && target.path.length > 0 && target.path.length <= 1024,
+ 'Invalid image target');
+ return Object.freeze({root_index: target.root_index, root_scope: target.root_scope, path: target.path});
+}
+
+function validateObserved(value) {
+ requireValue(object(value) && value.success === true && uuid(value.library_epoch) && object(value.capture), 'Invalid observation');
+ const capture = value.capture;
+ requireValue(captureToken(capture.token) && hex64(capture.sha256) && typeof capture.preview_url === 'string' &&
+ capture.preview_url.startsWith(CAPTURE_URL) && dimension(capture.width) && dimension(capture.height) &&
+ ['image', 'video'].includes(capture.media_type) && object(capture.generate) &&
+ typeof capture.generate.supported === 'boolean' &&
+ (capture.generate.reason === null || typeof capture.generate.reason === 'string'), 'Invalid capture');
+ requireValue(typeof value.filename === 'string' && (value.title === null || typeof value.title === 'string') &&
+ strings(value.tags) && count(value.tag_revision), 'Invalid observation');
+ return value;
+}
+
+function validateSuggestions(value, cap) {
+ requireValue(object(value) && value.success === true && Array.isArray(value.suggestions), 'Invalid suggestions');
+ const seen = new Set();
+ const rows = [];
+ for (const row of value.suggestions) {
+ requireValue(object(row) && typeof row.name === 'string' && (row.confidence === null || unit(row.confidence)), 'Invalid suggestion');
+ const name = row.name.trim();
+ const key = name.toLowerCase();
+ if (!name || seen.has(key)) continue;
+ seen.add(key);
+ const label = row.confidence === null ? 'Suggested' : `${Math.round(row.confidence * 100)}%`;
+ rows.push({name, confidence: row.confidence, label});
+ }
+ return rows.slice(0, cap);
+}
+
+function validateReceipt(value) {
+ requireValue(object(value) && value.success === true && strings(value.tags) && count(value.tag_revision), 'Invalid tag receipt');
+ return value;
+}
+
+/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */
+async function readDefaults(api, signal) {
+ try {
+ const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
+ if (!object(value)) return DEFAULTS;
+ return {
+ general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
+ character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
+ instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
+ suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
+ };
+ } catch {
+ // The defaults keep the dialog usable; Generate reports a real failure itself.
+ return DEFAULTS;
+ }
+}
+
+function initialState(source) {
+ return {
+ phase: 'observing', closed: false, source, showScopeNote: source === 'review',
+ image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
+ model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [],
+ suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
+ // Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
+ optionsRevision: 0, draftRevision: 0,
+ };
+}
+
+/** Derived labels and permissions; never stored independently of the fields they follow. */
+function derive(state) {
+ const editable = EDITABLE.has(state.phase);
+ return {
+ ...state,
+ primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
+ countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
+ chipHint: state.suggestions.length ? COPY.chipHint : '',
+ canSave: editable,
+ canGenerate: editable && state.generateSupported,
+ canRetry: RETRYABLE.has(state.phase),
+ };
+}
+
+export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) {
+ requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
+ const imageTarget = validateTarget(target);
+ requireValue(SOURCES.has(source), 'Unknown tagging source');
+ requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
+
+ let state = freeze(derive(initialState(source)));
+ const listeners = new Set();
+ let closed = false;
+ let capture = null;
+ let epoch = null;
+ let defaults = DEFAULTS;
+ let openSerial = 0;
+ let generateSerial = 0;
+ let saveSerial = 0;
+ let readAbort = null;
+ let generateAbort = null;
+ let lastBody = null;
+ // A save in flight: closing hands the capture release to its completion.
+ let saving = false;
+ let observedOnce = false;
+
+ function set(patch) {
+ if (closed) return;
+ state = freeze(derive({...state, ...patch}));
+ for (const listener of [...listeners]) listener(state);
+ }
+
+ function releaseToken(token) {
+ // Best effort: an unreleased capture expires on the server's idle timeout.
+ api.del(CAPTURE_URL + encodeURIComponent(token), {keepalive: true}).catch(() => {});
+ }
+ function releaseCapture() {
+ if (capture) releaseToken(capture.token);
+ capture = null;
+ }
+ function stopGenerating() {
+ generateSerial += 1;
+ generateAbort?.abort();
+ generateAbort = null;
+ }
+ function reasonText() {
+ return state.generateSupported ? '' : (state.generateReason || '');
+ }
+
+ /** Observe the file; `keepDraft` (a Retry after a changed file, storage or capture) keeps the typed draft. */
+ async function open({keepDraft = false} = {}) {
+ if (closed) return;
+ const serial = ++openSerial;
+ const draft = keepDraft ? state.manual : null;
+ const seenRevision = state.tagRevision;
+ stopGenerating();
+ releaseCapture();
+ readAbort?.abort();
+ readAbort = new AbortController();
+ const defaultsRead = readDefaults(api, readAbort.signal);
+ lastBody = null;
+ set({phase: 'observing', statusText: '', toast: null, currentTags: null, generated: false, suggestions: [], suggestionsText: COPY.modelChanged});
+ let value;
+ try {
+ const answer = await api.post(OBSERVE_URL, {target: {...imageTarget}});
+ if (closed || serial !== openSerial) {
+ if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token);
+ return;
+ }
+ try {
+ value = validateObserved(answer);
+ } catch (error) {
+ if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token);
+ throw error;
+ }
+ } catch (error) {
+ if (closed || serial !== openSerial) return;
+ set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
+ return;
+ }
+ capture = {token: value.capture.token, sha256: value.capture.sha256};
+ epoch = value.library_epoch;
+ defaults = await defaultsRead;
+ if (closed || serial !== openSerial) return;
+ observedOnce = true;
+ const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
+ // A kept draft stays as typed unless another writer changed the set meanwhile: then the set leads it (I2).
+ const changed = draft !== null && value.tag_revision !== seenRevision;
+ const reason = generate.supported ? '' : (generate.reason || '');
+ set({
+ phase: 'ready',
+ image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl,
+ dimensions: width && height ? `${width} × ${height}` : null},
+ savedTags: value.tags.slice(), tagRevision: value.tag_revision,
+ manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
+ draftRevision: state.draftRevision + 1,
+ options: {...defaults}, optionsRevision: state.optionsRevision + 1,
+ generateSupported: generate.supported, generateReason: generate.reason,
+ statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
+ });
+ }
+
+ function setManual(text) {
+ set({manual: typeof text === 'string' ? text : ''});
+ }
+
+ function setModel(id) {
+ requireValue(modelOf(id), 'Unknown tagging model');
+ if (closed || id === state.model) return;
+ stopGenerating();
+ const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
+ set({
+ phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
+ optionsRevision: state.optionsRevision + 1,
+ // Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
+ options: {...state.options, general_threshold: defaults.general_threshold,
+ character_threshold: defaults.character_threshold, instructions: defaults.instructions},
+ });
+ }
+
+ function setOption(key, value) {
+ requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
+ set({options: {...state.options, [key]: value}});
+ }
+
+ async function generate() {
+ if (closed || !state.canGenerate || optionError(state.model, state.options)) return false;
+ stopGenerating();
+ const serial = generateSerial;
+ const controller = new AbortController();
+ generateAbort = controller;
+ const model = state.model;
+ const options = requestOptions(model, state.options);
+ const stale = () => closed || serial !== generateSerial;
+ set({phase: 'generating', statusText: COPY.generating});
+ let rows;
+ try {
+ const value = await api.post(SUGGEST_URL, {
+ target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
+ }, {signal: controller.signal});
+ if (stale()) return false;
+ rows = validateSuggestions(value, options.suggestion_limit);
+ } catch (error) {
+ if (stale()) return false;
+ const phase = isApiError(error) && REOPEN[error.code];
+ set(phase ? {phase, statusText: error.message} : {phase: 'generate-error', statusText: messageOf(error, COPY.generateFailed)});
+ return false;
+ }
+ generateAbort = null;
+ set({
+ phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
+ statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
+ });
+ return true;
+ }
+
+ function remove(name) {
+ const suggestions = state.suggestions.filter(row => row.name !== name);
+ if (closed || suggestions.length === state.suggestions.length) return;
+ set({suggestions, suggestionsText: suggestions.length ? '' : COPY.allRemoved});
+ }
+
+ async function send(body) {
+ const serial = ++saveSerial;
+ lastBody = body;
+ saving = true;
+ set({phase: 'saving', statusText: ''});
+ let value = null, failure = null;
+ try {
+ value = validateReceipt(await api.put(TAGS_URL, body, {keepalive: true}));
+ } catch (error) {
+ failure = error;
+ } finally {
+ saving = false;
+ }
+ if (serial !== saveSerial) return false;
+ if (closed) return settleClosed(value, failure);
+ if (failure) {
+ set(failedSave(failure));
+ return false;
+ }
+ lastBody = null;
+ set({phase: 'saved', savedTags: value.tags.slice(), tagRevision: value.tag_revision, currentTags: null, statusText: '',
+ toast: toastOf(value)});
+ return true;
+ }
+ // The mock's toast verbatim (app.js:1545, without the demo's "in this tab"): "tags" even for one.
+ function toastOf(value) {
+ return `${value.tags.length} tags saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`;
+ }
+ /** The dialog closed while its save was in flight: release the capture now, then report the outcome. */
+ function settleClosed(value, failure) {
+ releaseCapture();
+ if (!failure) {
+ onLateSave({saved: true, filename: state.image.filename, tags: value.tags.slice(), toast: toastOf(value)});
+ return true;
+ }
+ const {phase, statusText} = failedSave(failure);
+ // A conflict's own sentence, not its merged "Saved now" view: the dialog that would show it is gone.
+ const message = phase === 'unknown-outcome' ? COPY.unknownClosed : phase === 'conflict' ? failure.message : statusText;
+ onLateSave({saved: false, message});
+ return false;
+ }
+
+ /** The state patch of a failed save. */
+ function failedSave(error) {
+ if (!isApiError(error) || error.status < 400 || UNCONFIRMED_STATUS.has(error.status)) {
+ return {phase: 'unknown-outcome', statusText: COPY.unknown};
+ }
+ lastBody = null;
+ if (error.code === 'tag-conflict') {
+ // The current set is shown (status and field) ahead of the draft; a second Save is against its revision.
+ const current = error.current;
+ if (!current) return {phase: 'conflict', statusText: error.message, currentTags: null};
+ return {phase: 'conflict', currentTags: current.tags.slice(), tagRevision: current.tag_revision,
+ statusText: `${error.message} ${savedNow(current.tags)}`,
+ manual: mergeCurrent(current.tags, state.manual), draftRevision: state.draftRevision + 1};
+ }
+ if (REOPEN[error.code]) return {phase: REOPEN[error.code], statusText: error.message};
+ return {phase: 'write-error', statusText: messageOf(error, COPY.saveFailed)};
+ }
+
+ async function save() {
+ if (closed || !state.canSave) return false;
+ const tags = dedupeTags([...parseManualTags(state.manual), ...state.suggestions.map(row => row.name)]);
+ return send({
+ target: {...imageTarget}, capture_token: capture.token, sha256: capture.sha256,
+ expected_epoch: epoch, expected_tag_revision: state.tagRevision, tags,
+ });
+ }
+
+ async function retry() {
+ if (closed || !state.canRetry) return false;
+ if (state.phase === 'observe-error') return open({keepDraft: observedOnce});
+ if (REOBSERVE.has(state.phase)) return open({keepDraft: true});
+ if (state.phase === 'generate-error') return generate();
+ return send(lastBody);
+ }
+
+ function cancel() {
+ if (closed) return;
+ set({closed: true});
+ closed = true;
+ openSerial += 1;
+ stopGenerating();
+ readAbort?.abort();
+ readAbort = null;
+ // A save in flight keeps its capture until it settles (send releases it); aborting cannot undo the write.
+ if (!saving) releaseCapture();
+ }
+
+ function dispose() {
+ listeners.clear();
+ cancel();
+ }
+
+ function subscribe(listener) {
+ requireValue(typeof listener === 'function', 'A listener function is required');
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }
+
+ return {
+ get state() {return state;},
+ subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
+ };
+}
diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs
new file mode 100644
index 0000000..8993c27
--- /dev/null
+++ b/web/js/workspace/image-tagging.mjs
@@ -0,0 +1,358 @@
+/**
+ * The concept's "Tag image" dialog (docs/concepts/workspace/app.js `tagReview` a1037–1061, `renderTagModelOptions`
+ * a1062–1076, `generateTags` a1077–1135, `apply-tags` a1517–1547; style.css s1598–1694) over the tagging controller
+ * (image-tagging-state.mjs), in the shared modal shell (workspace-dialogs.mjs) with the concept's `tagging-modal`
+ * class, whose sticky `.dialog-actions` stay reachable while the content scrolls.
+ *
+ * Recorded deviations: D83 (#249 plan D60) the suggestions are removable chips, every one preselected, at most the
+ * requested count, with "Remove anything you do not want to add." while chips exist and the all-removed copy; D84
+ * (plan D61) the legacy engines' labels and sizes, GGUF in the concept's Ollama slot, instructions prefilled with the
+ * server's default; D91 (plan D68) a Suggestions count field for every model. States the concept lacks (busy,
+ * conflict, changed storage or file, lost answers, unavailable model, video) are only text in
+ * `#tag-generation-status`; D116 (ruling T5) Retry leads the actions wherever the controller allows it, as the other
+ * workspace dialogs place theirs.
+ *
+ * The capture is released when the dialog closes (×, Escape, Cancel, Save), when the route changes (the shell
+ * closes its dialog) and on `pagehide` (which closes the dialog; the release is sent with `keepalive`). A save in
+ * flight at close keeps its capture until it settles, and its outcome is still toasted and announced.
+ */
+import { createButton, createScope } from '../shared/toolkit/core.js';
+import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
+import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
+import { createWorkspaceIcon } from './icons.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+import { cardTitle } from './images-state.mjs';
+
+const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
+const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
+// The option inputs per controller key; the classifier keys only exist for WD14 models.
+const OPTION_IDS = Object.freeze({
+ general_threshold: 'image-general-threshold',
+ character_threshold: 'image-character-threshold',
+ instructions: 'image-tag-instructions',
+ suggestion_limit: 'image-suggestion-limit',
+});
+
+function el(tag, text, className) {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (className) node.className = className;
+ return node;
+}
+function setText(node, text) {
+ if (node.textContent !== text) node.textContent = text;
+}
+function setDisabled(button, disabled) {
+ if (disabled) button.setAttribute('aria-disabled', 'true');
+ else button.removeAttribute('aria-disabled');
+}
+const disabled = button => button.getAttribute('aria-disabled') === 'true';
+
+const savedListeners = new Set();
+/** Run `listener({target, filename, tags})` after every confirmed tag save; returns the unsubscribe. */
+export function onImageTagsSaved(listener) {
+ if (typeof listener !== 'function') throw new TypeError('A listener function is required');
+ savedListeners.add(listener);
+ return () => savedListeners.delete(listener);
+}
+/** How many pages follow tag saves (a mounted page's subscription ends with its scope). */
+export function imageTagsListenerCount() {
+ return savedListeners.size;
+}
+function announceSaved(event) {
+ for (const listener of [...savedListeners]) {
+ try {
+ listener(event);
+ } catch (error) {
+ // One page's refresh failure never stops the others.
+ console.error('Image tag listener failed', error);
+ }
+ }
+}
+
+const tagButtons = new WeakSet();
+/** The concept card's Tag beside Info (app.js mediaCard a398), for Images and every Library-embedded gallery. */
+export function createTagButton(image) {
+ const button = createButton('', 'icon-btn');
+ button.append(createWorkspaceIcon('tagImage'));
+ button.setAttribute('aria-label', `Tag or auto-tag ${image.filename}`);
+ tagButtons.add(button);
+ return button;
+}
+/** Whether `node` is a card's Tag button. */
+export function isTagButton(node) {
+ return tagButtons.has(node);
+}
+
+/** A listed file's tagging identity (`root_index`, `root_scope`, `path`), else null. */
+export function imageTarget(image) {
+ const {root_index: index, root_scope: scope, path} = image ?? {};
+ if (!Number.isSafeInteger(index) || index < 0 || typeof scope !== 'string' || !/^[0-9a-f]{64}$/.test(scope) ||
+ typeof path !== 'string' || !path) return null;
+ return {root_index: index, root_scope: scope, path};
+}
+
+function numberInput(value, {min, max, step}) {
+ const input = el('input');
+ Object.assign(input, {type: 'number', min, max, step});
+ input.value = String(value);
+ return input;
+}
+
+/**
+ * The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
+ * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and
+ * `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate.
+ */
+export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
+ const panel = el('div', undefined, 'tag-model-panel');
+ const select = el('select');
+ for (const option of MODEL_OPTIONS) {
+ const node = el('option', option.label); // D84
+ node.value = option.id;
+ select.append(node);
+ }
+ const options = el('div');
+ options.id = 'image-tag-model-options';
+ const generate = createButton('', 'ca-primary');
+ generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
+ panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
+ const inputs = new Map();
+ let shownModel = null, shownOptions = null;
+
+ function renderOptions(state) {
+ inputs.clear();
+ const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
+ const rows = [];
+ if (classifier) {
+ const pair = el('div', undefined, 'two-col');
+ for (const [key, label] of [['general_threshold', 'General confidence'], ['character_threshold', 'Character confidence']]) {
+ const input = numberInput(state.options[key], {min: '0', max: '1', step: '0.05'});
+ inputs.set(key, input);
+ pair.append(field(label, input, OPTION_IDS[key]));
+ }
+ rows.push(pair);
+ } else {
+ const area = el('textarea');
+ area.value = state.options.instructions;
+ inputs.set('instructions', area);
+ rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
+ }
+ // D91: the number of suggestions, for every model.
+ const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
+ inputs.set('suggestion_limit', count);
+ rows.push(field('Suggestions', count, OPTION_IDS.suggestion_limit));
+ options.replaceChildren(...rows);
+ for (const [key, input] of inputs) {
+ input.addEventListener('input', () => {
+ input.setCustomValidity('');
+ controller.setOption(key, input.value);
+ });
+ }
+ }
+ function update(state) {
+ // A model change or the observation's server defaults rewrite the fields; typing never does (I1).
+ if (state.model !== shownModel || state.optionsRevision !== shownOptions) {
+ shownModel = state.model;
+ shownOptions = state.optionsRevision;
+ select.value = state.model;
+ renderOptions(state);
+ }
+ setDisabled(generate, !state.canGenerate);
+ }
+ select.addEventListener('change', () => controller.setModel(select.value));
+ function requestGenerate() {
+ const {state} = controller;
+ if (!state.canGenerate) return;
+ // The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
+ for (const input of inputs.values()) input.setCustomValidity('');
+ const invalid = [...inputs.values()].find(input => !input.checkValidity());
+ if (invalid) { invalid.reportValidity(); return; }
+ const error = optionError(state.model, state.options);
+ if (error) {
+ const input = inputs.get(error.field);
+ input.setCustomValidity(error.message);
+ input.reportValidity();
+ return;
+ }
+ controller.generate();
+ }
+ generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); });
+ scope.cleanup(controller.subscribe(update));
+ update(controller.state);
+ return {element: panel, requestGenerate};
+}
+
+function previewOf(image, state) {
+ const video = state?.image ? state.image.mediaType === 'video' : image?.is_video === true;
+ const node = el(video ? 'video' : 'img', undefined, 'tag-image-preview');
+ if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
+ return node;
+}
+
+/**
+ * Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
+ * `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
+ */
+export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
+ const target = imageTarget(image);
+ if (!target) throw new TypeError('The image has no tagging target');
+ const saved = ({filename: name, tags, toast}) => {
+ const event = {target, filename: name, tags, source};
+ announceSaved(event);
+ onSaved?.(event);
+ showWorkspaceToast(toast);
+ };
+ // A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5).
+ const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message));
+ const controller = createImageTaggingController({api, target, source, onLateSave});
+ const local = createScope();
+ let open = true;
+ let completed = false;
+ const {resolveUrl} = window.ApiClient;
+
+ // 2. The heading: preview, title, filename and size; the listing's values until the capture answers.
+ const heading = el('div', undefined, 'tag-image-heading');
+ let preview = previewOf(image, null);
+ const copy = el('div');
+ const title = el('h3', cardTitle(image));
+ const filename = el('p', image.filename, 'small');
+ const size = el('span', undefined, 'chip');
+ copy.append(title, filename, size);
+ heading.append(preview, copy);
+ const hasSize = (width, height) => [width, height].every(value => Number.isFinite(value) && value > 0);
+ if (hasSize(image.width, image.height)) size.textContent = `${image.width} × ${image.height}`;
+ else size.remove();
+
+ // 4. The manual field.
+ const manual = el('input');
+ manual.setAttribute('aria-describedby', 'manual-tags-help');
+ const help = el('p', MANUAL_HELP, 'small');
+ help.id = 'manual-tags-help';
+ const manualField = field('Image tags · edit manually', manual, 'manual-image-tags');
+ manualField.append(help);
+ manual.addEventListener('input', () => controller.setManual(manual.value));
+
+ // 5. The model panel; 6. the suggestions.
+ const {element: panel, requestGenerate} = renderModelPanel({scope: local, controller});
+ const suggestions = el('section', undefined, 'tag-suggestion-panel');
+ suggestions.setAttribute('aria-labelledby', 'tag-suggestions-title');
+ const panelHeading = el('div', undefined, 'panel-heading');
+ const suggestionsTitle = el('h3', 'Review suggestions');
+ suggestionsTitle.id = 'tag-suggestions-title';
+ const count = el('span', 'Not generated', 'small');
+ count.id = 'suggestion-count';
+ panelHeading.append(suggestionsTitle, count);
+ const list = el('div');
+ list.id = 'tag-suggestions';
+ const status = el('div', undefined, 'small');
+ status.id = 'tag-generation-status';
+ status.setAttribute('role', 'status');
+ suggestions.append(panelHeading, list, status);
+
+ // 7. The actions; Retry leads them only while the controller allows it.
+ const actions = el('div', undefined, 'dialog-actions');
+ const retry = createButton('Retry');
+ const cancel = createButton('Cancel');
+ const primary = createButton('Save manual tags', 'ca-primary');
+ actions.append(cancel, primary);
+
+ const nodes = [heading];
+ if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
+ nodes.push(manualField, panel, suggestions, actions);
+
+ let shownImage = null, shownRows = null, shownText = null, shownDraft = 0;
+ function renderImage(info) {
+ shownImage = info;
+ const next = previewOf(null, {image: info});
+ if (next.tagName !== preview.tagName) { preview.replaceWith(next); preview = next; }
+ title.textContent = info.title || info.filename;
+ filename.textContent = info.filename;
+ preview.alt = title.textContent;
+ if (info.width && info.height) { preview.width = info.width; preview.height = info.height; }
+ preview.src = resolveUrl(info.previewUrl);
+ if (info.dimensions) { size.textContent = info.dimensions; if (!size.isConnected) copy.append(size); }
+ else size.remove();
+ }
+ function renderSuggestions(state) {
+ // A removed chip's focus moves to its neighbour's ×, else to Generate.
+ const focused = list.contains(document.activeElement) ? [...list.querySelectorAll('button')].indexOf(document.activeElement) : -1;
+ shownRows = state.suggestions;
+ shownText = state.suggestionsText;
+ if (!state.suggestions.length) {
+ list.replaceChildren(el('p', state.suggestionsText, 'small'));
+ } else {
+ // D83: removable chips instead of the concept's checkbox rows, under ruling T4's instruction.
+ const chips = el('div', undefined, 'tags');
+ for (const row of state.suggestions) {
+ const chip = el('span', undefined, 'chip tag-suggestion');
+ const remove = createButton('×');
+ remove.setAttribute('aria-label', `Remove ${row.name}`);
+ remove.addEventListener('click', () => controller.remove(row.name));
+ chip.append(el('span', row.name), el('span', row.label, 'tag-confidence'), remove);
+ chips.append(chip);
+ }
+ list.replaceChildren(el('p', state.chipHint, 'small'), chips);
+ }
+ if (focused >= 0) {
+ const buttons = [...list.querySelectorAll('button')];
+ (buttons[Math.min(focused, buttons.length - 1)] ?? panel.querySelector('.ca-primary')).focus();
+ }
+ }
+ function complete(state) {
+ completed = true;
+ closeWorkspaceDialog();
+ saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
+ }
+ function render(state) {
+ if (!open || completed) return;
+ if (state.image && state.image !== shownImage) renderImage(state.image);
+ // The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
+ if (state.draftRevision !== shownDraft) {
+ shownDraft = state.draftRevision;
+ if (manual.value !== state.manual) manual.value = state.manual;
+ }
+ if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
+ setText(count, state.countText);
+ setText(status, state.statusText);
+ setText(primary, state.primaryLabel);
+ setDisabled(primary, !state.canSave);
+ if (state.canRetry !== retry.isConnected) {
+ if (state.canRetry) actions.prepend(retry); // D116
+ else {
+ if (document.activeElement === retry) primary.focus();
+ retry.remove();
+ }
+ }
+ if (state.phase === 'saved') complete(state);
+ }
+
+ cancel.addEventListener('click', () => closeWorkspaceDialog());
+ // Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4).
+ retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry()));
+ primary.addEventListener('click', () => {
+ if (disabled(primary) || !controller.state.canSave) return;
+ controller.save();
+ });
+ // Leaving the page closes the dialog, so a page restored from the back/forward cache shows no dead dialog (I4).
+ const pagehide = () => { if (open) closeWorkspaceDialog(); };
+ window.addEventListener('pagehide', pagehide);
+
+ function release() {
+ if (!open) return;
+ open = false;
+ window.removeEventListener('pagehide', pagehide);
+ document.getElementById('modal')?.classList.remove('tagging-modal');
+ local.dispose();
+ controller.dispose();
+ }
+ openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
+ document.getElementById('modal')?.classList.add('tagging-modal');
+ local.cleanup(controller.subscribe(render));
+ render(controller.state);
+ controller.open();
+ return {
+ close() { if (open) closeWorkspaceDialog(); },
+ get controller() { return controller; },
+ };
+}
diff --git a/web/js/workspace/images-favorites.mjs b/web/js/workspace/images-favorites.mjs
new file mode 100644
index 0000000..7767278
--- /dev/null
+++ b/web/js/workspace/images-favorites.mjs
@@ -0,0 +1,81 @@
+/**
+ * The media card's star (#249 D7 = A: the concept's `actions.favorite` against the favorites store), for the Images
+ * page and every Library-embedded gallery (PR E). Its toasts are the concept's without the demo's "in this tab"; the
+ * concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
+ *
+ * One favorite request per file at a time (TESTING.md): the requests outlive the page that sent them, so the keys in
+ * flight and the pages that receive each answer live here. Extracted from images.mjs unchanged.
+ */
+import { imageKey } from './images-state.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+
+const FAVORITE_URL = '/prompt_manager/images/favorite';
+const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
+const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
+
+const favoritePending = new Set();
+let pageTarget = null;
+// Library-embedded galleries showing media cards; keyed like the Images page, so each repaints its own copy.
+const embeddedTargets = new Set();
+let visitTarget = () => {};
+// 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);
+}
+/** Whether the favorite request of the file `key` is still in flight. */
+export function favoriteBusy(key) {
+ return favoritePending.has(key);
+}
+/** `target(key, favorite)` patches the saved Images visit while no Images page is mounted. */
+export function setFavoriteVisitTarget(target) {
+ visitTarget = target;
+}
+/** Receive favorite states with `setFavorite(key, favorite)` until `scope` ends: the Images page or an embedded gallery. */
+export function receiveFavorites({scope, embedded, setFavorite}) {
+ if (!embedded) {
+ pageTarget = setFavorite;
+ scope.cleanup(() => { if (pageTarget === setFavorite) pageTarget = null; });
+ } else {
+ embeddedTargets.add(setFavorite);
+ scope.cleanup(() => embeddedTargets.delete(setFavorite));
+ }
+}
+/** Give a favorite state to the mounted Images page, else the saved visit, and to every embedded gallery. */
+function deliver(key, favorite) {
+ if (pageTarget) pageTarget(key, favorite);
+ else visitTarget(key, favorite);
+ for (const target of embeddedTargets) target(key, favorite);
+}
+
+/** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
+export function paintStar(button, favorite, pending) {
+ button.textContent = favorite ? '★' : '☆';
+ button.setAttribute('aria-pressed', String(favorite));
+ if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled');
+}
+
+/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
+export async function toggleFavorite(api, image) {
+ const key = imageKey(image);
+ if (favoritePending.has(key)) return;
+ const favorite = !image.favorite;
+ favoritePending.add(key);
+ deliver(key, favorite);
+ const body = {root_index: image.root_index, path: image.path};
+ let saved = false;
+ try {
+ await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
+ saved = true;
+ } catch {
+ // Announced below; the star returns to its previous state.
+ }
+ favoritePending.delete(key);
+ deliver(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();
+}
diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs
index ec985c6..6938e52 100644
--- a/web/js/workspace/images-selection.mjs
+++ b/web/js/workspace/images-selection.mjs
@@ -25,6 +25,7 @@ function el(tag, text, className) {
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
+ * - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
@@ -54,6 +55,13 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
render();
}
+ /** Keep only the checked files `kept(key)` still lists (a refreshed result), so the count is what the actions apply to. */
+ function retain(kept) {
+ const next = [...checked].filter(kept);
+ if (next.length === checked.size) return;
+ checked = Object.freeze(new Set(next));
+ render();
+ }
function checkedImages() {
return getItems().filter(image => checked.has(imageKey(image)));
}
@@ -142,6 +150,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
isChecked: key => checked.has(key),
checked: () => checked,
clear,
+ retain,
/** Whether a Set category write is still in flight. */
writing: () => categorizing,
};
diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs
index 4c5b84b..d133eba 100644
--- a/web/js/workspace/images-state.mjs
+++ b/web/js/workspace/images-state.mjs
@@ -9,6 +9,8 @@ export function imageKey(image) {
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
const isCount = value => Number.isSafeInteger(value) && value >= 0;
+// The largest page a refresh asks for (the listing clamps at 500).
+const REFRESH_PAGE_LIMIT = 200;
function queryFor(filters, batchSize) {
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
@@ -110,8 +112,13 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
onChange(state);
}
+ // A refresh in flight (its abort controller): Load more waits for it, a reset or dispose cancels it. A refresh
+ // asked while a page loads is pending and runs once that page settles (I6).
+ let refreshing = null;
+ let refreshPending = false;
+
async function loadMore() {
- if (disposed || state.loading || !state.hasMore) return 0;
+ if (disposed || state.loading || !state.hasMore || refreshing) return 0;
const request = new AbortController();
controller = request;
const cursor = state.cursor;
@@ -145,14 +152,73 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
publish({ loading: false, error });
}
return 0;
+ } finally {
+ runPendingRefresh(request);
}
}
+ function runPendingRefresh(request) {
+ // Only the page this load owned: a reset or dispose already dropped the pending refresh.
+ if (disposed || !refreshPending || state.loading || request.signal.aborted) return;
+ refreshPending = false;
+ // A failure is the page's to report on the refresh it asked for; here it has no caller left.
+ refresh().catch(() => {});
+ }
+
+ /**
+ * Re-read the loaded range of the same query (a write may have changed which files match) and publish the new
+ * collection once, with the hint `{replaced: true}`; the current items stay shown until then. Pages are as large
+ * as the rest of the range (at most REFRESH_PAGE_LIMIT, at least one batch), read one after another (each page's
+ * cursor comes from the one before). A read failure rejects and leaves the collection as it was; a reset or a
+ * dispose abandons it. Resolves to the number of items now loaded (0 when abandoned, or deferred behind a page
+ * load in flight, which runs it once settled).
+ */
+ async function refresh() {
+ if (disposed) return 0;
+ if (state.loading) { refreshPending = true; return 0; }
+ refreshing?.abort();
+ const request = refreshing = new AbortController();
+ const target = Math.max(state.loaded, 1);
+ const items = [], keys = new Set();
+ let cursor = null, total = 0, hasMore = true, positiveCount = null;
+ try {
+ while (items.length < target && hasMore) {
+ const parameters = new URLSearchParams(query);
+ parameters.set('limit', String(Math.min(REFRESH_PAGE_LIMIT, Math.max(target - items.length, batchSize))));
+ if (cursor !== null) parameters.set('cursor', cursor);
+ const page = await readPage({parameters, signal:request.signal});
+ if (disposed || request !== refreshing) return 0;
+ validatePage(page, cursor);
+ for (const item of page.images) {
+ const key = imageKey(item);
+ if (keys.has(key)) continue;
+ keys.add(key);
+ items.push(Object.freeze({ ...item }));
+ }
+ if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
+ ({total, next_cursor: cursor, has_more: hasMore} = page);
+ }
+ } catch (error) {
+ // A newer refresh, a reset or a dispose replaced this one (and aborted it): nothing to report.
+ if (request !== refreshing) return 0;
+ refreshing = null;
+ throw error;
+ }
+ refreshing = null;
+ seen = keys;
+ state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
+ loaded: items.length, positiveCount });
+ onChange(state, {replaced: true});
+ return items.length;
+ }
async function reset(filters = {}, {load = true} = {}) {
if (disposed) return 0;
const nextQuery = queryFor(filters, batchSize);
controller?.abort();
controller = null;
+ refreshing?.abort();
+ refreshing = null;
+ refreshPending = false;
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
@@ -164,6 +230,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
get state() { return state; },
reset,
loadMore,
+ refresh,
+ /** Whether a refresh is re-reading the loaded range (Load more waits for it). */
+ get refreshing() { return refreshing !== null; },
/** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
updateItem(key, patch) {
const next = withItem(state, key, patch);
@@ -176,8 +245,11 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
dispose() {
if (disposed) return;
disposed = true;
+ refreshPending = false;
controller?.abort();
controller = null;
+ refreshing?.abort();
+ refreshing = null;
},
};
}
diff --git a/web/js/workspace/images-tagging.mjs b/web/js/workspace/images-tagging.mjs
new file mode 100644
index 0000000..babfd78
--- /dev/null
+++ b/web/js/workspace/images-tagging.mjs
@@ -0,0 +1,42 @@
+/**
+ * The Images page's side of image tagging (#249 PR C): a card's Tag opens the Tag image dialog, and every confirmed
+ * tag save re-reads what it may have changed on a mounted page.
+ *
+ * Under a Tags filter or a search the saved file may start or stop matching, so the loaded range is re-read in place
+ * (`createImageStream().refresh()`), keeping the scroll and the selection while it still matches; a failed re-read
+ * toasts and leaves the cards as they were. The selected file's inspection is then re-read quietly (its envelope
+ * carries the image tags), without a loading flash.
+ */
+import { isTagButton, onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+
+export const REFRESH_FAILED = 'Images could not be refreshed after the tag save. Refresh image collection to retry.';
+
+/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
+ * `image-tag` does); false for any other control. */
+export function openCardTagging({api, scope, image, action, embedded}) {
+ if (!isTagButton(action)) return false;
+ openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
+ return true;
+}
+
+/**
+ * Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active,
+ * `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs.
+ */
+export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) {
+ scope.cleanup(onImageTagsSaved(async ({target}) => {
+ const key = `${target.root_index}:${target.path}`;
+ if (filtered()) {
+ onRefreshing(true);
+ try {
+ await stream.refresh();
+ } catch {
+ if (!scope.signal.aborted) showWorkspaceToast(REFRESH_FAILED);
+ } finally {
+ if (!scope.signal.aborted) onRefreshing(false);
+ }
+ }
+ if (!scope.signal.aborted && selection() === key) inspection.refresh();
+ }));
+}
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 214358f..19045c0 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -1,6 +1,6 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
-import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
+import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
@@ -15,6 +15,10 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
+import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
+import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
+import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
+export { onFavoriteChange } from './images-favorites.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
@@ -73,36 +77,21 @@ function rescanFinished(result) {
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
-// D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's
-// "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
-const FAVORITE_URL = '/prompt_manager/images/favorite';
-const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
-const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
let lastVisit = null;
-// One favorite request per file at a time (TESTING.md): the requests outlive the Images page that sent them, so the keys
-// in flight and the page that receives each answer live here, beside the saved visit.
-const favoritePending = new Set();
-let favoriteTarget = null;
-// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy.
-const embeddedFavoriteTargets = new Set();
-/**
- * Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted
- * Library-embedded gallery.
- */
-function deliverFavorite(key, favorite) {
- if (favoriteTarget) favoriteTarget(key, favorite);
- else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
- for (const target of embeddedFavoriteTargets) target(key, favorite);
-}
-// 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);
+// The star's answers patch the saved visit while no Images page is mounted (D7).
+setFavoriteVisitTarget((key, favorite) => {
+ if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
+});
+
+// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
+onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
+/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
+export function selectedImage() {
+ const key = lastVisit?.selection;
+ return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
}
/**
@@ -127,10 +116,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
const saved = embedded || !savedState ? null : imagesFilters(savedState);
- const visit = embedded || saved || favorites ? null : lastVisit;
- let density = visit?.density || 'comfortable';
+ const prior = embedded || saved || favorites ? null : lastVisit;
+ // A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
+ const visit = prior?.stale ? null : prior;
+ let density = prior?.density || 'comfortable';
let metrics = {...INITIAL_METRICS};
- let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
+ let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
@@ -138,7 +129,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let refine = null;
const rendered = new Map();
const itemIndexes = new Map();
- const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
+ const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
favorites: favorites || saved?.favorites === true };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
@@ -332,7 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (typeof image.favorite === 'boolean') {
const star = createButton('', 'star-button');
star.setAttribute('aria-label', `Favorite ${image.filename}`);
- paintStar(star, image.favorite, favoritePending.has(key));
+ paintStar(star, image.favorite, favoriteBusy(key));
overlays.append(star);
}
art.append(open, overlays);
@@ -341,65 +332,32 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const title = el('h3', cardTitle(image)); title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
const actions = el('div', undefined, 'image-card-actions');
- // D9: the tag button waits for the image-annotations slice.
const inspectButton = createButton('', 'icon-btn');
inspectButton.append(createWorkspaceIcon('info'));
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
- actions.append(inspectButton);
+ actions.append(inspectButton, createTagButton(image));
info.append(text, actions);
card.append(art, info);
markSelected(card, key === selection);
return card;
}
- /** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
- function paintStar(button, favorite, pending) {
- button.textContent = favorite ? '★' : '☆';
- button.setAttribute('aria-pressed', String(favorite));
- if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled');
- }
function setFavorite(key, favorite) {
if (scope.signal.aborted) return;
stream.updateItem(key, {favorite});
const button = rendered.get(key)?.querySelector('.star-button');
- if (button) paintStar(button, favorite, favoritePending.has(key));
+ if (button) paintStar(button, favorite, favoriteBusy(key));
}
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
- if (!embedded) {
- favoriteTarget = setFavorite;
- scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
- } else {
- embeddedFavoriteTargets.add(setFavorite);
- scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite));
- }
- /** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
- async function toggleFavorite(image) {
- const key = imageKey(image);
- if (favoritePending.has(key)) return;
- const favorite = !image.favorite;
- favoritePending.add(key);
- deliverFavorite(key, favorite);
- const body = {root_index: image.root_index, path: image.path};
- let saved = false;
- try {
- await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
- saved = true;
- } catch {
- // Announced below; the star returns to its previous state.
- }
- favoritePending.delete(key);
- deliverFavorite(key, saved ? favorite : !favorite);
- showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
- // 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();
- }
+ receiveFavorites({scope, embedded, setFavorite});
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (!image) return;
- if (event.target.closest('.star-button')) toggleFavorite(image);
+ const action = event.target.closest('.image-card-actions button');
+ if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
- else if (event.target.closest('.image-card-actions button')) inspect(image);
+ else if (action && !openCardTagging({api, scope, image, action, embedded})) inspect(image);
});
function renderVisible() {
@@ -487,7 +445,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function updateGeometry() {
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
- grid.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH);
+ grid.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
@@ -535,14 +493,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The concept's end of the collection (app.js updateImageContinuation).
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
}
- function renderState(next, {patch} = {}) {
+ function renderState(next, {patch, replaced = false} = {}) {
const previous = state;
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
const changed = !patch && state?.items !== next.items;
if (changed) {
- if (!next.items.length) itemIndexes.clear();
- for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
+ // A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
+ if (!next.items.length || replaced) itemIndexes.clear();
+ for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
+ // The selected file left the refreshed result: the panel follows the collection, as after a reload; the
+ // selection bar keeps only the checked files still listed (I7).
+ if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
+ if (replaced) bar?.retain(key => itemIndexes.has(key));
state = next;
if (!embedded) {
setText(count, countLine(next));
@@ -551,7 +514,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
if (changed) updateLayout();
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
- if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) {
+ if (!embedded && changed && !replaced && previous?.loaded && next.loaded > previous.loaded) {
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
@@ -566,12 +529,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
+ followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
+ onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
scope.cleanup(() => {
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
function pressLoad() {
- if (recovering || state?.loading) return;
+ // A refresh re-reads the loaded range: Load more waits for it (and focus stays where it is, M3).
+ if (recovering || state?.loading || stream.refreshing) return;
focusAfterLoad = state?.loaded ?? 0;
stream.loadMore();
}
@@ -611,19 +577,23 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
toolbar?.setActiveFilters(activeRefinements(filters));
}
- function reset() {
- mediaViewer.stopSlideshow();
- main.querySelector('#viewer-dialog[open]')?.close();
- if (!recovering) {
+ /** No selected file; `keepPanel` (recovering sources) leaves the panel's details shown. */
+ function dropSelection({keepPanel = false} = {}) {
+ for (const video of details.querySelectorAll('video')) releaseVideo(video);
+ if (!keepPanel) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(noSelection());
}
selection = null;
+ }
+ function reset() {
+ mediaViewer.stopSlideshow();
+ main.querySelector('#viewer-dialog[open]')?.close();
+ dropSelection({keepPanel:recovering});
focusAfterLoad = null;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
bar?.clear();
- for (const video of details.querySelectorAll('video')) releaseVideo(video);
// 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; }
diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs
index bfb61ed..9b27a42 100644
--- a/web/js/workspace/inspection-state.mjs
+++ b/web/js/workspace/inspection-state.mjs
@@ -167,6 +167,20 @@ function libraryFor(value) {
return library;
}
+// #249 PR C (D88): the file's own current image tags (S1). `none` and `unavailable` carry no names; a name is a
+// vocabulary tag name (1–50 characters, trimmed, no comma or control character), at most 100 of them.
+const IMAGE_TAG_STATES = ['current', 'none', 'unavailable'], MAX_IMAGE_TAGS = 100, IMAGE_TAG_LENGTH = 50;
+const imageTagName = name => typeof name === 'string' && name !== '' && name === name.trim() && [...name].length <= IMAGE_TAG_LENGTH &&
+ !/[,\x00-\x1f\x7f]/.test(name);
+
+function imageTagsFor(value) {
+ if (value === undefined) return null;
+ if (!object(value) || Object.keys(value).sort().join() !== 'state,tags' || !IMAGE_TAG_STATES.includes(value.state) ||
+ !Array.isArray(value.tags) || value.tags.length > MAX_IMAGE_TAGS || !value.tags.every(imageTagName) ||
+ (value.state !== 'current' && value.tags.length)) throw new TypeError('Invalid inspection image tags');
+ return { state: value.state, tags: [...value.tags] };
+}
+
// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and
// admin pages; it fills any prompt or setting the server could not decide, so this page
// never shows less than they do. A definite server answer is never replaced.
@@ -279,9 +293,10 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) {
const record = {
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
- generation: unavailableFacts(), historyTarget: null, download,
+ generation: unavailableFacts(), historyTarget: null, download, imageTags: null,
};
if (legacy) return record;
+ record.imageTags = imageTagsFor(result.image_tags);
const resolution = resolutionFor(result.resolution);
record.file = fileFor(result.file, identity);
record.metadata = metadataFor(result.metadata);
@@ -302,11 +317,12 @@ export const rootedFileAdapter = Object.freeze({
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
let request = null, disposed = false;
return {
- async load(image) {
+ /** `quiet`: publish only the answer, so the shown record stays until it lands (a re-read after a write). */
+ async load(image, { quiet = false } = {}) {
if (disposed) return;
request?.abort();
const current = request = new AbortController();
- onChange({ loading: true });
+ if (!quiet) onChange({ loading: true });
try {
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
if (disposed || request !== current) return;
@@ -339,8 +355,8 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
}
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
let loader = makeLoader();
- function read(image) {
- pending = loader.load(image);
+ function read(image, options) {
+ pending = loader.load(image, options);
return pending;
}
return {
@@ -364,6 +380,11 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
if (disposed || !hasAttempt || state.refreshRequired) return;
return read(attempted);
},
+ /** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
+ refresh() {
+ if (disposed || !hasAttempt || state.refreshRequired) return;
+ return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
+ },
clear() {
if (disposed) return;
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;
diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs
index 6fb98ea..1f6f7fd 100644
--- a/web/js/workspace/vocabulary.mjs
+++ b/web/js/workspace/vocabulary.mjs
@@ -1,11 +1,12 @@
/**
* Native Tags & categories screen (concept renderTags, app.js:719-729).
*
- * Composition, labels and order follow the approved concept; the recorded
- * deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
- * "—" / "Not available yet", and bounded lists with Load more. Rows are real
- * vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
- * to the existing Library filters.
+ * Composition, labels and order follow the approved concept: Auto-tag images
+ * opens the Tag image dialog for the image selected in Images (aria-disabled
+ * without one, D115) and the Images cells count the files carrying each tag
+ * (#249 C2). The recorded deviation left is bounded lists with Load more. Rows
+ * are real vocabulary pages from vocabulary-state.mjs; Browse hands the exact
+ * constraint to the existing Library filters.
*/
import { createButton } from '../shared/toolkit/core.js';
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
@@ -13,6 +14,11 @@ import { countOf } from './plural.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
import { pageHeading } from './heading.mjs';
+import { selectedImage } from './images.mjs';
+import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
+import { showWorkspaceToast } from './toast.mjs';
+
+const NO_SELECTION = 'Select an image in Images first.';
const COPY = {
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
@@ -42,10 +48,8 @@ function operation(title, description, label) {
function tagRow(tag) {
const row = el('tr');
- const images = el('td');
- const dash = el('span', '—');
- dash.setAttribute('aria-hidden', 'true');
- images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
+ // The files carrying the tag (absent from an older server, which counts none).
+ const images = el('td', String(tag.image_count ?? 0));
const cell = el('td');
const actions = el('div', undefined, 'row-actions');
const browse = createButton('Browse');
@@ -159,7 +163,15 @@ export function mountVocabulary(main, api, scope) {
const { description, actions } = pageHeading(main);
description.textContent = 'Organize once. Reuse everywhere.';
const create = createButton('+ Create tag');
- actions.append(create);
+ const autoTag = createButton('Auto-tag images', 'ca-primary');
+ actions.append(create, autoTag);
+ // The concept's `tag-review` for `state.selectedImage` (app.js a1511); D115: none selected, so it only explains.
+ const selected = selectedImage();
+ if (!selected) autoTag.setAttribute('aria-disabled', 'true');
+ scope.listen(autoTag, 'click', () => {
+ if (!selected) { showWorkspaceToast(NO_SELECTION); return; }
+ openImageTagging({api, scope, image: selected, source: 'tags', trigger: autoTag});
+ });
const grid = el('div', undefined, 'two-col');
const tagSurface = el('section', undefined, 'surface');
@@ -232,6 +244,8 @@ export function mountVocabulary(main, api, scope) {
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) dialog.renameTag(tag, button);
});
+ // A saved image tag set changes the Images counts.
+ scope.cleanup(onImageTagsSaved(() => { if (!scope.signal.aborted) reload(); }));
reload();
- return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
+ return {autoTag, create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
}
diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css
index 63c2425..7394f31 100644
--- a/web/js/workspace/workspace.css
+++ b/web/js/workspace/workspace.css
@@ -137,6 +137,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
.narrow-cards .media-info { flex-wrap: wrap; }
.narrow-cards .media-info > div { flex: 1 0 100%; }
+/* #249 D31: Info and Tag (#249 C2) wrap onto two lines when even the stacked row cannot hold them side by side. */
+.narrow-cards .media-info > .image-card-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; /* D31 */ }
/* A list row beside its art keeps room for Inspect. */
.narrow-cards.list-grid .media-art { width: 40%; }
.narrow-cards.list-grid .media-info { padding-inline: 4px; }
@@ -362,3 +364,15 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.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; }
}
+
+/* Tag image dialog (#249 C2): the concept's `tagging-modal` (s1598–1694) as it stands, but D83 (ruling T4): the
+ suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
+ give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
+ s1655): a name, the confidence and a × button. */
+#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
+#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
+.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
+@media (pointer: coarse) {
+ /* D83: the chip's × keeps the concept's 40px coarse target. */
+ .ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
+}