Compare commits

...
36 changed files with 4637 additions and 2 deletions
+3
View File
@@ -9,6 +9,9 @@ on:
push:
branches: [main]
env:
PYTHONFAULTHANDLER: "1"
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+22
View File
@@ -128,3 +128,25 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
## Unified workspace shell
The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real
`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain
available while replacement workspaces are developed. Browser smoke tests in
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
mobile layout, theme persistence/fallback, loading/retry and stale responses.
They run automatically in the existing four-browser-project workflow and frontend
coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
Run the focused page checks with:
```sh
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs
node --test tests/js/workspace-*.test.js
```
Use the disposable fixture and managed-host prerequisites above. A maintainer
live check needs a ComfyUI restart after adding the Python route, then opening
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
+75
View File
@@ -0,0 +1,75 @@
# Application Redesign Development Plan
The maintainer approved the [interactive concept](concepts/workspace/README.md)
on 2026-10-17. It is a design reference with simulated operations, not production
code. This plan proposes implementation order and acceptance gates.
## Scope and compatibility
Rebuild the standalone application around one shared shell. Preserve existing
node schemas, saved workflows, backend capabilities, pinned Viewer.js and the
ComfyAssets toolkit. Keep existing application entry points usable during the
transition. Do not change the running maintainer instance during automated tests.
Use the concept's feature inventory as a parity checklist. Each row needs an
implementation issue, observable acceptance criteria and automated test coverage.
Record any intentional behavior change explicitly; no silent feature removal.
## Proposed delivery sequence
Use a tracking issue and small issue-linked branches and PRs. Dependencies may
require splitting these milestones further.
| Milestone | Deliverable and acceptance focus |
| --- | --- |
| 1. Foundation | Production shell, routing, shared theme and disposable page-test harness. Verify startup, navigation, dark/light changes, cleanup and existing entry points. Establish discovered-test and coverage baselines before changes. |
| 2. Image workspace | Real API-backed filters, Grid/List/Masonry, readable thumbnails, selection and shared Viewer.js inspector. Continuous infinite scroll only, with accessible Load more fallback, stable ordering, retry and restored browsing context. Bound retained DOM and test large datasets. |
| 3. Prompt library and history | Real positive/negative associations, selected full negative, run history and shared image filtering. Design any missing execution-history persistence and migration before wiring the timeline; never invent historical runs from incomplete metadata. |
| 4. Send to ComfyUI | Shared paper-plane actions wherever prompt text appears; role-specific and pair sends, destination preview, acknowledgement and failure recovery. Cover explicit empty versus unknown negatives and connected inputs. Never queue or replace a workflow. |
| 5. Editing and review | Prompt editing, vocabulary management, per-image manual/auto-tag approval and Needs review. Preserve cancellation semantics and distinguish tag approval from prompt-association decisions. |
| 6. System and maintenance | Settings, sources/scans, integrations, jobs, diagnostics, backup/restore and maintenance impact previews. Cover existing capabilities before retiring their old controls. |
| 7. Additional organization | Favorites independent of ratings, collections, saved views, Activity, scheduled backups and portable import previews. Specify persistence, validation and recovery separately for each capability. |
| 8. Cutover | Complete the parity checklist, migration and upgrade validation, accessibility/performance checks, documentation and targeted maintainer live testing. Switch the default only after these pass; preserve compatible URLs. |
## TDD and test layers
For each behavior, first write and run a meaningful failing test. Implement the
smallest working change, verify it passes, then refactor with the suite green.
Include failing-before/passing-after evidence in the PR.
- **Unit:** filtering and selection state, cursor handling, prompt roles,
formatting, validation, navigation and lifecycle cleanup.
- **Integration:** real HTTP handlers and disposable SQLite/filesystem fixtures;
persistence, migration from supported old schemas, idempotent scans, restore,
rollback and reference-safe deletion. Mock inference and external services.
- **Smoke:** shipped production pages start and navigate with real fixture APIs;
test populated, empty and error states, assets and unexpected console failures.
- **Browser workflows:** production HTML/JS across Chromium, Firefox, WebKit and
touch; editing/reload, infinite append, viewer cleanup, tagging approval,
keyboard access, delayed responses, cancellation and retries.
- **Host integration:** pinned disposable CPU ComfyUI, Classic and Nodes 2.0,
switching both ways and workflow reload for host-facing changes. Exercise
independent and combined pack installations for shared bridge/toolkit changes.
Retain existing test discovery and coverage gates. Carry forward database
injection and open-handle checks into new fixtures. Add new frontend modules to
coverage measurement and its baseline; do not lower floors or replace existing
regression coverage with mockup-only tests.
## GitHub and release gates
Extend existing required workflows: the Python 3.10–3.13 matrix on Linux,
Windows and macOS; 85% Python coverage; formatting; frontend units and per-file
coverage floors; vendor checks; browser workflows; real ComfyUI compatibility.
Publish traces, screenshots, logs and coverage artifacts on failures. See
[TESTING.md](../TESTING.md) for commands and fixture isolation requirements.
Every PR must map acceptance criteria to tests, report relevant checks and include
screenshots for visual changes. All required checks must pass before merging.
Use maintainer live review at meaningful visual and host-integration milestones;
capture discovered regressions in automated tests. Notify the maintainer when
Python changes require a ComfyUI restart.
Keep version/publication changes separate from intermediate UI work: the existing
registry workflow publishes on main/master changes to `pyproject.toml` or manual
dispatch. Do not trigger a release merely to preview a redesign milestone.
+136
View File
@@ -0,0 +1,136 @@
# Prompt Manager application redesign concept
Status: visual concept approved by the maintainer, 2026-10-17. Production
implementation is pending; see the [development plan](../../application-redesign.md).
## Review the mockup
Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and
open `/workspace/`. The original approved timeline remains available at `/`.
The server exposes an explicit allowlist of concept files and pinned assets.
All examples are synthetic; there are no application API calls, database access,
real model downloads, or ComfyUI handoffs. Local file inspection previews an
image in the browser without uploading it. Exports contain sample data only.
The sidebar opens Images, Prompt library, Tags & categories, Inspect a file,
Activity, System and Design review. System includes Overview, Appearance,
Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and
Diagnostics & logs. The in-app Design review contains the feature map below.
## Proposed direction
One application shell replaces separate Admin, Gallery and Metadata shells.
Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary
actions, green positive roles, red negative roles and errors. Preserve existing
capabilities and services while consolidating controls and clarifying their scope.
Browsing is image-led with readable thumbnails and one metadata inspector.
Prompt library groups by positive; negative variants and runs share its gallery.
The full selected negative appears directly beneath the positive in history.
Favorites express preference independently of the existing quality rating.
Collections organize projects without duplicating recipe text; saved views keep
search/filter context. New capabilities are proposals, not implemented features.
Maintenance moves out of the browsing toolbar. Checks, impact preview, backups,
and apply steps have separate responsibilities. Distinguish prompt text reuse
from image-file duplicates, and metadata enrichment from thumbnail generation.
No repeated global thumbnail-generation prompt on opening the gallery.
## Send to ComfyUI
Use the same original paper-plane outline, blue accent and accessible role label
on every positive/negative prompt block, including image details and the viewer.
A pair action sends both. Preview destination and exact fields before applying;
positive-only and negative-only actions leave the other role untouched. An
explicitly empty negative means clear the target field; unknown metadata must
never be interpreted as empty. Sending text neither queues execution nor
replaces a workflow.
Production needs a shared host bridge with versioned payloads, origin validation,
destination discovery, capability checks, disconnected-host handling, linked-input
checks and acknowledgements. Preview actual target values and report success only
after acknowledgement. Default to asking for a destination; never guess between
multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes
2.0 with both packs installed independently and together.
## Existing feature inventory
| Current feature | Proposed home / behavior | Disposition |
| --- | --- | --- |
| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt |
| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated |
| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained |
| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated |
| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified |
| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained |
| Grid/list, thumbnail size, columns, pages/infinite scroll | Infinite scroll only; one Appearance configuration and contextual layout / density overrides | Simplified |
| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated |
| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained |
| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained |
| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow |
| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained |
| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained |
| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained |
| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained |
| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained |
| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal |
| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept |
| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal |
| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded |
| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal |
| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal |
## Implementation boundaries and test requirements
Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts;
this mock uses their actual checked-in files without modifying them. Preserve
legacy routes during the transition, current node layouts/schemas, existing
controllers where reusable, and all existing test/coverage gates.
The timeline needs persisted execution records to represent real runs. Saved
views, collections, metadata review resolution, activity history, scheduled
backups, portable JSON import, and the ComfyUI bridge need separate implementation
and persistence work. Preserve source evidence and distinguish recorded from
inferred metadata. Existing schema migration is automatic; old image enrichment
remains an optional explicit scan. The one-time upgrade notice belongs in the
new shell, with Scan and Later choices.
Implement in reviewed slices: shared application shell and inspector; reusable
prompt blocks/send bridge; image workspace; grouped library/history; settings and
maintenance; optional organization and workflow improvements. Write regressions
first for production behavior. Retain Python, frontend unit, Chromium/Firefox/
WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared
filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe
maintenance, backup/restore, paired metadata and correct send destinations.
Screenshots in `screenshots/` record the reviewed starting point. The approved
standalone timeline in `../prompt-variant-timeline.html` remains unchanged.
## Review polish
History actions use a shared branch icon. Images can switch among Grid, List and
Masonry; Masonry preserves each image's proportions and keeps DOM order while
packing cards with a responsive layout. It does not require experimental native
masonry support. Resize observers are disconnected when leaving the view.
Every image card includes a tagging action beside Info, including cards in Needs
review. Choose WD14 SwinV2, WD14 ViT, Ollama or HuggingFace; adjust thresholds or
instructions, generate sample suggestions, edit tags manually, and approve only
the desired suggestions. Changes are staged until approval, apply to that image
alone and do not resolve uncertain prompt associations. Changing models discards
previous suggestions. Cancel preserves existing tags. All inference is simulated.
## Continuous browsing decision
The redesign uses infinite scroll only. Remove page numbers, previous/next page
buttons, paged/infinite mode selection and results-per-page controls. Keep request
batching internal. The mock appends 12 images at a time in Grid, List and Masonry;
it preserves existing card nodes, focus and selection while appending. It also
retains the loaded range when switching layouts or revisiting a search. A
keyboard-accessible Load more action provides an alternate trigger for the same
continuous stream, not pagination. Exhausted and empty results are explicit.
Production will need bounded rendering, stable cursors, scroll restoration and
loading/error/retry behavior for large collections. Existing backend pagination
can continue providing internal request batches; no production tests or routes
are removed as part of this prototype.
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Images · Prompt Manager concept</title>
<link rel="stylesheet" href="/toolkit/controls.css" />
<link rel="stylesheet" href="/vendor/viewer.css" />
<link rel="stylesheet" href="/workspace/style.css" />
<script src="/vendor/viewer.js" defer></script>
<script src="/workspace/app.js" type="module"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#main">Skip to workspace</a>
<div class="app-shell">
<aside class="sidebar">
<a class="brand" href="#images"
><span class="brand-mark">P<span>±</span></span
><span>Prompt Manager<small>COMFYASSETS</small></span></a
>
<div class="sidebar-label">WORKSPACE</div>
<nav id="primary-nav" aria-label="Workspace"></nav>
<div class="sidebar-label">
SAVED VIEWS <span class="new-label">PROPOSED</span>
</div>
<nav id="saved-nav" aria-label="Saved views"></nav>
<div class="sidebar-label">
COLLECTIONS <span class="new-label">PROPOSED</span>
</div>
<nav id="collections-nav" aria-label="Collections"></nav>
<div class="sidebar-bottom">
<nav id="system-nav" aria-label="Tools and system"></nav>
<div class="connection">
<i></i>
<div>
ComfyUI workspace<small
>Demo connection · Classic / Nodes 2.0</small
>
</div>
</div>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div class="breadcrumbs">
ComfyAssets <span>/</span> <span id="breadcrumb">Images</span>
</div>
<div class="top-actions">
<span class="concept-badge">Interactive concept</span
><button id="activity-open" type="button">
Activity <span class="count" id="activity-count">2</span></button
><label class="theme-label" for="theme"
>Theme
<select id="theme" aria-label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select></label
><a class="canvas-link" href="#send"
>ComfyUI <span aria-hidden="true">↗</span></a
>
</div>
</header>
<main id="main" tabindex="-1"></main>
<footer class="app-footer">
<span
>Design prototype · sample images and data · actions stay in this
tab</span
><button type="button" id="review-link">What changes & why</button>
</footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-atomic="true"></div>
<dialog id="modal" aria-labelledby="modal-title">
<div class="dialog-header">
<h2 id="modal-title"></h2>
<button type="button" id="modal-close" aria-label="Close dialog">
×
</button>
</div>
<div id="modal-body"></div>
</dialog>
<dialog id="viewer-dialog" aria-labelledby="viewer-title">
<div class="dialog-header">
<div>
<h2 id="viewer-title">Image details</h2>
<p class="muted small">
One viewer · image, prompts, generation data, workflow
</p>
</div>
<button type="button" id="viewer-close" aria-label="Close image viewer">
×
</button>
</div>
<div class="viewer-layout">
<div class="viewer-left">
<div id="viewer-stage"></div>
<div class="viewer-toolbar" id="viewer-toolbar"></div>
</div>
<aside id="viewer-details" aria-label="Image metadata"></aside>
</div>
</dialog>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+5
View File
@@ -85,6 +85,7 @@ def _public_error(exc: BaseException) -> str:
# doubles as the allow-list.
STATIC_MIME_TYPES = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".map": "application/json",
".woff": "font/woff",
@@ -363,6 +364,10 @@ class PromptManagerAPI(
# ── Web UI serving routes ─────────────────────────────────────
@routes.get("/prompt_manager/workspace")
async def serve_workspace_ui(request: web.Request) -> web.Response:
return self._serve_html_page("workspace.html", "Workspace")
@routes.get("/prompt_manager/web")
async def serve_web_ui(request):
return self._serve_html_page("index.html", "Web UI", cache=False)
+218
View File
@@ -0,0 +1,218 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace = '/prompt_manager/workspace';
const statistic = (page, label) => page.locator('#workspace-main dt')
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
const routes = [
['overview', 'Overview', null],
['images', 'Images', '/prompt_manager/gallery'],
['library', 'Prompt library', '/prompt_manager/admin'],
['tags', 'Tags & categories', '/prompt_manager/admin#/tags'],
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
['system', 'System', '/prompt_manager/admin'],
];
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.workspaceErrors = [];
page.on('pageerror', error => page.workspaceErrors.push(error.message));
page.on('response', response => {
if (response.status() >= 400 && /\/prompt_manager\/(js|lib)\//.test(response.url())) {
page.workspaceErrors.push(`${response.status()} ${response.url()}`);
}
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.workspaceErrors).toEqual([]);
});
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
const before = await (await request.get('/prompt_manager/stats')).json();
const mutations = [];
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
});
await page.goto(workspace);
await expect(page).toHaveTitle(/Prompt Manager/);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
}
expect(before.stats.total_prompts).toBe(2);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
expect(mutations).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
});
test('all shell destinations deep-link, reload and expose working existing applications', async ({ page, request }) => {
for (const [route, label, legacy] of routes) {
await page.goto(`${workspace}#/${route}`);
const main = page.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
if (legacy) {
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
await expect(await request.get(legacy.split('#')[0])).toBeOK();
}
await page.reload();
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
}
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
await page.goto(workspace);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
const images = navigation.getByRole('link', { name: 'Images', exact: true });
await images.focus();
await page.keyboard.press('Enter');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goBack();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.goForward();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goto(`${workspace}#/not-a-page`);
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(navigation.getByRole('link', { name: 'Overview', exact: true }))
.toHaveAttribute('aria-current', 'page');
});
test('skip to content focuses the current view without changing its route', async ({ page }) => {
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(`${workspace}#/library`);
await expect(page.locator('#workspace-main')).toBeFocused();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
});
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
await page.goto(workspace);
const prompts = statistic(page, 'Prompts');
await expect(prompts).toHaveText('2');
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
await expect(prompts).toHaveText('3');
await expect(navigation).toHaveCount(1);
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
});
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
await page.goto(workspace);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
await appearance.selectOption({ label: 'Dark' });
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await page.reload();
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
await expect(appearance).toHaveValue('dark');
await appearance.selectOption({ label: 'Light' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await appearance.selectOption({ label: 'Follow ComfyUI' });
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
});
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(workspace);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await page.emulateMedia({ colorScheme: 'dark' });
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
});
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.unroute('**/prompt_manager/stats');
await page.route('**/prompt_manager/stats', route => route.fulfill({
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
}));
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
for (const label of ['Prompts', 'Images', 'Tags']) {
await expect(statistic(page, label)).toHaveText('0');
}
});
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(workspace);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
for (const [, label] of routes) {
await navigation.getByRole('link', { name: label, exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText(label);
}
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test('leaving a loading overview prevents its delayed result from replacing the new view', async ({ page }) => {
let release;
let completed;
const pending = new Promise(resolve => { release = resolve; });
const finished = new Promise(resolve => { completed = resolve; });
await page.route('**/prompt_manager/stats', async route => {
await pending;
await route.fulfill({ json: {
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
} }).catch(() => {});
completed();
});
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
release();
await finished;
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(page.locator('#workspace-main')).not.toContainText('999');
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
});
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
let reads = 0;
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
});
await page.goto(workspace);
await expect(statistic(page, 'Prompts')).toHaveText('2');
for (let restore = 0; restore < 2; restore++) {
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
});
await expect(statistic(page, 'Prompts')).toHaveText('2');
}
expect(reads).toBe(3);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
});
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test);
test('workspace serves real library data beside either ComfyUI renderer', async ({ page, context, request }, testInfo) => {
const saved = await request.post('/prompt_manager/save', { data: {
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
} });
expect(saved.ok()).toBe(true);
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
const workspace = await context.newPage();
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('#workspace-main a')).toHaveAttribute('href', '/prompt_manager/gallery');
await workspace.reload();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
await workspace.screenshot({ path: testInfo.outputPath('workspace-host.png') });
expect(errors).toEqual([]);
expect(await page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
} finally {
await workspace.close();
}
});
+49
View File
@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/state.mjs');
test('workspace routes resolve only supported deep links', async () => {
const { resolveRoute } = await source;
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
assert.equal(resolveRoute(`#/${route}`).id, route);
}
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
assert.equal(resolveRoute(hash).id, 'overview');
}
});
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
const { summarizeStats } = await source;
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
['Prompts', 0], ['Images', 12], ['Tags', 3],
]);
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
{total_prompts:'2', total_images:0, total_tags:0},
{total_prompts:Infinity, total_images:0, total_tags:0},
{total_prompts:0, total_images:1.5, total_tags:0}]) {
assert.throws(() => summarizeStats(stats), /statistics/i);
}
});
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
const { mountRouter } = await source;
const win = new EventTarget();
win.location = {hash:'#/missing'};
const replacements = [];
win.history = {state: {keep:1}, replaceState(state, unused, hash) {
replacements.push([state, hash]); win.location.hash = hash;
}};
const seen = [];
const dispose = mountRouter(win, route => seen.push(route.id));
assert.deepEqual(seen, ['overview']);
assert.deepEqual(replacements, [[{keep:1}, '#/overview']]);
win.location.hash = '#/images';
win.dispatchEvent(new Event('hashchange'));
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['overview', 'images']);
dispose();
dispose();
win.location.hash = '#/system';
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['overview', 'images']);
});
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope } from '../../web/js/shared/toolkit/core.js';
import { mountWorkspaceTheme } from '../../web/js/workspace/theme.mjs';
function fixture({ dark = false, preference, fetchImpl, storageDenied = false, pathname = '/prompt_manager/workspace' } = {}) {
const media = new EventTarget(); media.matches = dark;
const win = new EventTarget(); win.location = { pathname }; win.matchMedia = () => media;
const documentElement = { style: { colorScheme: 'light dark' } };
const root = { dataset: {}, ownerDocument: { documentElement } };
const saved = new Map(preference === undefined ? [] : [['pm_workspace_theme', preference]]);
const storage = {
getItem(key) { if (storageDenied) throw new Error('denied'); return saved.get(key); },
setItem(key, value) { if (storageDenied) throw new Error('denied'); saved.set(key, value); },
};
const calls = [];
const scope = createScope();
const theme = mountWorkspaceTheme({ root, scope, win, storage, fetchImpl: fetchImpl || (async (url, init) => {
calls.push({ url, init }); return { ok: true, json: async () => 'dark' };
}) });
return { root, documentElement, saved, media, win, scope, theme, calls };
}
const response = value => ({ ok: true, json: async () => value });
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.documentElement.style.colorScheme, 'dark');
assert.equal(f.calls[0].url, '/proxy/comfy/api/settings/Comfy.ColorPalette');
f.scope.dispose();
});
test('supported light palettes accept string and wrapped values', async () => {
for (const palette of ['light', 'solarized', 'github', 'arc']) {
const f = fixture({ dark: true, fetchImpl: async () => response({ value: palette }) });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light', palette);
f.scope.dispose();
}
});
test('explicit overrides persist and follow restores host choice', async () => {
const f = fixture({ preference: 'light' }); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light');
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
f.scope.dispose();
});
test('unknown host palettes and invalid stored preferences follow changing OS', async () => {
const f = fixture({ preference: 'orange', fetchImpl: async () => response('custom') });
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'light');
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
assert.equal(f.root.dataset.caTheme, 'dark'); f.scope.dispose();
});
test('network, HTTP, JSON and storage failures recover using system theme', async () => {
for (const fetchImpl of [async () => { throw new Error('offline'); }, async () => ({ ok: false }), async () => ({ ok: true, json: async () => { throw new Error('invalid'); } })]) {
const f = fixture({ dark: true, storageDenied: true, fetchImpl });
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'dark');
assert.doesNotThrow(() => f.theme.setPreference('light'));
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
}
});
test('focus refreshes host palette and stale responses cannot replace newest palette', async () => {
const pending = [];
const f = fixture({ fetchImpl: (url, init) => new Promise(resolve => pending.push({ resolve, signal: init.signal })) });
f.win.dispatchEvent(new Event('focus'));
assert.equal(pending.length, 2); assert.equal(pending[0].signal.aborted, true);
pending[1].resolve(response('light')); await tick();
pending[0].resolve(response('dark')); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
});
test('cleanup aborts requests, restores attributes and native scheme and removes listeners', async () => {
let resolve, fSignal;
const f = fixture({ fetchImpl: (url, init) => new Promise(done => { resolve = done; fSignal = init.signal; }) });
f.scope.dispose(); assert.equal(fSignal.aborted, true);
assert.equal(f.root.dataset.caTheme, undefined);
assert.equal(f.documentElement.style.colorScheme, 'light dark');
f.win.dispatchEvent(new Event('focus')); f.media.dispatchEvent(new Event('change'));
resolve(response('dark')); await f.theme.ready;
assert.equal(f.root.dataset.caTheme, undefined);
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, undefined);
});
test('cleanup restores an existing toolkit theme', async () => {
const f = fixture(); await f.theme.ready; f.scope.dispose();
f.root.dataset.caTheme = 'light';
const scope = createScope();
const theme = mountWorkspaceTheme({ root: f.root, scope, win: f.win, storage: f.saved, fetchImpl: async () => response('dark') });
await theme.ready; scope.dispose(); assert.equal(f.root.dataset.caTheme, 'light');
});
test('malformed palette payloads fall back without losing native theme alignment', async () => {
for (const payload of [null, {}, {value: 42}, 42]) {
const f = fixture({ dark: true, fetchImpl: async () => response(payload) });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
assert.equal(f.documentElement.style.colorScheme, 'dark');
f.scope.dispose();
}
});
test('focus failure clears obsolete host palette and resumes system following', async () => {
let calls = 0;
const f = fixture({ fetchImpl: async () => {
if (++calls === 1) return response('dark');
throw new Error('host offline');
} });
await f.theme.ready;
assert.equal(f.root.dataset.caTheme, 'dark');
f.win.dispatchEvent(new Event('focus')); await tick();
assert.equal(f.root.dataset.caTheme, 'light');
f.scope.dispose();
f.win.dispatchEvent(new Event('focus')); await tick();
assert.equal(calls, 2);
});
test('blocked localStorage getter and missing proxy marker stay usable', async () => {
const f = fixture(); await f.theme.ready; f.scope.dispose();
Object.defineProperty(f.win, 'localStorage', { get() { throw new Error('denied'); } });
f.win.location.pathname = '/standalone';
const scope = createScope();
let url;
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
assert.equal(theme.preference, 'follow');
await theme.ready;
assert.equal(url, '/api/settings/Comfy.ColorPalette');
assert.equal(f.root.dataset.caTheme, 'dark');
theme.setPreference('light');
assert.equal(theme.preference, 'light');
scope.dispose();
});
+16 -1
View File
@@ -385,6 +385,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
async def tearDownAsync(self):
await super().tearDownAsync() # closes the aiohttp test client
self.api.db.close_all()
assert_closed(self, self.tmpdir)
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _fake_root(self):
@@ -399,6 +400,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
UI_PATHS = (
"/prompt_manager/workspace",
"/prompt_manager/web",
"/prompt_manager/gallery.html",
"/prompt_manager/admin",
@@ -429,7 +431,13 @@ class TestUiRoutes(CoreRoutesTestCase):
async def test_unreadable_pages_are_500(self):
web_dir = self._fake_root()
for name in ("index.html", "metadata.html", "admin.html", "gallery.html"):
for name in (
"workspace.html",
"index.html",
"metadata.html",
"admin.html",
"gallery.html",
):
(web_dir / name).mkdir() # a directory cannot be read as a file
for path in UI_PATHS:
resp = await self.client.request("GET", path)
@@ -448,6 +456,13 @@ class TestStaticRoutes(CoreRoutesTestCase):
self.assertEqual(resp.status, 200)
self.assertIn("application/javascript", resp.headers["Content-Type"])
async def test_workspace_module_served_with_javascript_mime(self):
resp = await self.client.request(
"GET", "/prompt_manager/js/workspace/index.mjs"
)
self.assertEqual(resp.status, 200)
self.assertIn("application/javascript", resp.headers["Content-Type"])
async def test_lib_served(self):
web_dir = self._fake_root()
(web_dir / "lib" / "x.css").write_text("body{}")
+6 -1
View File
@@ -1,4 +1,4 @@
"""Serve only the timeline concept and its pinned toolkit assets."""
"""Serve only UI concepts and their pinned toolkit and viewer assets."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -8,6 +8,11 @@ from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
FILES = {
"/": ("docs/concepts/prompt-variant-timeline.html", "text/html; charset=utf-8"),
"/workspace/": ("docs/concepts/workspace/index.html", "text/html; charset=utf-8"),
"/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"),
"/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"),
"/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"),
"/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"),
"/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"),
"/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"),
}
+18
View File
@@ -136,5 +136,23 @@
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/index.mjs": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"workspace/state.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/theme.mjs": {
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 98
}
}
+119
View File
@@ -0,0 +1,119 @@
import '../api-client.js';
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function legacyLink(route) {
const link = element('a', route.action, 'workspace-link');
link.href = resolveUrl(route.href);
return link;
}
function mountWorkspace() {
const scope = createScope();
const api = createApiClient();
const main = document.getElementById('workspace-main');
const nav = document.querySelector('nav[aria-label="Workspace"]');
const appearance = document.getElementById('appearance-control');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
main.focus();
});
const theme = mountWorkspaceTheme({ root: document.body, scope });
const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], '');
preference.value = theme.preference;
appearance.replaceChildren(createField('Appearance', preference, 'workspace-appearance'));
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
nav.replaceChildren(...routes.map(route => {
const link = element('a', route.label);
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
}));
let viewScope;
let firstRender = true;
scope.cleanup(() => viewScope?.dispose());
function render(route) {
viewScope?.dispose();
viewScope = createScope();
const view = viewScope;
for (const link of nav.children) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
document.title = `${route.label} · Prompt Manager`;
const heading = element('h1', route.label);
main.replaceChildren(element('p', 'YOUR CREATIVE LIBRARY', 'eyebrow'), heading);
if (!firstRender) main.focus();
firstRender = false;
if (route.id !== 'overview') {
main.append(element('p', route.description, 'intro'));
const panel = element('section', undefined, 'panel 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;
}
main.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
const tools = element('section', undefined, 'panel');
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
const cards = element('div', undefined, 'tool-grid');
for (const target of routes.filter(item => item.href)) {
const card = element('article', undefined, 'tool-card');
card.append(element('h3', target.label), element('p', target.description), legacyLink(target));
cards.append(card);
}
tools.append(cards);
main.append(tools);
async function loadStats() {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
stats.replaceChildren(status);
try {
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
if (view.signal.aborted) return;
const values = summarizeStats(result.stats);
const list = element('dl', undefined, 'stat-grid');
for (const [label, count] of values) {
const stat = element('div', undefined, 'panel stat');
stat.append(element('dt', label), element('dd', count.toLocaleString()));
list.append(stat);
}
stats.replaceChildren(list);
if (values.every(([, count]) => count === 0)) {
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
} catch (error) {
if (view.signal.aborted) return;
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', loadStats);
stats.replaceChildren(alert, retry);
}
}
loadStats();
}
scope.cleanup(mountRouter(window, render));
scope.listen(window, 'pagehide', () => scope.dispose());
return () => scope.dispose();
}
mountWorkspace();
window.addEventListener('pageshow', event => {
if (event.persisted) mountWorkspace();
});
+41
View File
@@ -0,0 +1,41 @@
export const routes = [
{ id: 'overview', label: 'Overview' },
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
description: 'Find, edit and organize the prompts already in your library.', action: 'Open existing prompt library' },
{ id: 'tags', label: 'Tags & categories', href: '/prompt_manager/admin#/tags',
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
{ id: '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' },
];
export function resolveRoute(hash) {
return routes.find(route => hash === `#/${route.id}`) || routes[0];
}
export function summarizeStats(stats) {
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
.map(([label, key]) => {
const count = stats?.[key];
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
return [label, count];
});
}
export function mountRouter(win, onRoute) {
let current;
const update = () => {
const route = resolveRoute(win.location.hash);
const hash = `#/${route.id}`;
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
if (current === route) return;
current = route;
onRoute(route);
};
win.addEventListener('hashchange', update);
update();
return () => win.removeEventListener('hashchange', update);
}
+64
View File
@@ -0,0 +1,64 @@
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body.ca-tools {
margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124;
background: var(--workspace-page); font-size: 14px;
}
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; }
[hidden] { display: none !important; }
a { color: var(--ca-focus); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100dvh; }
.sidebar { background: var(--workspace-sidebar); border-right: 1px solid var(--ca-line); padding: 28px 18px; display: flex; flex-direction: column; gap: 24px; }
.brand { display: flex; gap: 12px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 16px; }
.brand small { display: block; color: var(--ca-muted); font-size: 11px; font-weight: 400; margin-top: 2px; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--ca-accent); color: white; }
.eyebrow { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--ca-muted); margin: 0; }
nav { display: grid; gap: 6px; }
nav a { color: var(--ca-muted); padding: 11px 14px; border-radius: 7px; min-height: 44px; }
nav a:hover { background: var(--ca-hover); color: var(--ca-text); text-decoration: none; }
nav a[aria-current="page"] { color: #fff; background: var(--ca-accent); }
.sidebar-footer { margin-top: auto; color: var(--ca-muted); font-size: 11px; display: flex; align-items: center; gap: 8px; }
.connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ca-muted); }
.workspace-body { min-width: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 32px; border-bottom: 1px solid var(--ca-line); font-size: 12px; }
.muted { color: var(--ca-muted); }
main { flex: 1; padding: 36px; width: 100%; max-width: 1500px; margin: 0 auto; }
h1 { font-size: clamp(25px, 4vw, 34px); line-height: 1.3; letter-spacing: -1px; margin: 10px 0; font-weight: 620; }
h2 { font-size: 18px; margin: 0 0 8px; }
h3 { font-size: 15px; margin: 0; }
p { line-height: 1.6; }
.intro { color: var(--ca-muted); margin: 0 0 30px; }
.panel { border: 1px solid var(--ca-line); border-radius: 11px; padding: 24px; background: var(--ca-surface); }
.panel > p { color: var(--ca-muted); margin: 0 0 24px; }
.stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0 0 26px; }
.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; }
.tool-card { border: 1px solid var(--ca-line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
.tool-card p { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; flex: 1; }
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
.page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 11px; }
.skip { position: fixed; top: 10px; left: 10px; z-index: 10; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
.skip:focus { clip-path: none; width: auto; height: auto; padding: 12px; background: var(--ca-surface); }
@media (max-width: 780px) {
.workspace-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { padding: 18px; gap: 16px; border-right: 0; border-bottom: 1px solid var(--ca-line); }
nav { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
nav a { padding: 10px 6px; font-size: 12px; text-align: center; }
.sidebar > .eyebrow, .sidebar-footer { display: none; }
.topbar { padding: 14px 18px; flex-wrap: wrap; }
main { padding: 26px 18px; }
.stat-grid { gap: 8px; }
.stat { padding: 16px; }
.stat dd { font-size: 26px; }
.panel { padding: 18px; }
.page-footer { margin: 0 18px; }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
}
+73
View File
@@ -0,0 +1,73 @@
const STORAGE_KEY = 'pm_workspace_theme';
const PREFERENCES = new Set(['follow', 'light', 'dark']);
const LIGHT_PALETTES = new Set(['light', 'solarized', 'github', 'arc']);
const DARK_PALETTES = new Set(['dark', 'obsidian', 'nord', 'dracula', 'gruvbox']);
/** Adapt the standalone page to ComfyUI without coupling the shared toolkit to HTTP. */
export function mountWorkspaceTheme({ root = document.body, scope, fetchImpl = globalThis.fetch, storage, win = window }) {
const documentElement = root.ownerDocument.documentElement;
const previousTheme = root.dataset.caTheme;
const previousScheme = documentElement.style.colorScheme;
const system = win.matchMedia('(prefers-color-scheme: dark)');
let preference = 'follow';
let hostTheme = null;
let request = null;
let sequence = 0;
try {
storage ??= win.localStorage;
const saved = storage?.getItem(STORAGE_KEY);
if (PREFERENCES.has(saved)) preference = saved;
} catch { /* Storage can be disabled while theme controls remain usable. */ }
function update() {
if (scope.signal.aborted) return;
const theme = preference === 'follow' ? hostTheme || (system.matches ? 'dark' : 'light') : preference;
root.dataset.caTheme = theme;
documentElement.style.colorScheme = theme;
}
async function refreshHost() {
if (scope.signal.aborted) return;
request?.abort();
const controller = new AbortController();
request = controller;
const current = ++sequence;
let next = null;
try {
const prefix = win.location.pathname.match(/^(.*)\/prompt_manager(?:\/|$)/)?.[1] || '';
const response = await fetchImpl(`${prefix}/api/settings/Comfy.ColorPalette`, { signal: controller.signal });
if (response.ok) {
const data = await response.json();
const value = typeof data === 'string' ? data : data?.value;
const name = typeof value === 'string' ? value.toLowerCase().trim() : '';
next = LIGHT_PALETTES.has(name) ? 'light' : DARK_PALETTES.has(name) ? 'dark' : null;
}
} catch { /* A standalone or unavailable host follows the system preference. */ }
if (scope.signal.aborted || current !== sequence) return;
hostTheme = next;
update();
}
scope.listen(system, 'change', update);
scope.listen(win, 'focus', refreshHost);
scope.cleanup(() => {
sequence++;
request?.abort();
if (previousTheme === undefined) delete root.dataset.caTheme;
else root.dataset.caTheme = previousTheme;
documentElement.style.colorScheme = previousScheme;
});
update();
const ready = refreshHost();
return {
ready,
get preference() { return preference; },
setPreference(value) {
if (scope.signal.aborted) return;
if (!PREFERENCES.has(value)) throw new TypeError('Unknown theme preference');
preference = value;
try { storage?.setItem(STORAGE_KEY, value); } catch { /* Keep the in-memory preference. */ }
update();
},
};
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Prompt Manager · Workspace</title>
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
<link rel="stylesheet" href="js/workspace/style.css">
<script type="module" src="js/workspace/index.mjs"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#workspace-main">Skip to content</a>
<div class="workspace-shell">
<aside class="sidebar">
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P</span><span>Prompt Manager<small>ComfyAssets</small></span></a>
<p class="eyebrow">Your workspace</p>
<nav aria-label="Workspace"></nav>
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
</aside>
<div class="workspace-body">
<header class="topbar"><span>Prompt Manager <span class="muted">/ Workspace preview</span></span><div id="appearance-control"></div></header>
<main id="workspace-main" tabindex="-1"></main>
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
</div>
</div>
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
</body>
</html>