Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/workspace.css
T
Vito 69de20a427 feat(system): Sources & scans (#309)
* feat(system): report configured, effective and watched sources

GET /prompt_manager/settings adds settings.monitoring_enabled, a sources
block (configured roots with valid/code/reason, effective roots with their
listing index and watch state, auto_detected) and the monitor state
(present, observer_alive, scheduled). Every path is a public path.

A source is "watching" only while a live observer scheduled it, "manual"
when monitoring is off or the process has no monitor, and otherwise
"restart_pending".

POST accepts monitoring_enabled as a strict bool, saved under
gallery.monitoring.enabled; restart_required_fields names it when the
running observer does not already match. A save that changes the sources
runs as a "Source change" records hold, so it is refused with 409 busy
while a scan runs. Case variants of one folder are saved once.

POST /prompt_manager/settings/validate-source {path} checks a path
lexically (no "..", no control characters, native path forms only) and
then against the gallery-root rules, answering a code and reason, and
writes nothing.

* fix(system): no existence oracle in source validation

A gallery root's containment in the allowed ComfyUI folders is now
decided before its existence and type, so a path outside them answers
"outside-allowed" whether or not it exists. validate-source also folds
the filesystem-root and home-directory codes into "outside-allowed", with
one reason, and logs the detail server-side only. Paths inside the
allowed folders still say "not-found" or "not-directory". The legacy
settings save shares the same check.

* feat(scan): scan one selected source

POST /prompt_manager/jobs/scan accepts roots: indexes into the effective
sources, strict non-negative ints (no bools or floats), deduplicated, at
most 64, non-empty when present. An index past the sources is a 400
"unknown-source" and starts nothing. The job walks only the selected
sources, resolved when it was requested, and keeps each file's listing
root_index. Its scope records files, roots (null for every source) and
output_folders, the number of folders walked, which the log's first line
counts. The legacy /scan route still scans every source, and attaching to
a scoped job keeps its payload keys.

* feat(system): manage sources and start scans natively

System > Sources & scans renders the concept's section on the server's
sources: one row per effective source with its watch state and a scan
scoped to that source, the 3.2.0 update notice with the legacy dismissal,
Add source checked by validate-source, Remove, monitoring and worker
threads as host drafts behind the one Save row, and Refresh files.
A save refused by a running scan names the job. Thumbnail generation
hands off to the legacy gallery until S5.

Deviations D98, D113 and D-NEW-S4-1 are recorded in TESTING.md.

* fix(security): decide source containment as text before any filesystem access

Every caller (validate-source, POST /settings, config.json at load) now
refuses a source through one check, sources.source_problem: the form
check first, then GalleryConfig.gallery_root_problem, which compares the
normalised spelling with the allowed folders before anything resolves,
stats or opens the path. A UNC share, the \\?\ and \\.\ namespaces, a
device or any outside folder is refused with no SMB request, no
executor stall and no existence or timing oracle; only then is the path
resolved and contained again, which still catches a symlink out. The
filesystem root and the home directory answer outside-allowed with the
same reason as any outside path, so no caller learns the home path.

Also:
- POST /settings runs the form checks and parses the roots off the loop;
  an unusable source is a coded 400 invalid-source naming its public
  path (as text) and its index.
- ComfyUI folders outside the base directory are path anchors, so their
  sources round-trip and never collide by folder name.
- A per-source scan sends each index with its public path; a stale or
  shifted index is 409 sources-changed and starts nothing.
- A source still watched after monitoring was switched off is
  "stopping".
- Roots are compared as folders, not spellings, so an unchanged legacy
  resend takes no source-change hold.
- Tests: no realpath/stat/lstat of an outside path (native and Windows
  forms with ntpath fakes), the alike answers with /, ~ and a link to ~,
  an event instead of a timer wait, and a teardown that drains jobs
  before the temporary database goes.

* fix(system): say what Sources saved, refused and still watches

- A per-source scan sends the source's public path; when the sources
  changed since they were read the toast says the server's reason and
  the list and saved values are read again.
- "watching until restart" for a source the running monitor still
  watches after monitoring was saved off, and the status adds "Restart
  ComfyUI to apply: Watch for new outputs." from restart_required_fields.
- A save refused for one unusable source marks that row and the status
  is the server's message naming it.
- A valid configured source the effective list holds under another
  spelling reads "same folder as another source".
- The busy toast names the job the 409 named (ApiError keeps a
  validated running job and refused source), falling back to the list.
- The Add source dialog's listeners live on the page scope and Enter
  adds; its markup is compared with the concept (D127).
- TESTING.md: D-NEW-S4-1 is D127; D-NEW-S4-2 records the new copy; the
  Sources contract describes the lexical containment, invalid-source and
  sources-changed.

* fix(security): guard the Windows form probes on a token of their own

The no-filesystem guard matched "C:", which on a Windows runner is also
the temporary ComfyUI tree the save may resolve for its trusted first
entry. The probe paths now carry a token no trusted directory holds.

* docs(system): number the Sources states deviation D128
2026-10-23 13:15:06 -07:00

402 lines
36 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Production overlay on the verbatim concept base (concept.css, D1 of #249). Only production-only states and recorded
deviations live here; a rule on a selector the concept also styles carries its decision marker. */
/* Production-only shell states. */
main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* The page title takes focus after a save or route change (concept app.js 1168); a keyboard user sees where it went. */
#page-title:focus { outline: none; }
#page-title:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* D28: the sidebar shows no connection state, so its dot is neutral instead of the concept's demo green. */
.connection i { background: var(--ca-muted); }
/* D31: bounded rendering reads the grid's real width, so at extreme zoom the one-column shell must not grow to the
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
@media (max-width: 950px) {
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
}
/* D53: the running job's progress pill before the Activity button (the approved Activity mockup's `.pill`), in the
concept's tokens. At <=700px it keeps its verb and percentage; the file counts and bar stay in its accessible name. */
.activity-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line);
border-radius: 999px; font-size: 12px; white-space: nowrap; color: var(--ca-text); background: var(--ca-surface); /* D53 */ }
.activity-pill:hover { border-color: var(--ca-accent); /* D53 */ }
.activity-pill-bar { display: block; inline-size: 70px; block-size: 5px; border-radius: 3px; background: var(--line); overflow: hidden; /* D53 */ }
.activity-pill-bar i { display: block; block-size: 100%; inline-size: 0; background: var(--ca-accent); /* D53 */ }
@media (pointer: coarse) { .activity-pill:not([hidden]) { min-block-size: 40px; /* D53 */ } }
@media (max-width: 700px) {
/* D52: with the Activity button the full-width row can be wider than a zoomed phone, so its items wrap. */
.top-actions { flex-wrap: wrap; /* D52 */ }
.activity-pill:not([hidden]) { gap: 0; padding-inline: 10px; /* D53 */ }
.activity-pill-files, .activity-pill-bar { display: none; /* D53 */ }
}
/* D56: each Activity item's title links its job's view in the title's own colour. */
.activity-item h3 a { color: inherit; text-decoration: none; /* D56 */ }
.activity-item h3 a:hover { text-decoration: underline; /* D56 */ }
/* D56: one job's view, the approved Activity mockup (activity-scan.html) in the concept's tokens: the counter tiles,
the outcome list beside the selected outcome's files, and the collapsible log. */
.stat-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin: 16px 0; /* D56 */ }
.stat-row .stat { border: 1px solid var(--line); border-radius: 7px; padding: 12px 14px; background: var(--ca-surface); min-inline-size: 0; /* D56 */ }
.stat-row .stat dt { font-size: 11px; color: var(--ca-muted); /* D56 */ }
.stat-row .stat dd { margin: 6px 0 0; font-size: 20px; font-weight: 600; /* D56 */ }
.stat-row .stat.added dd { color: var(--green); /* D56 */ }
.stat-row .stat.warn dd { color: var(--warning); /* D56 */ }
.stat-row .stat.bad dd { color: var(--red); /* D56 */ }
.surface > .panel-heading > .search-field { max-width: 320px; /* D56 */ }
.outcomes { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 16px; margin-top: 6px; /* D56 */ }
.ca-tools .outcome-list button { display: flex; justify-content: space-between; width: 100%; margin-bottom: 6px; text-align: left; /* D56 */ }
.ca-tools .outcome-list button[aria-pressed="true"] { border-color: var(--ca-accent); background: var(--soft-blue); /* D56 */ }
.outcome-files .file-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 9px 4px;
border-bottom: 1px solid var(--line); font-size: 12px; /* D56 */ }
.ca-tools button.file-row { width: 100%; min-height: 0; justify-content: normal; align-items: start; border: 0;
border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: inherit; text-align: left;
white-space: normal; font: inherit; font-size: 12px; /* D56 */ }
.ca-tools button.file-row:hover:not(:disabled) { background: var(--ca-hover); /* D56 */ }
.file-row .path { font-family: ui-monospace, monospace; overflow-wrap: anywhere; /* D56 */ }
.file-row .why { color: var(--ca-muted); /* D56 */ }
.outcome-files > p.small { color: var(--ca-muted); margin-top: 10px; /* D56 */ }
.term { margin-top: 16px; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; /* D56 */ }
.term summary { padding: 11px 14px; cursor: pointer; font-size: 12px; background: var(--ca-surface); /* D56 */ }
.term summary span { color: var(--ca-muted); /* D56 */ }
.term pre { margin: 0; padding: 12px 14px; max-height: 180px; overflow: auto; font: 11px/1.7 ui-monospace, monospace;
color: var(--ca-muted); background: var(--page); white-space: pre-wrap; overflow-wrap: anywhere; /* D56 */ }
@media (max-width: 700px) {
.stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); /* D56 */ }
.outcomes { grid-template-columns: minmax(0, 1fr); /* D56 */ }
.outcome-files .file-row { grid-template-columns: minmax(0, 1fr); gap: 4px; /* D56 */ }
}
/* Overview: real library statistics and the legacy tool cards inside a concept `.surface`. */
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
.stat { padding: 22px; background: var(--ca-surface); border: 1px solid var(--line); border-radius: 7px; }
.stat dt { color: var(--ca-muted); font-size: 12px; }
.stat dd { margin: 8px 0 0; font-size: 32px; font-weight: 600; }
.overview-stats > [role="status"], .overview-stats > [role="alert"], .empty-state { padding: 18px 0; color: var(--ca-muted); }
.overview-stats > [role="alert"] { color: var(--ca-error-text); }
.overview-stats > button { margin-bottom: 24px; }
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: 12px; margin-top: 22px; }
.tool-card { border: 1px solid var(--line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
.tool-card p { flex: 1; margin: 0 0 12px; }
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
.handoff .workspace-link { margin-top: 12px; }
/* D94: a System section not delivered yet links every legacy tool that still owns it, side by side. */
.handoff .workspace-link + .workspace-link { margin-inline-start: 18px; /* D94 */ }
/* D92: System's Save row is aria-disabled while nothing is unsaved (or a save runs), and a setting the last save could
not save is highlighted, as its "Review the highlighted settings" status says. */
.save-row button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D92 */ }
.setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
/* D92: a source the last save was refused for (System › Sources) is highlighted the same way. */
#source-list > .operation[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
/* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */
.operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ }
/* D97: Appearance's legacy display rows. "Show image details on cards" off keeps a card's Info and Tag at the end of
its row; "Collapse viewer details by default" gives the viewer's stage the whole width while its details are hidden. */
.media-info > div[hidden] + .image-card-actions { margin-inline-start: auto; /* D97 */ }
.viewer-layout:has(> #viewer-details[hidden]) { grid-template-columns: minmax(0, 1fr); /* D97 */ }
@media (max-width: 700px) {
.stat-grid { gap: 8px; }
.stat { padding: 16px; }
.stat dd { font-size: 26px; }
}
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
.vocabulary-main .operation h3 { overflow-wrap: anywhere; }
/* Long tag names wrap inside the Tag cell so the Actions column keeps its intrinsic width; on two-column screens the
minimum keeps the concept's longest sample ("architecture", about 5em) on one line. */
.vocabulary-main td:first-child { overflow-wrap: anywhere; min-width: calc(6em + 20px); }
/* Tags & categories (#245 decision 5): bounded lists continue with Load more; never a scroll anchor, so appended rows push the end away. */
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
@media (max-width: 700px) {
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */
.vocabulary-main td:first-child { min-width: 0; }
.vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; }
}
/* Images (#249 C2). Production-only continuation states the concept never shows: a failed page and a load in flight. */
.infinite-continuation [role="alert"] { color: var(--ca-error-text); }
.infinite-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
/* #249 D14: the scope chip names real sources and folders, which can be far longer than "All output folders". */
.filter-row span.chip { max-width: 100%; min-width: 0; overflow-wrap: anywhere; }
/* #249 D32: at extreme zoom the 390px shell is narrower than the concept's three layout buttons; they wrap instead of
scrolling the page sideways (they never wrap while they fit). */
.segmented { flex-wrap: wrap; max-width: 100%; }
/* #249 D32: likewise the continuation's text and Load more wrap instead of pushing the button out of the column. */
.infinite-continuation { flex-wrap: wrap; }
/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
device has no real hover, so a tap must not leave the zoom stuck. */
@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is
absolute. Columns and gap still come from the concept's `.image-grid` tracks. */
.image-grid { position: relative; /* D3 */ }
.image-grid > .media-card { position: absolute; }
/* #249 D3: a list row's info keeps its own height so it can be measured; centred, it looks as the stretched concept row.
#249 D31: it may also shrink below a long nowrap title (the h3 ellipsis then applies, as in the grid). */
.list-grid .media-info { align-self: center; min-width: 0; /* D3, D31 */ }
/* #249 D31: at extreme zoom a list row is narrower than the concept's 160px art; the art yields so the info stays inside. */
.list-grid .media-art { width: min(160px, 45%); /* D31 */ }
/* #249 D31: at extreme zoom the subtitle wraps inside its column instead of running under the Inspect button. */
.media-info p { overflow-wrap: anywhere; /* D31 */ }
/* #249 D31: below the narrowest bounded card (120px, reached only at extreme zoom; images.mjs marks the grid) the info
row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
.narrow-cards .media-info { flex-wrap: wrap; }
.narrow-cards .media-info > div { flex: 1 0 100%; }
/* #249 D31: Info and Tag (#249 C2) wrap onto two lines when even the stacked row cannot hold them side by side. */
.narrow-cards .media-info > .image-card-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; /* D31 */ }
/* A list row beside its art keeps room for Inspect. */
.narrow-cards.list-grid .media-art { width: 40%; }
.narrow-cards.list-grid .media-info { padding-inline: 4px; }
/* Legacy video media (the mock does not drop it) takes the concept's image rules in each layout. */
.image-open video { display: block; width: 100%; height: auto; aspect-ratio: 1.33; object-fit: cover; }
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
titles and states that the concept's one demo image never shows. */
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
concept's `calc(100% - 72px)` layout in a column, so a taller header shrinks the layout instead of pushing the toolbar
out of view; a one-line title gives the concept's boxes. */
@media (min-width: 701px) {
.ca-tools #viewer-dialog[open] { display: flex; flex-direction: column; /* D34 */ }
}
/* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */
.ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ }
#viewer-dialog .dialog-header > div { min-width: 0; }
#viewer-title { overflow-wrap: anywhere; /* D34 */ }
#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; }
#viewer-details { overscroll-behavior: contain; /* D34 */ }
/* #249 D34: Viewer.js's stylesheet styles every `.viewer-toolbar` (`position: absolute; bottom: 0; left: 0; right: 0`),
which lifts the concept's toolbar out of `.viewer-left` to span the whole dialog over the details' last lines. It stays
in the column under the stage, where the concept's `.viewer-left` flex column and border place it. */
#viewer-toolbar { position: static; /* D34 */ }
/* #249 D34: Viewer.js's own toolbar stays hidden (`toolbar: false`); the concept's `.viewer-toolbar` rule would show it. */
.viewer-container .viewer-hide { display: none; /* D34 */ }
/* #249 D34: the stage is focusable for arrow-key navigation; a video plays with native controls, an image without
Viewer.js shows itself (the concept hides it under the viewer), and a load failure covers the stage with Retry. */
#viewer-stage:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; }
#viewer-stage > :is(video, img:not([hidden])) { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; visibility: visible; /* D34 */ }
.media-failure { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; }
/* #249 D34: Download stays the original file's link, drawn as the concept's toolbar button. */
#viewer-toolbar > .media-download { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 4px 10px; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-control); color: var(--ca-text); font-size: 12px; white-space: nowrap; /* D34 */ }
#viewer-toolbar > .media-download:hover:not([aria-disabled="true"]) { background: var(--ca-hover); }
#viewer-toolbar > [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
/* #249 D34: after Next → the position status and the unavailability reasons, each on its own centred line. */
#viewer-toolbar > p { flex-basis: 100%; margin: 0; text-align: center; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
@media (pointer: coarse) {
/* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "−" and "+" are narrower). */
#viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ }
/* #249 D34: the details' disclosure summaries and "Download original" keep 40px targets too (the concept's coarse
rule covers buttons only); the summary keeps its disclosure marker. */
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; /* D34 */ }
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; /* D34 */ }
}
@media (max-width: 700px) {
#viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
}
.prompt-text[tabindex]:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } /* production-only: a confirmed prompt stays keyboard-reachable (#249 D22) */
/* Images side panel (#249 D2), production-only. Real prompts can be far longer than the concept's samples, so the sticky
panel scrolls within the window and its footer stays reachable; where the concept unsticks it, it grows instead. */
.detail-panel { min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; /* D2 */ }
@media (max-width: 700px) { .detail-panel { max-block-size: none; /* D2 */ } }
@container images-page (inline-size < 600px) { .detail-panel { position: static; max-block-size: none; /* D2 */ } }
/* #249 D2/D20: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` (panel) and the
viewer details' wrapper as the concept's do. */
:is(.detail-inner, #viewer-details > div) > .prompt-inspector { display: contents; /* D20 */ }
/* #249 D2: real titles and filenames can be one unbroken word; they wrap inside the 310px panel. */
.panel-heading h2, .detail-inner > p.small { min-inline-size: 0; overflow-wrap: anywhere; }
/* #249 D3: production-only rules kept from the retired images.css and inspector.css. Each has a live consumer. */
/* The inspector wraps real unbroken literals (model paths, raw records) inside the 310px panel and the 340px viewer
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */
.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
.images-main { container: images-page / inline-size; }
@container images-page (inline-size < 600px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
}
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
and send keep the full text, and history shows all of it. */
.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; /* D23 */ }
/* #249 D23: the prompt blocks' mount point is transparent, so the section sits in `.card-content` as the concept's does. */
.card-content > .prompt-inspector { display: contents; /* D23 */ }
/* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount
point keeps its "Saved prompt texts" region name. */
.library-history article.surface > .prompt-inspector { display: contents; /* D24 */ }
/* #249 D25: each compared negative's block sits in the concept's `.compare-item` (app.js a600), so its mount point is
transparent and adds no margin. */
.compare-item > .prompt-inspector { display: contents; /* D25 */ }
/* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never
wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */
.card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }
.card-title .chip { flex-shrink: 0; max-width: 60%; overflow-wrap: anywhere; /* D23 */ }
.prompt-card .tags .chip { max-width: 100%; overflow-wrap: anywhere; /* D23 */ }
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's
`min-inline-size: 0` is load-bearing: it keeps cards out of the concept's ≤700px `1fr` track's min-content. */
.library-main .library-grid { overflow-anchor: none; /* D3 */ }
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; /* D3 */ }
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; /* D3 */ }
/* #249 D40: the shelf's and history's production-only load, failure and continuation states. */
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; /* D40 */ }
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); /* D40 */ }
/* History variants panel (#249 PR F). */
/* D45: the panel's actions wrap their labels at 390px and 200% zoom (the toolkit's buttons never wrap). */
.ca-tools .library-main .library-history .row-actions button { max-inline-size: 100%; white-space: normal; /* D45 */ }
/* D45: the panel's paging buttons wrap like the recorded toolbar's. */
.ca-tools .library-main :where(.timeline-panel, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; /* D45 */ }
/* D45: archived pairs (#235) are concept lanes at reduced emphasis (the .6 disabled-marker opacity); text stays at full contrast. */
.library-history .variant-lane.library-variant-archived::before, .ca-tools .library-history .library-variant-archived .variant-dot { opacity: .6; /* D45 */ }
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; /* D45 */ }
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; /* D45 */ } }
/* D45: at 390px and 200% zoom the panel heading and the legend row wrap, and the All images button wraps its label,
so the page never scrolls sideways (the retired production footer did the same). */
.library-history .timeline-panel > .panel-heading, .library-history .timeline-lower { flex-wrap: wrap; /* D45 */ }
/* D45: while the repeat-run dot shows (left: 190px), the lane text wraps inside the 57px lane before it (43px + 139px). */
.library-history .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; /* D45 */ }
.ca-tools .library-history .timeline-lower > button { max-inline-size: 100%; white-space: normal; /* D45 */ }
@media (pointer: coarse) {
/* D45: a coarse pointer reaches the concept dot through a 47px target. */
.ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; }
/* D45: the 12px repeat-run dot (8px inside its border) gets a 44px coarse target the same way. */
.ca-tools .library-history .run-dot::after { content: ""; position: absolute; inset: -18px; }
}
/* #249 D48: production surfaces the concept never shows keep their own rules, in the concept's tokens: recorded
executions and the Recorded run dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
prompt sender (spec §2.2). The dialog rules name `dialog` so they win over the concept's `.ca-tools dialog`. */
/* D48: recorded executions (#209) below the shared gallery. */
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; /* D48 */ }
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; /* D48 */ }
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; /* D48 */ }
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; /* D48 */ }
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; /* D48 */ }
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); /* D48 */ }
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; /* D48 */ }
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--green); color: var(--green); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; /* D48 */ }
.history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--green); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; /* D48 */ }
.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--green); /* D48 */ }
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; /* D48 */ }
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--red) 60%, transparent); /* D48 */ }
.history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--red); border-block-end: 2px solid var(--red); border-end-start-radius: 22px; /* D48 */ }
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; /* D48 */ }
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; /* D48 */ }
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; /* D48 */ }
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); /* D48 */ }
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--red); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; /* D48 */ }
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--red); color: #38151b; font-size: 12px; /* D48 */ }
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; /* D48 */ }
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; /* D48 */ }
/* D48: the Recorded run dialog (#209, D26). */
.ca-tools dialog.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border-radius: 7px; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; /* D48 */ }
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; /* D48 */ }
.recorded-run-header h2 { font-size: 20px; /* D48 */ }
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; /* D48 */ }
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; /* D48 */ }
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; /* D48 */ }
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; /* D48 */ }
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; /* D48 */ }
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; /* D48 */ }
.recorded-run-dialog :where(.muted:not(.prompt-text), .recorded-output-status) { font-size: 11px; line-height: 1.65; /* D48 */ }
/* D48: the current file's prompt blocks sit below the dialog's evidence line (elsewhere their mount point is `display: contents`). */
.recorded-run-dialog .prompt-inspector { margin-block-start: 22px; /* D48 */ }
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; /* D48 */ }
.recorded-outputs { margin-block: 24px; /* D48 */ }
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; /* D48 */ }
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; /* D48 */ }
.recorded-receipt > div { margin-block-end: 10px; /* D48 */ }
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; /* D48 */ }
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; /* D48 */ }
/* D48: the frozen field provenance and the Archive confirmation show stored text in `pre.prompt-text`, not as the
concept's code `pre`. */
pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-block: 10px; background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; /* D48 */ }
/* D48: the Archive negative confirmation's negative block (#235). */
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; /* D48 */ }
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; /* D48 */ }
.prompt-block.prompt-negative h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--red); /* D48 */ }
.prompt-empty { font-size: 10px; /* D48 */ }
/* D48: the acknowledged prompt sender (prompt-sender.mjs) and its labelled pair send button. */
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); /* D48 */ }
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; /* D48 */ }
.ca-tools dialog.prompt-sender { inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; /* D48 */ }
.ca-tools dialog.prompt-sender::backdrop { background: #000b; /* D48 */ }
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; /* D48 */ }
.prompt-sender-header h2 { font-size: 20px; /* D48 */ }
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; /* D48 */ }
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; /* D48 */ }
.prompt-sender select { inline-size: 100%; min-block-size: 38px; /* D48 */ }
.prompt-sender-status { font-size: 13px; line-height: 1.6; /* D48 */ }
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); /* D48 */ }
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; /* D48 */ }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; /* D48 */ }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; /* D48 */ }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; /* D48 */ }
@media (max-width: 780px) {
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); /* D48 */ }
}
@media (max-width: 700px) {
.ca-tools dialog.recorded-run-dialog { padding: 16px; /* D48 */ }
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); /* D48 */ }
}
@media (pointer: coarse) {
/* D48: the recorded toolbar, its scope disclosure and the dialog's controls keep 44px targets (concept coarse buttons are 40px). */
.ca-tools .recorded-history :is(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--red); border-radius: 50%; background: var(--ca-field); }
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--red); }
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
}
@media (forced-colors: active) {
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
/* Tag image dialog (#249 C2): the concept's `tagging-modal` (s1598–1694) as it stands, but D83 (ruling T4): the
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
s1655): a name, the confidence and a × button. */
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
@media (pointer: coarse) {
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ }
.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }