Commit Graph
17 Commits
Author SHA1 Message Date
Vito c7375573ed feat(images): set an image's own category, show its origin and count image categories (#332, PR 3) (#428)
* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* feat(vocabulary): count and rename image categories (#332)

Tags & categories lists every category value with image_count, the
stored image_categories rows holding it. Values only images hold follow
every prompt-backed value (first_pid at SQLite's INTEGER maximum), so
keyset cursors cross the boundary. /library/options keeps its own query
and never lists image-only values.

Manage category counts image rows (impact.images), treats an image-held
target as existing, renames image rows in the same transaction, allows
renaming an image-only value, and hashes the sorted image rowids into
the impact token so a change between preview and apply is refused with
vocabulary-conflict. Every image-category statement reads
idx_image_categories_category.

* feat(images): the category filter matches each image's effective category (#332)

An image's own category wins; without one it inherits its link's: the
recipe's category when set, else the positive prompt's (MC4, one rule
with the inspector chip). Ambiguous and unlinked images inherit nothing,
so an unlinked image with its own category is now listed. The link read
no longer filters by category in SQL; the page filter, select all N
matching and saved views compare the effective value. A failed
own-category read answers the existing listing-failed 500.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* feat(inspector): show an image's own category in the inspector and History (#332)

The inspector's library facts gain category_scope "image": an image's
own category overrides the inherited one for any link status (linked,
ambiguous, none). Images reads it for output sources only; History reads
it once per inspection inside its snapshot, keyed by the same file key
from the symlink-resolved output folder. A failed read hides the chip
rather than show a possibly wrong value. The chip's hidden prefix names
the origin ("Image category: "); the visible origin text comes in PR 3.

* perf(images): keep the category filter's link read narrow; record D141, D142, D145 (#332)

Under a category the link read keeps only links whose raw recipe or
positive category is the value (Python still decides the inherited
one), the links of images whose own category is the value are read by
path, and plain reads skip the inherited-category work. The own-category
map is keyed by normalized path. TESTING.md records D141, D142 and D145
and amends D13, D36 and D39; the filter budget seeds 2,000 own
categories.

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* fix(images): Windows path keys for own categories; plan check for the category read (#332)

Review fixes: on Windows the own-category map keys each file with
normalize_image_path (abspath drops trailing dots and spaces), and the
plan test also covers the category's narrowing clause.

* feat(images): Set category sets each image's own category; the chip names its origin (#332)

The selection bar's Set category writes the selected images' own
category (linked or not, up to 500, or all N matching up to 500), with
the toolbar categories as suggestions; Use prompt category removes it
after a confirmation that states the count. It never writes a prompt
(#259's prompt action is retired from Images; the Library editor and
the legacy admin Set Category still change prompt categories). The
toolbar lists the listed images' own categories beside the prompt and
recipe ones. The inspector chip shows its origin: "· this image",
"· prompt" or "· recipe". Tags & categories counts images and opens
Images for a category only images use.

* test(images): compare the category chip's visible origin against the concept (#332)

D146: the inspector comparison measures each chip's span.chip-source and
lays the concept's own chip out with the recorded rule and origin added at
runtime (withChipSource), in the side panel and the viewer's details. The
D39 recipe case asserts the visible " · prompt"; the mutation check
removes the origin.

* test(images): compare Set category and the category options against the concept (#332)

With one image's own category set through the real route, the toolbar's
#category-filter options are compared with D141 mapped (the image category
sorted in with the Library's, D36), and the Set category dialog against the
concept's simpleInput laid out as recorded at runtime: D140 (empty field,
datalist#workspace-input-suggestions of the toolbar's categories, Use prompt
category between Cancel and Apply) and D8 (Apply). Unmapped, the dialog
differs only in those parts.

* test(history): compare the History media inspector's category chip against the concept (#332)

D145: with an own category set on the baseline's newest output, the
Recorded run viewer's chip is compared with the concept viewer's category
chip laid out with the D146 origin and the own category at runtime; D21
maps its hidden "Image category: " prefix. Unmapped, the two differ only
in the text, the prefix, the width and span.chip-source.

* test(images): Set category sets each image's own category in page workflows (#332)

Replace the #259 prompt-category tests with CUJ 1, 2, 3, 8 and 10, the
suggestions datalist, the 500-image limit, the source-changed path, D142
and coded refusals; Images never posts /prompt_manager/bulk/category.

* test(inspector): the category chip names its origin in the panel, viewer and History (#332)

CUJ 4 and 5: "· this image", "· prompt" and "· recipe" chips, an
unlinked file's own category, and History's current-file inspector.

* test(vocabulary): category rename covers image categories; image-only rows browse Images (#332)

CUJ 6 and 7 with own categories set through the API on two output files.

* test(vocabulary): compare image-category rows and the Manage category preview against the concept (#332)

The vocabulary fixture seeds own image categories on request
(image_categories: Landscapes on two images, Portraits on one image only).
mapVocabularyConcept maps D143 ("N positive prompts · M images", image-only
rows before Manage category), a model checked against the concept page with
those rows added at runtime, and mapVocabularyDialog takes the preview
sentence, so the Manage category preview is compared with D9 and D144's
images. The default fixture and mapping are unchanged.

* test(vocabulary): park the pointer before measuring the category rows (#332)

Firefox kept the pointer where the concept's Review was pressed, over the
production Portraits row's Browse, so that button was measured hovered.

* docs(testing): record D140, D143, D144 and D146 and the image category UI (#332)

D140 (Set category writes images' own categories), D146 (the chip's visible
origin) beside D141, D142 and D145; D143 and D144 in the vocabulary
section. D8, D21 (the D20/D21 side panel) and D39 amended; the D8, the
"all N matching" and the Native vocabulary UI paragraphs name the Prompt
library editor and the legacy admin "Set Category" for prompt
categories; the #332 section lists the interface and its comparisons.

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* fix(images): focus after a changed source; Browse is a fresh Images visit (#332)

Review fixes: a 409 source-changed reload moves focus to the heading as a
success does; a selection the listing no longer holds says none could be
found instead of sending nothing; a Tags & categories Browse opens a
plain Images visit filtered by the category (remembered and with the
Appearance defaults) rather than a saved-view visit.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* refactor(images): take the selection bar's categories from its one caller (#332)

The getCategories default was never used (images.mjs always passes the
toolbar's categories) and left images-selection.mjs function coverage at 97.05
(floor 98).
2026-10-28 19:23:41 +00:00
Vito 4f37af209c feat(collections): store collections and add their routes and Images filter (#331, PR 1) (#400)
* feat(collections): store collections in the library database

* feat(collections): add, remove and rename collections and keep members through duplicate cleanup

* feat(collections): add collection routes and the Images collection filter

* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* fix(collections): address review findings

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* test(browser): install the slideshow clock before wrapping setTimeout

The slideshow fixture wrapped window.setTimeout before page.clock.install(),
so the viewer's timer ran on real time and clock.runFor() did not drive it;
a late WebKit timer failed 'slideshow exposes exact state labels' on CI.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
2026-10-28 18:49:06 +00:00
Vito b2cc489fd4 feat(inspect): copy settings summary and local workflow roles (#286)
* feat(inspect): copy settings summary in the full inspector

The legacy metadata page's COPY ALL (LI-4) becomes "Copy settings summary"
after the six facts of the shared full inspector, so the Images viewer,
the recorded history's current file and Inspect all offer it; the Images
side panel keeps the concept's footer. The text is the legacy copyAllData
template with its placeholders, filled from what the inspector shows. A
unit test runs the template read from web/metadata.html on the legacy
parse of every parity PNG: 22 of 25 are byte-equal, and the other three
differ only by the ZeroOut guard (#249) or facts the legacy parse missed.

Copying follows the concept's prompt copy: the toast "Settings summary
copied.", or the select-to-copy dialog (now titled per copy) without a
clipboard. The concept comparisons map the button as D-NEW-1.

* feat(inspect): local execution evidence and prompt tracing

A browser port of the server's inspection resolver for one sampler
(PR L3 Task 8, LI-1). local-resolver.mjs answers inspect_prompt_evidence's
{prompts, resolution} for a local read's records: execution evidence
(read_pair_execution and its per-node match), every traced strategy
(encoders, strings, concatenations, PromptManager and its historical
classes, passthrough, zeroed, selector branches, composites, guiders),
the latent stage walk, the R1 overwritten-text guard and the inspection
profile's visit, depth, text, path, built and scanned bounds, with the
same reason codes and outcome lines. py-json.mjs reads the records as
CPython's json.loads does (dict order and last-wins keys, int versus
float, BigInt past 2^53, NaN) and measures and strips text as str does.
Several sampler candidates, detailer stages and A1111 parameters raise
LocalResolverGap until Task 9. Nothing is wired into Inspect yet.

Python is the oracle: test_local_inspect_parity compares the port with
the server on the parity corpus, 107 named record sets, every work bound
at its edge and 600 seeded fuzzed graphs, and requires gaps to be exactly
the Task 9 paths. Sixteen porting mutations each fail the suite. The
Node unit test replays committed server answers, which the Python suite
keeps current.

* fix(inspect): a list or object workflow node type answers malformed

The R1 overwritten-text check looked every UI workflow node's `type` up
among the subgraph IDs. A `type` that is a JSON list or object raised
TypeError there, so the inspect route answered 500 "Prompt inspection
failed". Such a workflow is now malformed evidence: the resolution is
unresolved with reason_code `malformed` and the inspector's own
"Prompt evidence is malformed" line, never exception text.

The browser port answers the same, and the parity corpus gains the
list, object, nested-subgraph and skipped-ID shapes.

* fix(inspect): a list or object graph class_type is never a preview output

With several sampler candidates the inspector first drops stages whose
outputs reach only previews. That pass looked each API graph node's
`class_type` up in the preview class set, so a list or object
`class_type` raised TypeError and the inspect route answered 500. Only
a string names a class now; such a node is an ordinary terminal, as the
rest of the resolver already treats it.

* feat(inspect): local candidate selection matching the inspector

The browser port of the inspection resolver now answers every record set
the server does, so no path is left to the server any more:

- several sampler candidates, with the identical-branch rule (#270): a
  compare grid whose branches all resolve to one pair (text and recipe)
  is resolved, any other mix is ambiguous;
- stages whose outputs reach only previews are dropped first;
- detailer stages are judged with the samplers in their image ancestry
  (multistage-differs, cycles, depth and the per-stage visit budgets),
  and a sampler that only feeds detailers is not a candidate of its own;
- A1111 `parameters`, with Python's multiline anchors, `\s` and Unicode
  `\d`, the bare footer and Template trailers, counted in code points.

The parity corpus gains the selection cases and a seeded fuzzer, and
every inspection vector now gives the server's prompts, resolution and
facts locally. 16 of 17 mutations are caught; the uncaught one (not
flattening an ambiguous upstream stage) cannot change any answer.

* feat(inspect): local prompts resolved like the server's

A file chosen on the Inspect page now shows the prompts and resolution
the server's inspector gives the same records: buildLocalPayload takes
its roles from the local resolver instead of the interim open roles, so
the legacy fill applies exactly where it does for Images.

The resolver reads the server's own node table. The page fetches
web/js/inspection-semantics.json once through the API client as it
mounts, before any selection; the static route serves that one JSON
file by exact path (every other JSON stays forbidden). A table that
cannot be fetched answers "The inspector's node table is unavailable."
as the server does, and a later selection asks again.

The Copy settings summary deviation is numbered D117.

* fix(inspect): skip a workflow node whose type is a list or object

The overwritten-PromptManager walk now skips such a node, on the server
and in the browser port, instead of marking the whole file malformed.
A prompt that resolves still resolves; the other nodes still feed the
check.

* fix(inspect): one JSON depth cap for both browser parsers

py-json.mjs and local-video.mjs now refuse past the same 1,000 nested
arrays or objects, counting every container as CPython's scanner does.
Boundary vectors at 999, 1,000 and 1,001 for a deep prompt and a deep
workflow run through the Python oracle, and TESTING.md gives the
measured per-version server limits and both divergence directions.

* fix(inspect): realpath containment for the exact-path node table route

inspection-semantics.json now goes through the same realpath check as
every other static file, so a symlink out of web/ is refused. The
forbidden-path test asserts exactly 403.

* fix(inspect): abortable, stepped local reads with a busy state

The wait for the node table now ends with the selection's signal. A
local read resolves and projects in bounded steps with a turn of the
event loop between them, and the inspector carries aria-busy while it
loads. A budget test covers maximal 2 MiB graphs and checks the stepped
read is byte-identical to one synchronous build. The D117 wrap rule now
targets only the summary button, and TESTING.md scopes the restart line
to PRs L1 and L2.

* fix(inspect): record the two CPython-JSON emulations as a follow-up
2026-10-23 01:38:36 -07:00
Vito 195a1d33ac feat(annotations): Tag image dialog, card Tag button and inspector tag chips (#281)
* feat(annotations): image tagging controller with staged suggestions

Add the UI-agnostic Tag image dialog controller (#249 C1): observe the
file into a capture, prefill the saved tags and the server's tagging
defaults, stage the top suggestions as removable chips, and save manual
tags plus the remaining chips with case-insensitive dedupe (manual
spelling first). Every read is serial-guarded; saves are sent once and a
lost answer is resent identically only by Retry. Conflict, storage,
file and capture outcomes keep the draft and gate Save on a reopen.

ApiError now carries a validated copy of a tag-conflict body's current
tag set so the dialog can show it.

* feat(annotations): Tag image dialog beside Info on every image card

Every Images and Library-embedded gallery card shows the concept's Tag
after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs
over the C1 controller) in the shared modal shell with the sticky
tagging-modal footer: capture preview, manual tags, the four legacy
engines with real defaults, explicit Generate, removable suggestion
chips (D83), legacy model labels (D84), a Suggestions count (D91) and
Retry where the controller allows it. The capture is released on close,
route change and pagehide.

The Tags page gains the concept's Auto-tag images for the image selected
in Images (aria-disabled without one, D115) and real Images counts that
follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses
the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68
markers are renumbered to D83/D84/D91, and at extreme zoom a card's info
row stacks (and wraps Info and Tag) when its row is narrower than the
narrowest bounded card, list rows included (D31).

* feat(annotations): show image tags in the inspector and viewer

The inspector parses the B3 envelope's image_tags facet (strictly: exact
keys, current/none/unavailable, at most 100 vocabulary names) and draws one
chip row after the prompt blocks: the file's current image tags first, then
D21's linked tags not already shown (case-insensitive). In the Images panel
image tags are Tags filter buttons and linked tags keep D21's rule; in the
viewer and wherever no Tags filter exists every chip is plain. none and
unavailable add no chips (D88, plan D65; D21 amended).

The tagging fixture reset now drops leftover captures, so a long run of
tagging tests in one worker stays below the capture store's limit.

* fix(annotations): re-read a tagged file in place after a save

After a tag 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. While a Tags filter or a
search is active, Images re-reads its loaded range in place (same query,
from the first page up to the loaded count, published once), keeping the
cards, the scroll position and the selection while the selected file still
matches; when it no longer matches, the panel follows the collection.

* fix(annotations): give suggestion chips the concept chip corner

The D83 chips took the later `.tag-suggestion` row rule's 5px corner; the
concept `.chip` has 4px. The concept comparison of the Tag dialog found it.

* test(annotations): compare the Tag dialog with the approved concept

The Tag image dialog's inventory (order, text, field attributes, tokens,
preview box, chips, status, sticky actions) must equal the concept's at
1600x1100 and 390x844, dark and light, opened, generated with WD14 and
with a language model, for an empty result and in the review scope, and
the card's Info and Tag group must equal the concept's. Production runs on
the concept fixture with the concept image's tags saved and the fake model
answering the concept's own sample; only D29, D83, D84 and D91 are mapped,
and a mutation check shows the comparison notices a change.

The D3 footer check again asserts a strictly shorter footer, with short
seedless subtitles that stay on one line beside Info and Tag.

* fix(annotations): final review round for the Tag image dialog

- Option fields show the server defaults that Generate sends: the
  controller bumps an options revision on open and on a model change,
  and the panel re-renders only then (I1).
- A tag conflict shows the current set at once ("Saved now: ...") and
  puts it ahead of the draft in the manual field, so the next Save
  sends what is shown (I2).
- The opening sentence is the concept's on-screen copy; "Remove
  anything you do not want to add." leads the chips only while chips
  exist; the comparison's opened mapping is gone (I3).
- ApiClient gains an additive keepalive option; the capture release
  and the Save PUT use it, and pagehide closes the dialog (I4).
- Closing during a save keeps the capture until the PUT settles, then
  releases it and still reports the outcome (toast, page refresh) (I5).
- A refresh asked during a page load is pending and runs once it
  settles; re-reads use pages up to 200 files; a failed re-read
  toasts; Load more waits for a refresh (I6, M2, M3).
- The selection bar drops checked files a refreshed result no longer
  lists (I7).
- D116 records Retry; after a failed Generate it takes Generate's own
  path, and after a changed storage, file or expired capture it
  observes again keeping the draft (I9, M4, M5).
- A save made from Tags marks a filtered Images visit stale (M6).
- images.mjs drops under 800 lines: the tagging wiring moves to
  images-tagging.mjs and the favorites star to images-favorites.mjs
  (unchanged), and reset() reuses dropSelection() (M1).
- Restore the activity-scan.mjs coverage floors; drop the duplicate
  paired captures test (I8, M9).

* fix(annotations): coverage floors for the Tag dialog round

Floors for the new images-favorites.mjs and images-tagging.mjs, and the
measured raises of image-tagging-state.mjs and image-tagging.mjs.
activity-scan.mjs keeps main's floors.
2026-10-22 15:21:07 -07:00
Vito b7cf210f0f feat(annotations): image tag storage, vocabulary participation and capture store (#276)
* feat(annotations): add image annotation schema with restore validation

Install the image annotation group beside image_favorites and saved_views:
media_annotation_metadata (one row, schema version 1), media_annotation_files
(one private locator per physical file), media_annotation_bytes (one record
per observed SHA-256 with the verified size and mtime_ns) and media_image_tags
(the ordered tag set of a byte record, joined into the global tags registry).

The group migrates in a SAVEPOINT that validates before and after creating
it, with no backfill from prompt or pair tags and no filesystem probe.
annotation_schema_error reads only: it compares the catalog objects after
whitespace compaction, the columns, foreign keys and indexes, and the
persisted values (UUIDs, hex digests, bounds, the 4096-character locator cap
measured past embedded NULs, exactly one metadata row). verify_database_file
checks it with allow_missing=not require_library_state, so old backups
restore and a migrated restore stage must carry it.

Tests pin the exact DDL, a fresh install and idempotent reopen, a current
database without the group gaining it with legacy rows untouched, every
partial or malformed shape rejected without repair, install failure rolling
back and retrying, a WAL backup/restore round trip, staged-failure
immutability of source, live and safety files with every handle closed, and
the require_library_state gate.

* feat(annotations): persist per-image tag sets in the shared vocabulary

Add database/annotations.py (AnnotationOperations, mixed into
PromptDatabase): read_image_tags, replace_image_tags and add_image_tags
run in one _pair_transaction fenced by the storage epoch, own the byte
record by exact locator and digest, always supply UUID ids, rewrite the
ordered junction and bump tag_revision plus image_index_revision.
Revision mismatches raise AnnotationCommandError("tag-conflict") with
the current set; the identical set at the current or previous revision
is replayed without writes; a union over 100 names is "tag-limit".
normalize_image_tags validates each name with validate_vocabulary_tag_name
and folds case-insensitive repeats keeping the first spelling.
refresh_image_fingerprint, current_image_tags (chunked, fingerprint
matched, newest verification wins) and iter_image_tag_records serve the
S1 cheap reads.

Vocabulary participation: _move_tag_links also carries media_image_tags,
legacy merge_tags moves image-only sources, tag rows gain image_count
(files with any byte record carrying the tag), impact gains images and
rename/delete receipts gain images_changed. vocabulary-state.mjs accepts
the optional counts and names images in the delete text.

Browser fixtures clear the three annotation data tables on reset (never
the metadata row); TESTING.md documents the schema beside favorites.

* feat(annotations): add bounded private media capture store

Add utils/media_capture.py: CaptureStore copies one rooted media file
into an owner-only prompt-manager-captures-* temp directory, streaming
it in 1 MiB chunks while computing the whole-file SHA-256, and compares
the opened descriptor with the path (dev, ino where meaningful, size,
mtime_ns, ctime_ns) before and after the copy; any disagreement raises
CaptureChanged and removes the copy. Symlinked files or directories,
root escapes, non-media names and directories raise CaptureDenied; the
locator is normcase(join(realpath(root), relative)), the listing's
identity, so a symlinked root matches it.

Limits are the plan's: 256 MiB per file on stat and stream
(CaptureTooLarge), 8 completed captures, 512 MiB reserved and 2
capture/verify workers with nonblocking admission (CaptureCapacity),
900 s idle and 1800 s hard lifetimes on an injected clock. acquire and
release_use keep an in-use capture past expiry, release and
invalidate_all until its last use ends; close removes only owned files
and the owned directory. A header-only probe of the copy marks Generate
unsupported above 4096 px per side or 16,777,216 px, for unreadable
headers and for video; verify re-hashes the current file without a
copy. Every handle closes before unlink.

tests/test_media_capture.py covers exact bytes and owner-only modes,
alias and escape denial, the symlinked-root locator, change detection
during the copy (size, same-size rewrite with restored mtime through
the POSIX change time, inode replacement), both size caps, count, byte
and worker admission, idle and hard expiry with in-use retention,
cleanup scope, handle release at unlink, dimension and video
readiness, and the allowlist equality with the inspection service.
TESTING.md documents the store and the Windows change-time limit.

* test(annotations): wait on the POSIX change time only where it exists

The same-size rewrite case waits for the filesystem clock to pass the
source's ctime so the detection is deterministic on Linux's coarse
timestamps; Windows reports the creation time as st_ctime, where the
probe could never advance, so the wait runs only on POSIX.

* fix(annotations): compare the change time only between fstat reads

Python 3.12+ on Windows reports the change time from stat but the
creation time from fstat, so a freshly written file whose creation and
write straddle a clock tick failed the pre-read identity check with
CaptureChanged. The path-versus-descriptor checks now compare size,
mtime_ns and dev/ino; ctime_ns is compared between the fstat before
and after the copy, where it still detects a same-size rewrite with a
restored mtime on POSIX.

The symlinked-root test asserted the locator against the unresolved
temp path, which macOS (/var -> /private/var) and Windows (short names)
resolve differently; it now compares against the real path.

* test(browser): give the inspector evidence matrix the concept comparisons' budget

The touch run of 'full inspector attaches matched theme and mobile
evidence with reachable content' captures 32 screenshots across four
viewports and took 23.5 s, then 31.6 s, in the sharded coverage job,
hitting the 30 s default. Like the concept comparison specs it now sets
an explicit 120 s timeout; the CI-like pinned run (4 CPUs, 3 workers,
repeat-each 3) passes 198/198 with the matrix at 18-20 s on touch.

* fix(annotations): compare capture identity like with like and keep paths out of errors

Native Windows CI (Python 3.12/3.13) rejected untouched files with
CaptureChanged because the path stat and the descriptor fstat disagree
there (change time versus creation time, possibly dev/ino encodings).
The descriptor is now checked against the path only on the API-stable
facts (size, mtime_ns); the full identity (size, mtime_ns, ctime_ns,
dev/ino where meaningful) is compared between the fstat before and
after the copy and between the path stats before and after. A test
injects shifted fstat results to simulate the Windows discrepancy, and
a fresh file is captured twice with no platform guard.

Error messages carry strerror only, never the path (the path goes to
the debug log); a test asserts no CaptureError names the root. Files
are unlinked outside the store lock; close() during an in-flight
capture lets that worker remove its temp file and then the directory.
get() documents that readers of capture.path must acquire/release_use.
The test fixture resolves its temp root (macOS /private/var, Windows
8.3 names). TESTING.md states the real identity contract.

* fix(annotations): move image revisions when a fingerprint or the vocabulary changes

A replayed save that observes a new verified (size, mtime_ns) now bumps
image_index_revision, like refresh_image_fingerprint, so caches keyed
on it see the record become current again; an identical replay still
moves nothing.

Vocabulary rename, merge and delete (native and legacy paths) bump the
tag_revision of every image set carrying the source tag, in the same
transaction and before the links move or cascade, plus one
image_index_revision bump when any set was affected. A Tag dialog
opened before the change therefore gets tag-conflict with the renamed
set as current instead of recreating the old name.

* test(annotations): accept change detection wherever fstat exposes a change time

Native Windows CI on Python 3.13 detected the same-size, mtime-restored
rewrite through the descriptor's change time (3.10-3.12 report the
creation time there and complete the torn copy). The test asserted the
platform, not the signal: it now accepts CaptureChanged plus temp
removal wherever it happens, requires detection on POSIX and on
Windows 3.13+, and otherwise requires the digest to describe the
copied bytes. A new test injects a drifting fstat change time so the
detection path runs on every platform; TESTING.md records the limit as
"Windows before Python 3.13".

* test(annotations): assert the rewrite outcome, not the platform

Native Windows CI detected the same-size, mtime-restored rewrite on
3.12 and missed it on 3.13 in the same run: the path stat's change time
(Python 3.12+ fast path) only moves when the clock ticks between the
pre-stat and the rewrite, and the tick wait was skipped on Windows. The
wait now runs everywhere with fresh probe names (Windows tunnels the
creation time of a reused name), and the test asserts the outcome: a
detected change leaves no temp file; an undetected one is accepted on
Windows only and must produce a digest of the copied bytes; POSIX must
detect it. The injected-fstat test keeps the detection path covered on
every platform.

* fix(images): keep the Filters label node across a listing reload

Every listing reload reports the active refinement count, and
setActiveFilters rewrote the button's textContent even when the label
was unchanged, replacing its text node. WebKit drops a click whose
mousedown hit a node that was detached before mouseup, so a Safari
user who types a search and clicks Filters inside the 250 ms debounce
lost the click, and the webkit page workflows timed out in the
source-change recovery tests (1 in ~30 runs of the pinned image; an
instrumented probe showed pointerdown/mousedown, a DOM mutation,
pointerup/mouseup and no click event).

The label is rewritten only when it changes. The toolbar unit test
counts textContent writes (1 for an unchanged count before, 0 after),
and a workspace-images test pins the text node identity across a
search reload: 0/6 before the fix on chromium and webkit, 6/6 after,
with the recovery and Refine tests 36/36 in the pinned CI-like run.
2026-10-22 07:19:53 -07:00
Vito f95f66d76a ci(browser): run page workflows with parallel workers and CI shards (#274)
* test(browser): one test server per Playwright worker

The page workflows started one global webServer and ran with one worker.
Each worker now owns its own tests/browser/server.py on an OS-chosen port,
with its own temp root and database, through a shared `test` in
tests/browser/fixtures.mjs. baseURL, and so page, request and
browser.newContext, point at that worker's server. A server that dies
fails the running test with its stderr tail and the next test gets a
fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI,
else a quarter of the CPUs up to 8.

Chromium workflows in the pinned image: 510 tests in 606 s with one
worker on main, 511 (one new isolation spec) in 119 s with eight.

* test(browser): test servers bind an OS-chosen port themselves

tests/browser/server.py accepts --port 0: it binds 127.0.0.1:0 before
building the app, holds that socket for its whole life and prints
PM_TEST_SERVER_PORT=<n> to stdout. startBrowserServer defaults to port 0
and reads the announced port, so no port is reserved and released before
the server binds and two launchers can no longer race for one. The
"address already in use" retry and free-port.mjs are gone; --port N still
works and is checked against the announcement.

* ci(coverage): shard and merge modes for the frontend coverage run

frontend-coverage.mjs now plans its steps from the command line.
No arguments still runs everything, then reports and checks.
--shard I/N runs one slice of the chromium+touch workflows into
coverage/js/raw without wiping it or reporting; shard 1 also runs the
node units and the viewer suite. --merge-only reports and checks over raw
coverage the shards wrote, with --update-baseline as before, and refuses
an empty or missing raw dir. npm scripts: test:coverage:shard and
coverage:merge.

* ci(viewerjs): run browser suites with workers and shards

Frontend coverage runs as three shards (PM_TEST_WORKERS=3 each) that upload
their raw V8 files as coverage-raw-N; a merge job downloads them, fails unless
all three arrived with raw files and no clashing names, then runs
coverage:merge. Firefox and WebKit page workflows run as two shards each with
three workers. The required viewerjs context keeps its name and now needs the
shards, the merge and the browser shards, failing unless every one succeeded.

* ci(viewerjs): cache npm downloads and size timeouts from the first sharded run

The first sharded run lost a webkit shard to its 12-minute limit after all
267 tests passed, and npm ci stalled for 5 minutes in six of eight jobs. Each
npm ci now restores npm's download cache keyed on the vendor lockfile, and
browser job timeouts are the measured times plus at least 50%, never under 20
minutes. The workflow comment records the measurements.

* test(browser): wait for rendering frames, not timers or luck, under load

Two Library workflows failed in CI's webkit shard 2/2 (run 39714840809)
under parallel load:

- "library infinite loading ..." counted cards right after the last
  page hid "Load more". That render queues the window update for the
  next frame, so a slow frame left the 120 cards of the all-rows
  layout in place (received 120, expected < 80). It now waits for the
  next frame, which runs after every callback the page queued.
- "row measurement preserves anchor ..." settled with a 60 ms timer. A
  scroll event is delivered in a rendering frame, so on a starved
  renderer the timer ended before the scroll's frame and no card was in
  view (null anchor). settleLibrary now waits for two consecutive
  native frames with no Library frame queued, checked inside the frame
  callback.

Before/after: a scratch copy that delays every frame by 120 ms (as a
starved runner) failed the old helpers 19 of 40 runs, at the CI lines,
and passed the new ones 40 of 40 on chromium and webkit. Throttled to
1 CPU with 4 workers, the original spec failed 1 of 42 for each test.

The Tags viewer workflow closed the viewer as soon as its image was
visible, before ViewerJS rendered the title, so CI's coverage never ran
the page's title callback (tags-page.js functions 26/46 = 56.52%, one
function above the floor of 56; locally 27/46). The test now asserts the
title names the shown file, which always runs the callback.

* test(browser): stable under parallel workers

Document how to run the browser suites in parallel and sharded, with the
measured times, and what keeps specs stable beside each other: wait for
a rendering frame, a response or a visible state, never for time.

With b665aa6 the full workflows suite passed on chromium, touch, firefox
and webkit with 8 workers, each test 3 times (6132 passed, 0 failed,
24 minutes), and once at CI size (docker --cpus=4, 3 workers: 2044
passed, 25.5 minutes).

The npm download cache removed the npm ci stall: 0 to 2 s in every job
of run 39722172529, against about 300 s in most jobs before. The
workflow's timing comment now records that run; the whole workflow took
8m54s against about 33 minutes before sharding. Shard imbalance (shard 1
carries the concept-comparison specs) is documented, not tuned.

* test(browser): server stderr on failures, no orphan servers, waits on state

Final review fixes for the per-worker test servers (#274):

- A failed test gets the stderr its server wrote during that test
  attached as `server-stderr`, so an aiohttp traceback behind a 500
  reaches the CI report. The launcher keeps the newest 256 KB with a
  stream position per test (stderrMark/stderrSince), and the fixture
  attaches it only when the test did not end as expected.
- The port announcement counts only as a complete line; a chunk ending
  mid-number was read as a shorter port.
- server.py --exit-on-stdin-eof shuts down gracefully (database closed,
  temp root removed) when stdin closes. The launcher holds that pipe,
  so a SIGKILLed worker leaves no server behind, on every OS.
- settled() gives up after 600 frames with the observation stats
  instead of hanging to the test timeout.
- The Classic text-sync test waits for the page's 50 ms timer to
  install serializeValue instead of sleeping 60 ms. With that timer run
  100 ms late (a scratch copy), the old wait failed 20/20 and the poll
  passed 20/20; the spec passed 240/240 at 8 workers x10 and 240/240
  at 1 CPU with 4 workers.
- The workflow test checks each npm cache key names its job's pinned
  image tag.

Every new unit test failed before its change (missing export, the old
regex timing out on the split announcement, an unknown server flag, a
hung settle, a mismatched cache key).

* docs(testing): server lifetime, stderr attachments and the final measured CI times

TESTING.md describes the stdin-EOF shutdown and the server-stderr
attachment, states that the npm cache key names the job's image tag
(now tested), quotes run 39725176320 at cbc8a61 (8m38s) and explains the
20-minute browser job limit. The workflow timing comment quotes the same
run.

* fix(browser): server shutdown and its tests on Windows, Python < 3.13 and CI

Three CI failures on ed809f7:

- Windows: server.py exited 1 on stdin EOF because create_app works with
  the temp root as its cwd, and Windows cannot remove a process's cwd.
  main() now restores the original cwd before the root is removed.
- Python 3.10 to 3.12: the lifetime test's cleanup called communicate()
  after closing stdin, which flushes the closed pipe (ValueError); 3.13
  tolerates it. It now waits and closes the pipes itself, and the exit
  assertion shows the server's stderr. Under 3.12: errors=2 before, OK
  after.
- Tooling (CI container): the SIGKILLed-worker tests treated an exited
  server as alive, because the container's PID 1 does not reap orphans
  and kill(pid, 0) succeeds on a zombie. Reproduced locally with docker
  without --init (2 failed); a zombie now counts as exited (2 passed).
2026-10-21 23:10:57 -07:00
Vito 1ff2ddce05 fix(inspector): fall back to the positive's category and tags for recipe-linked images (#267)
* fix(inspector): fall back to the positive's category and tags for recipe-linked images

Maintainer ruling D39 (2026-10-21): a recipe-linked image shows the recipe's
own category and tags when set, otherwise the linked positive prompt's, each
field on its own and without merging tag sets. Set category (#259) and the
Images category and Tags filters work on the positive, so the panel now agrees
with them.

The inspection library replaces metadata_scope with category_scope and
tags_scope so the category chip's hidden scope prefix (D21) and the tag chips'
filter-button rule (#260 D2) follow the value actually shown. History's media
inspector uses the same shared rule. The concept fixture's positive_links
workaround is gone: the comparison now matches the concept without it.

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

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

* test(inspector): cover mixed-scope panels, invalid library scopes and the skipped positive tags query
2026-10-21 04:18:28 -07:00
Vito 10a4847a6b feat(history): concept heading, negative variants and compare (#265)
* feat(history): concept heading and summary

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

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

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

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

* feat(history): concept negative variants panel

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

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

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

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

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

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

* feat(history): compare negative variants

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(inspector): concept panel and viewer details

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(library): concept comparison for the shelf

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

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

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

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

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

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

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

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

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

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

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

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

* feat(inspector): concept side panel

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

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

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

* feat(inspector): full details in the viewer

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

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

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

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

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

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

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

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

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

- Prompt history closes the viewer and, when its target is the route
  already shown, re-renders it as the concept's go() does (navigateTo
  plus a router refresh event), instead of doing nothing.
- The viewer details' disclosure summaries and "Download original" keep
  40px targets on coarse pointers (#249 D34), as TESTING.md promises.
- Drop the dead `.prompt-positive` rules and merge the two
  `pre.prompt-text` rules.
- TESTING.md records the side panel's D20/D21 deviations.
2026-10-20 20:15:21 -07:00
Vito 86d82186ee feat(images): concept Images page — toolbar, cards, star, viewer, compared against the mock (#258)
* feat(workspace): shared concept toolbar and dialogs

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

Refs #249

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

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

Refs #249

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #249

* feat(images): concept hover zoom

* feat(images): favorite images from the card

* feat(images): concept viewer shell

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A longer paste was sent as is, the API rejected it and the collection
showed "Images could not be loaded". The legacy search capped it.
2026-10-20 11:31:53 -07:00
Vito 9d398f9862 feat(workspace): concept shell — sidebar, topbar, heading and footer on the approved mock's CSS (#256)
* build(workspace): vendor the approved concept stylesheet with a drift check

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

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

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

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

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

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

* test(workspace): concept fixture and shell comparison

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

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

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

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

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

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

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

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

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

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

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

A confirmed Library or vocabulary write cannot change the Images count,
but it re-read all three, including the Images output listing. It now
re-reads only the Library and Tags counts. Each count has its own read,
so a write never cancels an Images read that is still in flight.
2026-10-19 23:04:40 -07:00
Vito d6eba05d21 fix(inspect): show the prompts and settings the legacy parser finds (#251)
* fix(inspect): show the prompts and settings the legacy parser finds

The workspace inspector took its prompts from the new server resolver,
which refused workflows where several samplers share one prompt (base
pass, upscale pass, FaceDetailer), and showed "Multiple possible
prompts" for files the legacy gallery and admin pages read correctly.

The inspector now runs the same extractGenerationParams those pages
use and shows its positive, negative, seed, steps, CFG, sampler and
model whenever it finds them. Prompts recorded at execution or saved
in the Library are never replaced; when the legacy parser finds
nothing, the server result stays.

Read-only check over a 38,602-file output folder: prompts shown went
from 1,798 to 7,542 files, and every one of the 7,236 positives and
9,421 negatives the legacy parser finds is shown identically.

* fix(inspect): keep definite server prompts over the legacy parse

The legacy parser follows ConditioningZeroOut back to the positive text
node and shows the positive as the negative. Apply its result only to
roles and settings the server could not decide, so a definite answer
such as an explicitly empty negative is never replaced.

Node callers (the corpus facts tool and the parity test support) now
pass the parser explicitly instead of relying on a browser global.

On a 38,602-file output folder, prompts shown stay at 7,542 files. Where
the server and the legacy parser both answer, they differ only in
surrounding whitespace (793 roles), except 227 zeroed negatives the
legacy parser misread and one prompt that is a JSON block.

* fix(inspect): never fill a setting the file does not record

The legacy parser can take a FaceDetailer as the generation sampler and
report its CFG for a workflow whose guider records none. Fill a setting
from the legacy parse only when the server left it unresolved or
ambiguous; a known value or a definite "not recorded" stays.

The corpus and parity Node tools again measure extractGenerationFacts
on its own, the contract their fixture vectors describe.

* fix(viewer): destroy an inline Viewer only after its pending view settles

Viewer.js's inline destroy() only unbinds and leaves an in-flight image
view running; when it finishes it reads the destroyed item list and
throws "c is undefined". Closing the preview while an image was still
loading hit this in Firefox about once in 15 runs of the recorded-run
history test. A closed viewer is now destroyed once that view settles,
its image fails, or a 10 second bound passes.

The history test passed 60 of 60 runs in Firefox with the fix.

* fix(viewer): keep the image src until a closed Viewer is released

Viewer.js rebuilds its item list from images that still have a src. On
close, the src was removed before the viewer cleanup ran, so a pending
view found no items and threw "c is undefined" (WebKit and Firefox).
With Viewer.js, the src is now removed after the viewer is destroyed,
once its pending view settles.

The recorded-run history test passed 40 of 40 runs in WebKit and in
Firefox.
2026-10-19 20:05:55 -07:00
Vito 6d2213ead5 feat(inspect): declarative node support matrix (#250)
* feat(inspect): add a declarative inspection node semantics table

* fix(inspect): bound and isolate the semantics table

Bound the semantics loader: at most 1 MiB is read, containers may nest
at most eight levels, and a RecursionError from the JSON parser becomes
a SemanticsError instead of escaping.

The table is no longer loaded at import. get_semantics() reads it on
first use and caches only a valid result, and the resolver imports it
lazily, only for a profile with semantics on. The default ingestion
profile never imports the module, so a missing or corrupt table cannot
break scan, monitor or admin import. Inspection of a prompt graph with
an unusable table is unresolved with the reason "Inspection node
semantics are unavailable" and an error log, rather than a crash.

INSPECTION_PROFILE now has semantics on; no strategy consumes the table
yet, so resolution is unchanged.

* feat(inspect): resolve guider-based samplers

Under INSPECTION_PROFILE the resolver discovers samplers from the
semantics table and follows SamplerCustomAdvanced through its guider
link (Reroutes allowed) to a listed guider:

- CFGGuider: positive and negative, so the pair resolves.
- BasicGuider: positive only. The state is "absent" with the reason
  "Guider has no negative input", the existing missing-negative path.
- Any other guider, including DualCFGGuider (D17), is unsupported-node
  and names that guider.

Guider hops draw visits, scan and depth from the existing budgets. A
SamplerCustomAdvanced upstream in latent ancestry is an earlier stage,
so the result stays multistage-ambiguous. Positive and negative
provenance record guider_id. FaceDetailer (detailer-stage) is not a
candidate yet: deciding it needs output ancestry. DEFAULT_PROFILE never
loads the table and is unchanged.

Vector changes (inspection profile only, default vectors unchanged):
- f07b: no-sampler -> resolved (CFGGuider).
- f17: no-sampler -> unsupported-node (FluxGuidance, Task 2.3).
- f01b: no-sampler -> unsupported-node (EmptyFlux2LatentImage latent,
  Task 2.5).
- f01: no-sampler -> ambiguous (24 SamplerCustomAdvanced branches).

The no-sampler tests (resolver and inspect route) now use a sampler
class the inspector does not know, because f17 has a supported one.

* feat(inspect): trace conditioning through supported pass-through and selector nodes

* feat(inspect): treat detailers as sampler stages

* fix(inspect): choose selectors that share one condition together

* fix(inspect): bound detailer stage chains by the depth limit

* feat(inspect): support common text encoders and string sources

* fix(inspect): keep pair execution evidence behind unreadable string sources

* feat(inspect): accept any non-sampler latent origin

* fix(inspect): queue each node once in breadth-first ancestry walks

The latent and detailer image walks queued every link before checking
whether its node was already seen, and popped from the list head. A dense
shared fan-in grew the queue far past the visit budget before any visit
was charged. Both walks now use a deque and enqueue each node at most
once, charging every examined edge as scanned work; outcomes are
unchanged.

* fix(inspect): treat unlisted pipe and embed samplers as possible stages

The latent and detailer ancestry walks only flagged unlisted nodes with a
linked latent_image, guider, positive or negative input, so easy-use pipe
samplers, Impact pipe detailers and embed-driven video samplers were walked
through and the final sampler's prompt resolved silently. A separate
stage-shaped input set (adding pipes, embeds and custom-sampler parts)
now makes such nodes unsupported-node, named.

* fix(inspect): walk each sampler's latent ancestry once per resolution

Every selector branch run and candidate re-walked the same latent
ancestry, so conditioning-carrying origins such as WanImageToVideo turned
a three-selector graph from ambiguous into work-limit. The walk outcome is
now cached per sampler; branch runs of one candidate pay for it once and
other candidates are charged its visit cost, as the text memo does. Walks
that ran out of work are not cached. Every link input is still walked.

* fix(inspect): treat unlisted model-driven samplers as possible stages

Unlisted samplers that link a model with conditioning or a latent under
other input names (XlabsSampler, generic video samplers) were still walked
through, so the final sampler's prompt resolved silently. An unlisted node
with a linked model plus conditioning, neg_conditioning, samples or latent
is now unsupported-node, named; model patches such as LoraLoader and
ModelSampling nodes are not.

* feat(inspect): compute facts from the resolved sampler

* fix(inspect): merge facts across sampler branches when ambiguous

* fix(inspect): merge facts over unresolved stages and share stage inputs via the table

* feat(inspect): explain why a prompt could not be resolved

* feat(inspect): port legacy parser rules the new resolver missed (PromptManager)

R1: before 3.2.3 a save patch overwrote every PromptManager node's saved
text with the last-run node's prompt. The legacy viewer recognizes the
signature (comfy-metadata.js createContext: two or more PromptManager
workflow nodes with one API text and different widget values). Inspection
now refuses those nodes as malformed instead of resolving to another
node's prompt; the inspector hands the parsed workflow to the resolver for
this check only. Lands before the new pass-throughs (R6) that would
otherwise make these graphs resolve to the wrong text.

R7: this repository's node classes up to 07a5af3 (PromptManagerV2,
Positive, Negative, Detailer, V2Text, PositiveText, NegativeText) resolve
with their own outputs and their exact strip and ", " join.

The default (ingestion) profile ignores both.

* feat(inspect): port legacy parser rules the new resolver missed (A1111)

R3: an accumulating batch saver repeats the A1111 parameters record once
per batch item, identical except "Batch index: N". The bounded PNG reader
now keeps the last copy (Pillow's rule, which #165 relied on) instead of
calling the file unreadable. Any other difference, including a missing or
second batch index, is still a conflict, and decoding the copies draws from
the duplicate role budget.

R4: parameters text that starts with "Negative prompt:" has an empty
positive (#165), instead of showing the delimiter line as the positive.

R5: the settings footer may be followed by sd-dynamic-prompts' one-line
"Template:" and "Negative Template:" records, and may be "Steps: N" alone
when there is no full footer.

R10: commas inside A1111's quoted settings values no longer split them, so
an "ADetailer prompt" with a comma keeps the other facts known.

The facts' A1111 reader mirrors R4, R5 and R10; the default (ingestion)
profile keeps the historical split. Also from the Task 2.7 review: a guard
test fails on any uncoded unresolved reason without a user line (M1), and
the encoder bounds role lines to the client's 512 code units (M4).

* feat(inspect): port legacy parser rules the new resolver missed (installed nodes)

R6 and R9 for nodes whose sources are installed beside ComfyUI, each
entry's input names and output slots read from the node's code (the task
report lists file and line):

- detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale
  (upscaled_image) and DetailerForEach (image), judged with their upstream
  samplers like FaceDetailer;
- pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning
  (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod;
- encoder Text to Conditioning, strings TextInput and DisplayText;
- facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader
  (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance.

Impact Pack encodes a detailer's non-empty wildcard instead of its positive
input, so FaceDetailer and DetailerForEach now list it as a prompt
override: a literal wildcard is decided at run time and a linked one names
its source, instead of resolving to the positive input. The overwritten
PromptManager refusal (R1) still holds behind the new pass-throughs. The
default profile does not read the table.

* feat(inspect): port legacy parser rules the new resolver missed (upstream nodes)

R6 and R9 for nodes not installed beside ComfyUI here. Their input names
and output slots come from each pack's source at the commit the saved
workflows record in the node's cnr_id and ver properties (the task report
lists repository, commit, file and line):

- samplers ClownsharKSampler_Beta (RES4LYF), KSampler (Efficient) and
  ttN KSampler_v2 (latent input "latent");
- pass-throughs SeedVarianceEnhancer and ApplyInstantIDAdvanced (positive
  1, negative 2; output 0 is the model);
- selector Switch conditioning [Crystools];
- encoder smZ CLIPTextEncode: its text when with_SDXL is false; when true
  the connected CLIP decides at run time which inputs are read, so the
  prompt is known only when text, text_g and text_l agree;
- strings ttN text, CR Text, CR Prompt Text and TextBoxMira;
- facts model pass-through CR Apply LoRA Stack.

Inputs that replace what a sampler reads are not followed: RES4LYF options
(may carry a guider, and with sigmas replace the settings), an Efficiency
script and a ttN XY plot leave the prompt unsupported and the facts
unresolved. The overwritten PromptManager refusal (R1) still holds behind
SeedVarianceEnhancer. The default profile does not read the table.

* test(inspect): cover batch copies past the role budget and orphan widgets

* fix(inspect): match the batch index only in the settings footer

Review I-1: duplicate A1111 parameters copies were compared after blanking
"Batch index: N" anywhere in the record, so copies whose prompts differ
only by that phrase were accepted. The field is now blanked only inside
the record's one "Steps: N, ..." footer line, which must hold it exactly
once; any other difference stays a conflict.

Review M-4: the inspector parses the workflow record only when the API
graph can show the overwritten-PromptManager signature (a PromptManager
class, or a node with PromptManager's search_text input).

* fix(inspect): refuse overwritten PromptManager text inside subgraphs

Controller ruling on review M-1: PromptManager nodes inside subgraphs run
as "<instance>:<inner>" API IDs, nested instances chaining their IDs. The
overwritten-text check now walks subgraph instances (bounded in depth and
nodes) and reads a text widget stored by name, so these nodes are refused
like top-level ones, anchored or not. A definition without an instance
runs nothing and is ignored.

Review M-5: the historical adapters skip an explicit null prepend or append,
as the 07a5af3 code's "if prepend_text:" did.

* test(inspect): import unittest one way in the inspection suites

Code scanning flagged the mixed 'import unittest' and 'from unittest import
mock' forms. Use 'import unittest' plus 'import unittest.mock' everywhere.

* fix(history): name the recorded run's current-file prompts apart from its frozen fields

Now that the inspector resolves more saved files, the Recorded run dialog can
hold two regions named "Positive prompt" (and two of each Copy/Send action):
the frozen run field and the inline current file's prompt. Assistive
technology could not tell them apart, and the real-host chronology test read
two <pre> elements.

Prompt blocks take an optional context label. The inline current file inside
a recorded run names its regions "Current file positive prompt" and its
actions "Copy/Send current file positive prompt"; the visible headings stay
as in the concept. The real-host test now also scopes its frozen-field reads
to the "Frozen run prompt texts" section, and a disposable-server workflow
pins the distinct names with a resolved current file.

* fix(inspect): drop preview-only stages from unanchored candidates

A stage whose outputs reach only non-file outputs (PreviewImage and the other
non_file_outputs) cannot have written a saved file, yet it stayed an
unanchored candidate, so a preview-only FaceDetailer made the saved branch
ambiguous (f01b).

The inspection profile now drops such stages before detailer absorption,
using two linear backward passes over the graph (charged like the consumer
map; a failed pass keeps every stage). A stage without consumers, or one that
also reaches any other terminal, is kept, and nothing is dropped when every
stage is preview-only. Raw plus detailed double saves both write files and
stay ambiguous. A file in the host temp folder may itself be a preview, so it
is inspected with PREVIEW_INSPECTION_PROFILE, which keeps those stages.

Workspace facts follow the same choice: an absent or unresolved result that
names a sampler merges facts across that sampler's stages only, so the
dropped detailer's settings no longer blur the saved file's facts.

f01b now resolves (absent negative, guider has none); its vector is updated
and the expected failure removed. The default profile is unchanged:
differential against 39dcc40 identical over 160,256 cases. Over 60,054
inspection cases no previously resolved or absent result changed.

* fix(history): name the current file's prompt pair button apart from the frozen run's

The recorded run dialog nests the current-file inspector, whose footer repeated the "Send prompt pair" name. Name it from the prompt context, scope the real-host test to the frozen region, and add a dialog-wide duplicate-name check.
2026-10-19 17:26:37 -07:00
Vito 048d599386 fix(inspect): bound inspection work, not graph size (#244)
* test(inspect): add synthetic inspection parity fixtures and legacy characterization

* test(inspect): pin test-time parity vectors and keep fixtures fully synthetic

* fix(inspect): bound resolver work instead of graph size

* fix(inspect): bound shared-node and stripped-text work in the inspection profile

Keep one memo per resolution whose entries carry their unfolded visit cost;
a memo hit charges that cost to the current candidate's allowance only.
Cache node validation and charge input keys, stripped characters and copied
provenance entries to a whole-resolution work budget before doing the work.
The default profile is unchanged.

* fix(inspect): keep memoized depth honest across inspection candidates

A memo entry now records its height below the reusing depth; a hit raises
depth-limit exactly where a fresh walk would, so each candidate's outcome
matches its anchored result and does not depend on trace or node order.
The default profile is unchanged.

* test(inspect): pin memo height propagation

Three samplers share one chain, each later one hanging below a memo hit.
The third candidate must report depth-limit in both trace orders and when
anchored; removing the memo-hit height propagation fails this test. The
provenance test now asserts a plain subset.

* fix(inspect): tolerate ComfyUI node metadata quirks in projections

Projections now take a kind. Each prompt node keeps exactly class_type and
inputs; _meta, is_changed and other node keys are validated and dropped, so
"is_changed": [NaN] no longer marks the whole prompt graph malformed. A
nonfinite value inside inputs, anywhere in a workflow, or in a retained
carrier member projects to null and is never reported as a number. The
carrier applies the prompt/workflow kinds to its members. Exact numbers are
unchanged (Decimal spelling, unsafe integers as strings) and duplicate keys
are still malformed. The 500-node gate is replaced by a 20,000-node backstop
(workflow count includes subgraph definitions). Raw records are untouched.

The f14 inspection vector's facts become known; default vectors are unchanged.

* fix(inspect): budget metadata per record so large workflows never hide prompts

PNG inspection no longer counts every text chunk against one shared 2 MiB
budget. Role records (prompt, parameters, the pair execution record) keep a
2 MiB budget together, encoded and decoded, and exceeding it still fails the
whole read. A workflow has its own 4 MiB budget: a chunk over it is skipped
by seek, and a compressed one stops inflating at budget + 1. Only the
workflow is then reported as limited (record format "limited", no raw text,
workflow_graph limited); prompts, facts and the prompt graph are unaffected.

Each text chunk's keyword is read first (at most 80 bytes). Chunks that are
not retained are skipped by length without being read or checksummed, so
memory stays bounded. Byte-identical duplicates of a retained record are
accepted and do not consume budget; differing duplicates still raise
"Duplicate prompt metadata", and a duplicate of a limited workflow stays
limited. The chunk-count limit is unchanged.

InspectionRead gains limited_records (empty by default). The reader is only
used by the inspection routes, so ingestion, scan and import are unchanged.

Vectors: f04-inspection (2.5 MB workflow, within the new workflow budget)
and f15-inspection (identical duplicate chunks) now read and resolve, with
the same outcome and facts as f05-inspection. Default vectors are unchanged.

* fix(inspect): keep prompt evidence longest under the response budget

* fix(inspect): bound identical duplicate chunk reads

* fix(inspect): fail closed when role duplicates exceed their budget

* fix(inspect): use the bounded inspection profile end to end

Inspection routes resolve with INSPECTION_PROFILE and expose reason_code
and node on the resolution; scan, live monitor and admin import keep the
default profile (pinned by spies over the real resolver on f03).

JS facts drop the entries and workflow node-count gates; per-lookup visit
sets and the server's node ceiling bound the work. The validator accepts
the textless `limited` workflow format, follows the workflow-first
response omission order, and checks the closed reason-code vocabulary.
The file inspector labels limited records and disables their workflow
download.

vectors.json: 12 inspection vectors gain their reason codes; f03 facts
become known and f01 facts ambiguous. Default vectors are unchanged. A
new parity test asserts vectors.json facts through the workspace JS.

* chore(tools): add read-only inspection corpus tool

tools/inspection_corpus.py runs the inspection route pipeline and the
workspace JS facts of a chosen code tree over a media folder, read-only,
and reports the #240 invariants H1 to H8 with aggregate counts. Rows hold
statuses and SHA-256 digests of resolved role texts, never file names or
prompt text unless --with-paths is given. Outputs inside the root or a code
tree are refused.

The facts-over-vectors test now fails instead of skipping when CI is set
and Node.js is missing.

* docs(testing): document bounded inspection work

* fix(tools): fail the corpus run when facts or inspection did not run

Rows whose facts were never computed (no or exiting Node.js) now fail H3,
and a new harness line fails on any tool exception or missing facts, so a
run where nothing was measured can no longer exit 0. Unreadable files stay
inspector outcomes. A missing --node binary is refused, an exited harness
is reaped, H8 has a 50 ms floor for tiny baselines, and JSONL rows are
sorted by id.

* docs(inspect): correct ceiling wording and bandit annotations

Describe the two 20,000-node ceilings (prompt entries, workflow UI plus
subgraph nodes) in TESTING.md, and use bare nosec ids so Bandit stops
warning about unparsed test names.

* test(inspect): make parity observation tuples explicit
2026-10-19 09:54:29 -07:00
Vito d9537a726c feat(workspace): complete shared inspector and collection viewer (#224)
* feat(inspector): expose bounded physical file evidence

* fix(inspector): distinguish ambiguous saved associations

* feat(inspector): derive conservative generation facts

* fix(inspector): guard fact ancestry and inspection state

* feat(inspector): share complete file details and exports

* fix(inspector): preserve fact states and touch targets

* feat(viewer): navigate the loaded media collection

Refs #216

* fix(viewer): refit after current stage geometry changes

Ref #216. Observe the current media stage after readiness and wrapped title changes without rebuilding focused controls or resetting transforms at unchanged dimensions.

* feat(viewer): add lifecycle-safe slideshow and media preferences

* fix(viewer): resume gallery continuation after close (#216)

* fix(images): guard source recovery and wrap viewer actions

Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery.

Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review.

* test(viewer): await gallery layout after viewport changes

Require actual inner, root client, and document widths to agree after each responsive-case viewport and CSS zoom transition. Keep the existing exact frame, overflow, focus, and coarse target assertions unchanged.

Observed PR #224 CI initial mobile resize at537px before pending card relayout settled to356px. Refs #216.
2026-10-18 09:41:53 -07:00