Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe9a047dc8 | ||
|
|
dbdc8cd819 | ||
|
|
e724cf18b4 | ||
|
|
16b4c1a7ec | ||
|
|
fae047bb87 | ||
|
|
cf011d1365 | ||
|
|
0aa496876e |
@@ -9,6 +9,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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{}")
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||