Merge remote-tracking branch 'origin/main' into fix/activity-ended-cancel-and-restore-test

This commit is contained in:
Vito Sansevero
2026-10-23 06:09:11 -07:00
40 changed files with 5227 additions and 137 deletions
+10
View File
@@ -336,6 +336,16 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti
return { busy: store.running()[0] ?? null, message: error.message };
}
},
/**
* Follow a job another route started (its summary from that route's answer, e.g. the health check's
* `POST /system/health`): it is listed and its stream opened at once, with no read and no start toast.
*/
track(raw) {
const job = parseJob(raw);
if (!job) throw new TypeError('Invalid job');
upsert(job);
return job;
},
/** Ask a running job to stop at its next file boundary; false once it has ended. */
async cancel(id) {
const result = await api.post(`${JOBS_URL}/${encodeURIComponent(id)}/cancel`, {}, { signal: scope.signal });
+18 -11
View File
@@ -1,6 +1,6 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages, onFavoriteChange } from './images.mjs';
@@ -16,6 +16,7 @@ import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
import { mountActivityJobView } from './activity-job-view.mjs';
import { mountInspect } from './inspect.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -28,7 +29,8 @@ function element(tag, text, className) {
function legacyLink(route) {
const link = element('a', route.action, 'workspace-link');
link.href = resolveUrl(route.href);
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
link.href = route.native ? route.href : resolveUrl(route.href);
return link;
}
@@ -137,6 +139,9 @@ function mountWorkspace() {
let leaveGuard = null, renderToken = 0;
scope.cleanup(() => viewScope?.dispose());
// The mounted System page: another of its sections is shown in place, keeping the shell and the drafts.
let system = null;
function render(route) {
const token = ++renderToken;
const hold = leaveGuard?.();
@@ -151,6 +156,11 @@ function mountWorkspace() {
leaveGuard = null;
// A shared page dialog never stays open over the next route.
closeWorkspaceDialog();
if (route.id === 'system' && system && currentKey === 'system') {
system.show(route.section);
return;
}
system = null;
viewScope?.dispose();
images = null;
viewScope = createScope();
@@ -251,21 +261,18 @@ function mountWorkspace() {
else mountActivityPage(main, { store: activity, scope: view });
return;
}
if (route.id === 'system') {
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
description.textContent = route.description;
system = mountSystem(main, api, view, { section: route.section, activity });
return;
}
if (route.id === 'inspect') {
// The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
description.textContent = route.description;
({ leaveGuard } = mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) }));
return;
}
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
legacyLink(route));
main.append(panel);
return;
}
description.textContent = 'A home for your prompts, images and creative history.';
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
+23 -11
View File
@@ -35,7 +35,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
if (scope.signal.aborted) return;
active?.close(false);
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
@@ -115,7 +115,9 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
mediaScope?.dispose();mediaScope = createScope();viewer = null;
for (const child of [...stage.children]) if (child !== failure) child.remove();
failure.hidden = true;
const mediaLocal = mediaScope, key = session.state.key;
const mediaLocal = mediaScope, key = session.state.key, ViewerJs = window.Viewer;
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
withoutViewer = !ViewerJs;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
// original gets a fresh cache-buster on every build.
@@ -142,15 +144,23 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
},{once:true});
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!window.Viewer;
node.hidden = !!ViewerJs;
// With Viewer.js the src is released after the viewer (see release below).
if (!window.Viewer) {
if (!ViewerJs) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
}
node.src = src;stage.append(node);
if (!selected.is_video && window.Viewer) {
if (!selected.is_video && ViewerJs) {
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
// while `image.complete` is false, so an image left with its load event still queued would build a
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
let mountedViewer = false;
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
}
function mountViewer() {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
@@ -166,7 +176,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
fitWidth = width;fitHeight = height;instance.resize();
});
};
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
const instance = new ViewerJs(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
@@ -176,10 +186,12 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame);
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
// rebuilds its list from images that still have a src; keep both until the view
// settles, fails or times out.
if (viewSettled) { release(); return; }
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
// deferring it would let the build run into the image the stage has just dropped.
// Once built, inline Viewer.js keeps viewing a still-loading image after destroy(), and that
// view rebuilds its list from images that still have a src; keep both until the view settles,
// fails or times out.
if (viewSettled || !instance.ready) { release(); return; }
node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
});
@@ -188,7 +200,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
}
function updateControls(state) {
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
'Image transforms are unavailable for video.' : !viewer ?
'Image transforms are unavailable for video.' : withoutViewer ?
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
reason.textContent = unavailable;reason.hidden = !unavailable;
for (const node of transforms) {
+30 -4
View File
@@ -14,11 +14,29 @@ export const routes = [
{ id: 'favorites', label: 'Favorites' },
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
{ id: 'activity', label: 'Activity' },
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
// a workspace hash, not a legacy page); each undelivered section hands off to its legacy tool (D94). The
// description is the concept's System heading (app.js renderSystem 700).
{ id: 'system', label: 'System', href: '#/system/overview', native: true,
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity']);
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'system']);
/**
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
* `overview`, so `#/system/overview` sits beside the production hub `#/overview` (D28).
*/
export const SYSTEM_SECTIONS = Object.freeze([
['overview', 'Overview'], ['appearance', 'Appearance'], ['sources', 'Sources & scans'], ['integrations', 'Integrations'],
['tagging', 'Auto-tagging'], ['maintenance', 'Maintenance'], ['backups', 'Backup & export'], ['diagnostics', 'Diagnostics & logs'],
].map(section => Object.freeze(section)));
const SYSTEM_SECTION_IDS = new Set(SYSTEM_SECTIONS.map(([id]) => id));
/** Whether `id` names a System section. */
export function isSystemSection(id) {
return typeof id === 'string' && SYSTEM_SECTION_IDS.has(id);
}
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
@@ -39,6 +57,14 @@ export function resolveRoute(hash) {
// One job of the Activity page (its id is the registry's uuid4 hex).
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
// `#/system` is the Overview section under the sidebar's own hash; an unknown child is Overview, its hash replaced.
const system = /^#\/system(?:\/(.*))?$/.exec(hash);
if (system) {
const route = routes.find(item => item.id === 'system');
if (system[1] === undefined) return {...route, section:'overview', hash};
const section = isSystemSection(system[1]) ? system[1] : 'overview';
return {...route, section, hash:`#/system/${section}`};
}
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
if (match && Number.isSafeInteger(Number(match[1]))) {
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
@@ -117,7 +143,7 @@ export function mountRouter(win, onRoute) {
const hash = route.hash || `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
current?.jobId === route.jobId) return;
current?.jobId === route.jobId && current?.section === route.section) return;
current = route;
onRoute(route);
};
+132
View File
@@ -0,0 +1,132 @@
/**
* System's row builders on the concept's markup (#249 System S2 Task 5), built with the DOM, never HTML strings:
* - `settingRow` is the concept's `setting(id, label, description, type, options)` (app.js 624–634): `.setting-row` >
* `div` > `label[for]` + `p`, then the control. A toggle is the Toolkit switch, as the concept's `hydrateSwitches`
* (708–717) makes it; a range is the concept's `min=1 max=16` unless the row gives its own bounds.
* - `operationRow` is `operation(title, desc, action, label, extra, danger)` (645–654): `.operation` > `div` > `h3` + `p`,
* then `.row-actions` with one button, `danger` when flagged.
* - `sectionNav` is `sectionNav(items, active, action)` (306–308): `.section-nav[role=group]` buttons with `aria-pressed`.
* Every listener is registered on the caller's `scope`, so disposing it leaves none behind.
*/
import { createButton, createSwitch } from '../shared/toolkit/core.js';
const INPUT_TYPES = new Set(['number', 'password', 'text', 'range']);
const RANGE_DEFAULTS = Object.freeze({ min: 1, max: 16 });
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function requireText(values) {
for (const [name, value] of Object.entries(values)) {
if (typeof value !== 'string' || !value) throw new TypeError(`The row needs a ${name}`);
}
}
function requireScope(scope, callback, name) {
if (callback === undefined) return;
if (typeof callback !== 'function') throw new TypeError(`The row ${name} must be a function`);
if (typeof scope?.listen !== 'function') throw new TypeError('The row needs the section scope for its listener');
}
/** `[value, label]` pairs from strings or pairs. */
function optionPairs(options) {
if (!Array.isArray(options) || options.length === 0) throw new TypeError('A select row needs its options');
return options.map(option => (Array.isArray(option) ? option.map(String) : [String(option), String(option)]));
}
/** The control a spec describes, and how to read its value. */
function buildControl(id, spec) {
if (spec?.type === 'toggle') {
const field = createSwitch('', '', id);
const input = field.children[0];
// The concept's toggles are on unless configured off (`val !== false`).
input.checked = spec.checked !== false;
return { node: field, input, read: () => input.checked };
}
if (spec?.type === 'select') {
const select = el('select');
for (const [value, label] of optionPairs(spec.options)) {
const option = el('option', label);
option.value = value;
select.append(option);
}
if (spec.value !== undefined) select.value = String(spec.value);
return { node: select, input: select, read: () => select.value };
}
if (INPUT_TYPES.has(spec?.type)) {
const input = el('input');
input.type = spec.type;
const bounds = spec.type === 'range' ? { ...RANGE_DEFAULTS, ...spec } : spec;
for (const key of ['min', 'max', 'step']) if (bounds[key] !== undefined) input[key] = String(bounds[key]);
input.value = spec.value === undefined || spec.value === null ? '' : String(spec.value);
const numeric = spec.type === 'number' || spec.type === 'range';
return { node: input, input, read: () => (numeric ? Number(input.value) : input.value) };
}
throw new TypeError(`Unknown setting control: ${spec?.type}`);
}
/**
* One `.setting-row`. `control` is a spec (`{type:'toggle', checked}`, `{type:'select', options, value}` with string or
* `[value, label]` options, `{type:'number'|'password'|'text'|'range', value, min, max, step}`) or a ready-made node,
* which takes `id`. `onChange(value)` (on `scope`) reports the checked state, a number for number and range, else the
* text.
*/
export function settingRow({ id, label, description, control, scope, onChange }) {
requireText({ id, label, description });
requireScope(scope, onChange, 'onChange');
let built;
if (control && typeof control === 'object' && typeof control.type !== 'string' && 'tagName' in control) {
control.id = id;
built = { node: control, input: control, read: () => control.value };
} else {
built = buildControl(id, control);
built.input.id = id;
}
built.input.dataset.setting = id;
const row = el('div', undefined, 'setting-row');
const text = el('div');
const caption = el('label', label);
caption.htmlFor = id;
text.append(caption, el('p', description));
row.append(text, built.node);
if (onChange) scope.listen(built.input, 'change', () => onChange(built.read()));
return row;
}
/** One `.operation` row; `onClick(button)` runs on `scope`. */
export function operationRow({ title, description, label, danger = false, onClick, scope }) {
requireText({ title, description, label });
if (typeof onClick !== 'function') throw new TypeError('The operation needs onClick');
requireScope(scope, onClick, 'onClick');
const row = el('div', undefined, 'operation');
const text = el('div');
text.append(el('h3', title), el('p', description));
const actions = el('div', undefined, 'row-actions');
const action = createButton(label, danger ? 'danger' : '');
scope.listen(action, 'click', () => onClick(action));
actions.append(action);
row.append(text, actions);
return row;
}
/** The `.section-nav` group: one button per `[id, title]`, the `active` one pressed; `onSelect(id)` runs on `scope`. */
export function sectionNav({ label, sections, active, onSelect, scope }) {
requireText({ label });
if (typeof onSelect !== 'function') throw new TypeError('The section nav needs onSelect');
requireScope(scope, onSelect, 'onSelect');
const nav = el('div', undefined, 'section-nav');
nav.setAttribute('role', 'group');
nav.setAttribute('aria-label', label);
for (const [id, title] of sections) {
const item = createButton(title);
item.dataset.value = id;
item.setAttribute('aria-pressed', String(id === active));
scope.listen(item, 'click', () => onSelect(id));
nav.append(item);
}
return nav;
}
+432
View File
@@ -0,0 +1,432 @@
/**
* System › Overview (#249 System S2 Task 6): the concept's `renderSystem` "general" body (app.js 658–675) on real
* values, its Statistics dialog ("Usage statistics", a1600–1604) and its health report (`health-check`, a1439–1456).
*
* - The four `.summary-metrics` and the two `.two-col` surfaces ("Library health", "Workspace preferences") come from
* one `GET /prompt_manager/system/overview`. D93: metric 4 is the last health result (Not checked / Passed /
* N issues / Incomplete); the review, backup, thumbnail and sources rows say what the server reports, and nothing
* reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. The thumbnail row is
* its own read of `/images/thumbnails/status` (a walk of every source): "Checking thumbnails…" until it answers.
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
*
* Reads run through `latestOnly()` on the section's scope, so a late answer never repaints a newer section.
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { closeWorkspaceDialog, openContentDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
import { operationRow } from './system-controls.mjs';
export const OVERVIEW_URL = '/prompt_manager/system/overview';
export const STATISTICS_URL = '/prompt_manager/system/statistics';
export const HEALTH_URL = '/prompt_manager/system/health';
export const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
export const HEALTH_KIND = 'health';
export const LOADING_TEXT = 'Loading the overview…';
export const THUMBNAILS_LOADING = 'Checking thumbnails…';
export const LOAD_FAILED_TEXT = 'The overview could not be loaded. Your saved data has not changed.';
export const STATISTICS_FAILED_TEXT = 'Statistics could not be loaded. Your saved data has not changed.';
export const HEALTH_FAILED_TEXT = 'The health check could not finish. Nothing was changed.';
export const NOT_AVAILABLE = 'Not available yet.';
const number = value => Number(value).toLocaleString();
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* D93: metric 4, the last health result of this server session: the checks that found issues, even when another
* check could not run; otherwise Passed, or Incomplete while a check was unavailable or failed.
*/
export function integrityText(health) {
if (!health) return 'Not checked';
if (health.issues > 0) return `${number(health.issues)} ${noun(health.issues, 'issue')}`;
return health.status === 'passed' ? 'Passed' : 'Incomplete';
}
/** The concept's four metrics `[value, label]`, in its order. */
export function metricValues(overview) {
return [
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[number(overview.images.indexed), 'Images'],
[integrityText(overview.health), 'Database integrity'],
];
}
/** D93 (Q6): the review row's title; a count only once the Needs review queue reports one. */
export function ambiguousTitle(needsReview) {
const pending = needsReview?.pending;
if (!Number.isSafeInteger(pending)) return 'Ambiguous image links';
return `${number(pending)} ambiguous image ${noun(pending, 'link')}`;
}
/** D93: the backup row; only a recorded, verified backup is ever named. */
export function backupText(backup) {
if (!backup) return 'No retained backup record.';
const when = new Date(backup.verified_at ?? backup.created_at);
return Number.isNaN(when.getTime()) ? 'No retained backup record.' : `Last verified backup ${when.toLocaleString()}.`;
}
/**
* D93: the thumbnail row's title from the `/images/thumbnails/status` answer: undefined while it is read, null when
* the read failed; with no image source (`sources.effective` 0) there is nothing to count.
*/
export function thumbnailTitle(status, sources) {
if (status === undefined) return THUMBNAILS_LOADING;
const missing = status?.pending;
if (!Number.isSafeInteger(missing) || sources?.effective === 0) return 'Thumbnail status unavailable';
if (missing === 0) return 'No missing thumbnails';
return `${number(missing)} missing ${noun(missing, 'thumbnail')}`;
}
const SOURCES_SHARED = 'Monitoring and enrichment share one source list.';
/** D93: the sources row, `[title, description]`: the sources in use and how they were chosen. */
export function sourcesCopy(sources) {
const { configured, effective, auto_detected: auto } = sources;
const title = `${number(effective)} output ${noun(effective, 'source')}`;
if (auto) return [title, `None configured is available; ComfyUI's output folder is used. ${SOURCES_SHARED}`];
if (configured !== effective) return [title, `${number(effective)} of ${number(configured)} configured ${noun(configured, 'source')} available. ${SOURCES_SHARED}`];
return [title, SOURCES_SHARED];
}
// D95: an issue chip names what was found; the concept's own wording for missing files and ambiguous metadata.
const ISSUE_NOUNS = Object.freeze({
integrity: ['problem found', 'problems found'],
foreign_keys: ['broken reference', 'broken references'],
canonical_hashes: ['hash does not match its text', 'hashes do not match their text'],
pair_uniqueness: ['duplicate pair row', 'duplicate pair rows'],
missing_files: ['link needs review', 'links need review'],
ambiguous_metadata: ['image needs review', 'images need review'],
consistency: ['inconsistency', 'inconsistencies'],
});
/** D95: a check's chip from its real status. */
export function checkChip(check) {
if (check.status === 'passed') return 'Passed';
if (check.status === 'failed') return 'Could not check';
if (check.status === 'unavailable') return check.id === 'ambiguous_metadata' ? NOT_AVAILABLE.slice(0, -1) : 'Not available';
const [one, many] = ISSUE_NOUNS[check.id] ?? ['issue', 'issues'];
if (!Number.isSafeInteger(check.count)) return 'Issues found';
// SQLite stops listing integrity problems at its limit, so a capped count is a lower bound ("100+").
if (check.capped === true) return `${number(check.count)}+ ${many}`;
return `${number(check.count)} ${check.count === 1 ? one : many}`;
}
function sourceChip(source) {
if (source.status !== 'checked') return 'Not available';
return source.count === 0 ? 'Passed' : checkChip({ id: 'missing_files', status: 'issues', count: source.count });
}
/** D95: the report's rows `[label, chip]` in the server's order; Missing files once per image source when several. */
export function healthRows(report) {
return report.checks.flatMap(check => {
const sources = check.id === 'missing_files' && Array.isArray(check.sources) ? check.sources : [];
if (sources.length < 2) return [[check.label, checkChip(check)]];
return sources.map(source => [`${check.label} · ${source.name}`, sourceChip(source)]);
});
}
/** D95: when the report was taken, and whether it is complete. */
export function healthSummary(report) {
const when = new Date(report.checked_at);
const time = Number.isNaN(when.getTime()) ? '' : `Checked ${when.toLocaleString()}. `;
if (report.status === 'passed') return `${time}Every check passed.`;
if (report.status === 'incomplete') return `${time}Some checks could not run, so this report is incomplete.`;
return `${time}Nothing was changed; review the checks with issues.`;
}
/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */
export function categoryRows(statistics) {
return statistics.by_category.map(row => [
row.category ?? 'Uncategorized',
number(row.runs),
row.average_rating === null || row.average_rating === undefined ? 'Not rated'
: `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`,
]);
}
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images],
['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]);
}
function table(caption, head, rows, empty) {
const node = el('table');
const header = el('tr');
for (const text of head) {
const cell = el('th', text);
cell.scope = 'col';
header.append(cell);
}
const thead = el('thead');
thead.append(header);
const tbody = el('tbody');
for (const values of rows) {
const row = el('tr');
row.append(...values.map(text => el('td', text)));
tbody.append(row);
}
if (rows.length === 0) {
const row = el('tr');
const cell = el('td', empty);
cell.colSpan = head.length;
row.append(cell);
tbody.append(row);
}
node.append(el('caption', caption), thead, tbody);
return node;
}
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
/** The concept's health report body: `.operation-list` of `h3` + chip, the D95 summary, then Close / Review missing links. */
export function healthNodes(report, { scope, onReviewMissing }) {
const list = el('div', undefined, 'operation-list');
for (const [label, chip] of healthRows(report)) {
const row = el('div', undefined, 'operation');
row.append(el('h3', label), el('span', chip, 'chip'));
list.append(row);
}
const close = createButton('Close');
const review = createButton('Review missing links', 'ca-primary');
scope.listen(close, 'click', () => closeWorkspaceDialog());
scope.listen(review, 'click', () => onReviewMissing());
const actions = el('div', undefined, 'dialog-actions');
actions.append(close, review);
return [list, el('p', healthSummary(report), 'small'), actions];
}
/**
* Wait until the job `id` has ended, following it in the Activity store (`activity`), which streams the job's events
* and marks it lost when the server no longer lists it; nothing here polls. Resolves its final state ('finished',
* 'failed', 'cancelled' or 'lost').
*/
export function waitForJob(activity, id, { signal } = {}) {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const check = () => {
const state = activity.find(id)?.state;
if (state && state !== 'running') finish(resolve, state);
};
const onAbort = () => finish(reject, signal.reason);
const unsubscribe = activity.subscribe(check);
function finish(settle, value) {
unsubscribe();
signal?.removeEventListener('abort', onAbort);
settle(value);
}
signal?.addEventListener('abort', onAbort, { once: true });
check();
});
}
/**
* Run the health check and resolve its report. The started job is tracked in the Activity store; a check already
* running (409 busy, another tab) is found in the store's job list and waited for instead of refused. Rejects when the
* check fails or ends without a report.
*/
export async function runHealthCheck(api, { activity, signal } = {}) {
let id;
try {
const { job } = await api.post(HEALTH_URL, {}, { signal });
id = activity.track(job).id;
} catch (error) {
if (error?.status !== 409 || error?.code !== 'busy') throw error;
await activity.load();
id = activity.running().find(job => job.kind === HEALTH_KIND)?.id;
// The other check ended between the refusal and this read: its report is the latest.
}
const state = id ? await waitForJob(activity, id, { signal }) : 'finished';
if (state !== 'finished') throw new Error(HEALTH_FAILED_TEXT);
const { report } = await api.get(HEALTH_URL, { signal, cache: 'no-store' });
if (!report) throw new Error(HEALTH_FAILED_TEXT);
return report;
}
/** Open the Health report for `report` (the Maintenance notice's and, from S7, its Check action's dialog). */
export function openHealthReport(report, { scope, trigger = null, navigate = navigateTo }) {
openContentDialog({ scope, title: 'Health report', trigger, nodes: healthNodes(report, { scope,
// Missing-link cleanup lives in Maintenance (its D94 hand-off until S7).
onReviewMissing: () => { closeWorkspaceDialog(); navigate('#/system/maintenance'); } }) });
}
/**
* The concept's Maintenance notice ("Check first. Review changes. Then apply." + "Run health checks", a689), which
* S2 delivers ahead of the rest of Maintenance (D123): the press runs the read-only check, then opens the report.
*/
export function healthNotice({ api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast }) {
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h2', 'Check first. Review changes. Then apply.'),
el('p', 'One home for database and media maintenance. Scanning is separate from changing or removing records.'));
const run = createButton('Run health checks', 'ca-primary');
notice.append(text, run);
scope.listen(run, 'click', async () => {
if (run.getAttribute('aria-disabled') === 'true') return;
run.setAttribute('aria-disabled', 'true');
run.textContent = 'Checking…';
try {
const report = await runHealthCheck(api, { activity, signal: scope.signal });
if (scope.signal.aborted) return;
openHealthReport(report, { scope, trigger: run, navigate });
} catch (error) {
if (!scope.signal.aborted) notify(HEALTH_FAILED_TEXT);
} finally {
if (!scope.signal.aborted) {
run.removeAttribute('aria-disabled');
run.textContent = 'Run health checks';
}
}
});
return notice;
}
function metricsNode(overview) {
const metrics = el('div', undefined, 'summary-metrics');
for (const [value, label] of metricValues(overview)) {
const metric = el('div', undefined, 'metric');
metric.append(el('strong', value), el('span', label));
metrics.append(metric);
}
return metrics;
}
function surface(heading, rows) {
const section = el('section', undefined, 'surface');
section.append(el('h2', heading), ...rows);
return section;
}
/** The Overview's nodes, and its thumbnail row (painted by its own read). */
function overviewNodes(overview, { scope, navigate, openStatistics }) {
const go = section => () => navigate(`#/system/${section}`);
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
description: Number.isSafeInteger(overview.needs_review?.pending)
? 'Review metadata before assigning a positive / negative pair.' : NOT_AVAILABLE,
label: 'Review metadata', onClick: () => {} });
// D93 (Q6): the review queue (Needs review) is not part of the workspace yet.
review.querySelector('button').setAttribute('aria-disabled', 'true');
const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources);
const thumbnails = operationRow({ scope, title: THUMBNAILS_LOADING, description: 'Generate only missing previews when necessary.',
label: 'Review thumbnails', onClick: go('sources') });
const health = surface('Library health', [
review,
operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups',
onClick: go('backups') }),
// Until the thumbnail review dialog (S5), Review thumbnails opens Sources, where thumbnail generation lives.
thumbnails,
]);
const preferences = surface('Workspace preferences', [
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
return { nodes: [metricsNode(overview), columns], thumbnails };
}
/**
* The Overview section's mount (`mountSystem`'s `sections.overview`): returns its loading line, which the server's
* answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer
* one superseded, or one answering after the section left, paints nothing.
*/
export function mountOverview(container, { api, scope, navigate = navigateTo, notify = showWorkspaceToast,
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// The thumbnail status (undefined while read, null when it failed), painted into the row whenever both are there.
let thumbnailStatus;
let thumbnailRow = null;
let sources = null;
const paintThumbnails = () => {
if (thumbnailRow) thumbnailRow.querySelector('h3').textContent = thumbnailTitle(thumbnailStatus, sources);
};
function swap(nodes) {
const anchor = shown.find(node => node.isConnected);
if (anchor) anchor.before(...nodes);
for (const node of shown) node.remove();
shown = nodes;
return nodes;
}
async function openStatistics(button) {
try {
const statistics = await statisticsRead(signal => api.get(STATISTICS_URL, { signal, cache: 'no-store' }));
if (statistics === STALE || scope.signal.aborted) return;
openContentDialog({ scope, title: 'Usage statistics', trigger: button, nodes: statisticsNodes(statistics) });
} catch (error) {
if (!scope.signal.aborted) notify(STATISTICS_FAILED_TEXT);
}
}
function loading() {
const status = el('p', LOADING_TEXT, 'small');
status.setAttribute('role', 'status');
return [status];
}
async function load() {
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails } = overviewNodes(overview, { scope, navigate, openStatistics });
thumbnailRow = thumbnails;
sources = overview.sources;
swap(nodes);
paintThumbnails();
} catch (error) {
if (scope.signal.aborted) return;
const alert = el('p', LOAD_FAILED_TEXT);
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
scope.listen(retry, 'click', () => {
swap(loading());
load();
loadThumbnails();
});
const panel = el('section', undefined, 'surface');
panel.append(alert, retry);
swap([panel]);
}
}
async function loadThumbnails() {
let status;
try {
status = await thumbnailsRead(signal => api.get(THUMBNAILS_URL, { signal, cache: 'no-store' }));
} catch (error) {
status = null;
}
if (status === STALE || scope.signal.aborted) return;
thumbnailStatus = status ?? null;
paintThumbnails();
}
const first = swap(loading());
load();
loadThumbnails();
return first;
}
+201
View File
@@ -0,0 +1,201 @@
/**
* The System draft store (#249 System S2 Task 5): every System section edits drafts behind the one Save row (the
* concept's `.save-row`, app.js renderSystem 705), in three domains:
* - `browser`: preferences kept in this browser (Appearance);
* - `host`: the server configuration, saved with the `revision` its read returned (`expected_revision`, #220);
* - `runtime`: values applied to the running server only (the log level, Q10b).
*
* `save()` writes browser, then host, then runtime, and only the domains with changes. A domain that fails keeps its
* drafts dirty and its keys `failed()`, so the section highlights them; the others are saved. A host save refused
* because the configuration changed elsewhere (`settings-changed`) is a `conflict` and keeps the draft. A host save before
* `load('host')` returned a revision fails without a request: a blind write would merge over a configuration changed
* elsewhere (S1 reads a null `expected_revision` as "merge into what is on disk"). `reloadHost()` reads the host values
* again (System calls it on mount); a host conflict reloads them itself, so the next save carries the current revision.
* Drafts live for
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
*
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only), `writeHost(changes,
* {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
* a write that throws failed.
*/
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
/** D92: the Save row's status copy; never the concept's prototype text. */
export const SETTINGS_STATUS = Object.freeze({
clean: 'No unsaved changes.',
dirty: 'Unsaved changes.',
browser: 'Saved in this browser.',
host: 'Saved to the server configuration.',
runtime: 'Applied until the server restarts.',
partial: 'Some changes were not saved. Review the highlighted settings.',
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
conflict: 'Settings changed elsewhere. Reload this section to see them.',
});
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
const CONFLICT_CODE = 'settings-changed';
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
/** The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict). */
export function statusText(outcome) {
const results = DRAFT_DOMAINS.map(domain => outcome?.[domain]);
if (results.includes('conflict')) return SETTINGS_STATUS.conflict;
if (results.includes('failed')) return SETTINGS_STATUS.partial;
const saved = [];
if (outcome?.browser === 'saved') saved.push(SETTINGS_STATUS.browser);
if (outcome?.browser === 'tab-only') saved.push(SETTINGS_STATUS.tabOnly);
if (outcome?.host === 'saved') saved.push(SETTINGS_STATUS.host);
if (outcome?.runtime === 'applied') saved.push(SETTINGS_STATUS.runtime);
return saved.join(' ') || SETTINGS_STATUS.clean;
}
function requireDomain(domain) {
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
}
/** Create the store; see the module comment for the adapters. */
export function createSystemDrafts(adapters) {
for (const name of ADAPTERS) {
if (typeof adapters?.[name] !== 'function') throw new TypeError(`System drafts need ${name}`);
}
const baseline = { browser: {}, host: {}, runtime: {} };
const drafts = { browser: new Map(), host: new Map(), runtime: new Map() };
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
const listeners = new Set();
let revision = null;
let outcome = null;
let saving = null;
const notify = () => { for (const listener of [...listeners]) listener(); };
const changes = domain => Object.fromEntries(drafts[domain]);
/** Read a domain's saved values; drafts are kept, and a draft equal to the new value is clean. */
async function load(domain) {
requireDomain(domain);
let values;
if (domain === 'host') {
const read = await adapters.readHost();
values = read?.values ?? {};
revision = read?.revision ?? null;
} else {
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
}
baseline[domain] = { ...(values ?? {}) };
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
notify();
return { ...baseline[domain] };
}
function get(domain, key) {
requireDomain(domain);
return drafts[domain].has(key) ? drafts[domain].get(key) : baseline[domain][key];
}
function set(domain, key, value) {
requireDomain(domain);
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
else drafts[domain].set(key, value);
failedKeys[domain].delete(key);
outcome = null;
notify();
}
function dirty(domain) {
if (domain !== undefined) {
requireDomain(domain);
return drafts[domain].size > 0;
}
return DRAFT_DOMAINS.some(name => drafts[name].size > 0);
}
function discard(domain) {
for (const name of domain === undefined ? DRAFT_DOMAINS : [domain]) {
requireDomain(name);
drafts[name].clear();
failedKeys[name].clear();
}
outcome = null;
notify();
}
/** The values of one domain as written: they become its saved values, and drafts still equal to them are clean. */
function commit(domain, written) {
for (const [key, value] of written) {
baseline[domain][key] = value;
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
}
}
function fail(domain, written) {
for (const [key] of written) failedKeys[domain].add(key);
}
async function saveDomain(domain) {
const written = new Map(drafts[domain]);
if (written.size === 0) return 'clean';
const values = Object.fromEntries(written);
try {
if (domain === 'browser') {
const result = await adapters.writeBrowser(values);
commit(domain, written);
return result?.persisted === false ? 'tab-only' : 'saved';
}
if (domain === 'host') {
if (typeof revision !== 'string') { fail(domain, written); return 'failed'; }
const result = await adapters.writeHost(values, { expected_revision: revision });
if (result?.code === CONFLICT_CODE) { fail(domain, written); return 'conflict'; }
if (!result?.success || result.persisted !== true) { fail(domain, written); return 'failed'; }
if (typeof result.revision === 'string') revision = result.revision;
commit(domain, written);
return 'saved';
}
await adapters.writeRuntime(values);
commit(domain, written);
return 'applied';
} catch (error) {
fail(domain, written);
return error?.code === CONFLICT_CODE ? 'conflict' : 'failed';
}
}
/** Read the host values again; a failed read leaves host saves refused until one succeeds. */
const reloadHost = () => load('host').then(() => true, () => false);
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
function save() {
saving ??= (async () => {
for (const domain of DRAFT_DOMAINS) failedKeys[domain].clear();
const result = {};
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
if (result.host === 'conflict') await reloadHost();
return result;
})().then(result => {
saving = null;
outcome = result;
notify();
return result;
});
return saving;
}
return {
load,
reloadHost,
get,
set,
dirty,
discard,
save,
changes,
saving: () => saving !== null,
failed: domain => { requireDomain(domain); return new Set(failedKeys[domain]); },
/** The Save row's text: the last save's outcome until the next edit, else unsaved or clean. */
statusText: () => (outcome ? statusText(outcome) : dirty() ? SETTINGS_STATUS.dirty : SETTINGS_STATUS.clean),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Native System (#249 System S2 Task 5): the concept's `renderSystem` shell (app.js 698–706) at `#/system/<section>`.
* Under the page heading it renders the section nav (`.section-nav`, one button per section, the shown one
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs).
*
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
import { sectionNav } from './system-controls.mjs';
import { healthNotice, mountOverview } from './system-overview.mjs';
const SETTINGS_URL = '/prompt_manager/settings';
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open existing image gallery' });
/**
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): the admin page's
* dashboard, Settings, Maintenance, Backup, Restore, Scan, Logs, Diagnostics and Auto-tag tools, and the gallery's
* settings, thumbnails, duplicates and cache tools.
*/
export const SECTION_HANDOFFS = Object.freeze({
appearance: [GALLERY, ADMIN],
sources: [ADMIN, GALLERY],
integrations: [ADMIN],
tagging: [ADMIN],
maintenance: [ADMIN, GALLERY],
backups: [ADMIN],
diagnostics: [ADMIN],
});
export const NOT_DELIVERED = 'This setting is not available here yet.';
let drafts = null;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
/** The page session's drafts (Q10); the host domain reads and saves the server settings with their revision (#220). */
export function systemDrafts(api) {
drafts ??= createSystemDrafts({
readBrowser: () => ({}),
readRuntime: () => ({}),
writeBrowser: undelivered,
writeRuntime: undelivered,
async readHost() {
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
// The store reads a missing value as none and a missing revision as unreadable (no host save).
return { values: result.settings, revision: result.revision };
},
writeHost: (changes, { expected_revision }) => api.post(SETTINGS_URL, { ...changes, expected_revision }),
});
return drafts;
}
/** D94: the shared hand-off panel, linking each legacy tool that still owns this section. */
function handoff(section) {
const panel = el('section', undefined, 'surface handoff');
panel.append(el('h2', 'Your current tools are ready'),
el('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'));
for (const tool of SECTION_HANDOFFS[section]) {
const link = el('a', tool.label, 'workspace-link');
link.href = window.ApiClient.resolveUrl(tool.href);
panel.append(link);
}
return [panel];
}
/** D92: highlight the controls of the settings the last save could not save. */
function markFailed(layout, store) {
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
for (const control of layout.querySelectorAll('[data-setting]')) {
if (failed.has(control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
else control.removeAttribute('aria-invalid');
}
}
/**
* The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the
* hand-off to the maintenance tools that are not native yet (D123).
*/
export const DELIVERED_SECTIONS = Object.freeze({
overview: mountOverview,
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
});
/**
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
* (`(container, {api, activity, scope, drafts}) => nodes`), and every other section hands off. `activity` is the page's
* Activity store, which follows the jobs a section starts.
*
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
* until a read returned one, a host save fails without a request (system-state.mjs).
*/
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
const store = systemDrafts(api);
store.reloadHost();
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
onSelect: id => navigate(`#/system/${id}`) });
const layout = el('div', undefined, 'settings-layout');
const saveRow = el('div', undefined, 'save-row');
const status = el('span', undefined, 'small');
status.id = 'settings-status';
const save = createButton('Save preferences', 'ca-primary');
saveRow.append(status, save);
layout.append(saveRow);
main.append(nav, layout);
const paintStatus = () => {
status.textContent = store.statusText();
if (store.dirty() && !store.saving()) save.removeAttribute('aria-disabled');
else save.setAttribute('aria-disabled', 'true');
markFailed(layout, store);
};
scope.cleanup(store.subscribe(paintStatus));
scope.listen(save, 'click', () => {
if (save.getAttribute('aria-disabled') === 'true') return;
const saving = store.save();
paintStatus();
saving.then(() => { if (!scope.signal.aborted) paintStatus(); });
});
let sectionScope = null;
scope.cleanup(() => sectionScope?.dispose());
function show(next) {
sectionScope?.dispose();
sectionScope = createScope();
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
while (layout.firstChild !== saveRow) layout.firstChild.remove();
const mount = sections[next];
const nodes = mount ? mount(layout, { api, activity, scope: sectionScope, drafts: store }) : handoff(next);
layout.prepend(...nodes);
paintStatus();
}
show(section);
return { show };
}
+101 -42
View File
@@ -11,7 +11,8 @@
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…"). D55: the
* concept's own "Scan an output folder → Review scan" row follows as "Scan for metadata".
* - "Review metadata scan" (a1428–1432 `scan-review`, D55): Sources and Scope in a `dl.meta-grid`, the concept's
* sentence, a backup `.notice` ("Back up now") and Cancel / Start scan; no sample-plan note.
* sentence, a backup `.notice` ("Back up now") and Cancel / Start scan; no sample-plan note. It is one caller of
* `openReviewDialog`, the review every record-changing System operation shares, with its `backupNotice`.
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
* action; used by the Images selection bar's "Set category" (#249 D8) and the toolbar's Save view.
@@ -367,6 +368,98 @@ const BACKUP_BUSY_LABEL = 'Backing up…';
export const SCAN_SCOPE_LABELS = { new: 'New and unresolved files', all: 'All files' };
export const SCAN_ALL_FILES_HINT = 'Also read the files already linked to their prompt again.';
/** The scan review's backup box copy (D55). */
export const SCAN_BACKUP_COPY = Object.freeze({ heading: 'Back up the database first (recommended)',
text: 'Downloads a copy of the prompts database before the scan changes any records.' });
/**
* The D55 backup box: the concept's System `.notice` (`div` > `h3` + `p`, then `.row-actions` with "Back up now").
* `onBackup` runs the backup and toasts its outcome; until it settles the button reads "Backing up…" and is
* aria-disabled, so a second press is ignored. It never closes the dialog it sits in.
*/
export function backupNotice({ copy, onBackup }) {
if (typeof copy?.heading !== 'string' || !copy.heading || typeof copy.text !== 'string' || !copy.text) {
throw new TypeError('The backup box needs its heading and text');
}
requireCallbacks({ onBackup });
const notice = el('div', undefined, 'notice');
const text = el('div');
text.append(el('h3', copy.heading), el('p', copy.text));
const actions = el('div', undefined, 'row-actions');
const backup = createButton(BACKUP_LABEL);
actions.append(backup);
notice.append(text, actions);
backup.addEventListener('click', async () => {
if (backup.getAttribute('aria-disabled') === 'true') return;
backup.setAttribute('aria-disabled', 'true');
backup.textContent = BACKUP_BUSY_LABEL;
try {
await onBackup();
} finally {
backup.removeAttribute('aria-disabled');
backup.textContent = BACKUP_LABEL;
}
});
return notice;
}
function requireGrid(grid) {
if (!Array.isArray(grid) || grid.length === 0 || grid.some(row => !Array.isArray(row) || row.length !== 2 ||
typeof row[0] !== 'string' || !row[0] || typeof row[1] !== 'string')) {
throw new TypeError('The review needs its [term, value] grid');
}
}
/**
* The shared record-changing review (the concept's `scan-review`, maintenance plan and restore preview): an optional
* `h3` heading, the `dl.meta-grid` of `[term, value]` rows, page-built `options` nodes, the optional `p.small`
* sentence, the D55 backup box when `backup` is `{copy, onBackup}`, then `.dialog-actions` with `cancelLabel` and the
* primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls
* `onStart()`. Returns `{setValue(term, text)}` for a grid value that follows an option.
*/
export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null,
cancelLabel, primary, onStart, trigger = null }) {
requireScope(scope);
requireCallbacks({ onStart });
for (const [name, text] of Object.entries({ title, cancelLabel, label: primary?.label })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The review needs its ${name}`);
}
requireGrid(grid);
if (!Array.isArray(options) || options.some(node => !node || typeof node !== 'object')) {
throw new TypeError('The review options must be nodes');
}
const nodes = [];
if (heading) nodes.push(el('h3', heading));
const list = el('dl', undefined, 'meta-grid');
const values = new Map();
for (const [term, value] of grid) {
const entry = el('div');
const dd = el('dd', value);
values.set(term, dd);
entry.append(el('dt', term), dd);
list.append(entry);
}
nodes.push(list, ...options);
if (sentence) nodes.push(el('p', sentence, 'small'));
if (backup) nodes.push(backupNotice(backup));
const cancel = createButton(cancelLabel);
const start = createButton(primary.label, primary.danger ? 'danger' : 'ca-primary');
cancel.addEventListener('click', () => finish());
start.addEventListener('click', () => {
finish();
onStart();
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, start);
nodes.push(actions);
show(title, nodes, { scope, trigger });
return {
setValue(term, text) {
const dd = values.get(term);
if (dd) dd.textContent = text;
},
};
}
/**
* The concept's "Review metadata scan" (`scan-review`, D55). `sources` is the number of output folders the scan reads.
@@ -379,18 +472,9 @@ export function openScanReviewDialog({ scope, sources, onBackup, onStart, trigge
requireScope(scope);
requireCallbacks({ onBackup, onStart });
if (!Number.isSafeInteger(sources) || sources < 0) throw new TypeError('The scan review needs its source count');
const grid = el('dl', undefined, 'meta-grid');
const values = {};
for (const [term, value] of [['Sources', `${sources.toLocaleString('en-US')} output ${sources === 1 ? 'folder' : 'folders'}`],
['Scope', SCAN_SCOPE_LABELS.new]]) {
const entry = el('div');
values[term] = el('dd', value);
entry.append(el('dt', term), values[term]);
grid.append(entry);
}
// The concept's System toggle: a `.setting-row` whose checkbox is the Toolkit switch (concept `hydrateSwitches`).
const toggle = createSwitch('', '', 'scan-all-files');
const allFiles = toggle.querySelector('input');
const allFiles = toggle.children[0];
allFiles.checked = false;
const option = el('div', undefined, 'setting-row');
const optionText = el('div');
@@ -402,35 +486,10 @@ export function openScanReviewDialog({ scope, sources, onBackup, onStart, trigge
optionText.append(optionLabel, hint);
option.append(optionText, toggle);
const chosenScope = () => (allFiles.checked ? 'all' : 'new');
allFiles.addEventListener('change', () => { values.Scope.textContent = SCAN_SCOPE_LABELS[chosenScope()]; });
const notice = el('div', undefined, 'notice');
const copy = el('div');
copy.append(el('h3', 'Back up the database first (recommended)'),
el('p', 'Downloads a copy of the prompts database before the scan changes any records.'));
const backupActions = el('div', undefined, 'row-actions');
const backup = createButton(BACKUP_LABEL);
backupActions.append(backup);
notice.append(copy, backupActions);
backup.addEventListener('click', async () => {
if (backup.getAttribute('aria-disabled') === 'true') return;
backup.setAttribute('aria-disabled', 'true');
backup.textContent = BACKUP_BUSY_LABEL;
try {
await onBackup();
} finally {
backup.removeAttribute('aria-disabled');
backup.textContent = BACKUP_LABEL;
}
});
const cancel = createButton('Cancel');
const start = createButton('Start scan', 'ca-primary');
cancel.addEventListener('click', () => finish());
start.addEventListener('click', () => {
const files = chosenScope();
finish();
onStart({ scope: files });
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, start);
show('Review metadata scan', [grid, option, el('p', SCAN_REVIEW_SENTENCE, 'small'), notice, actions], { scope, trigger });
const review = openReviewDialog({ scope, trigger, title: 'Review metadata scan',
grid: [['Sources', `${sources.toLocaleString('en-US')} output ${sources === 1 ? 'folder' : 'folders'}`],
['Scope', SCAN_SCOPE_LABELS.new]],
options: [option], sentence: SCAN_REVIEW_SENTENCE, backup: { copy: SCAN_BACKUP_COPY, onBackup },
cancelLabel: 'Cancel', primary: { label: 'Start scan' }, onStart: () => onStart({ scope: chosenScope() }) });
allFiles.addEventListener('change', () => review.setValue('Scope', SCAN_SCOPE_LABELS[chosenScope()]));
}
+9
View File
@@ -82,6 +82,15 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.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 */ }
/* 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 */ }
@media (max-width: 700px) {
.stat-grid { gap: 8px; }
.stat { padding: 16px; }