Compare commits

...
12 changed files with 757 additions and 0 deletions
+2
View File
@@ -131,6 +131,8 @@ jobs:
run: npm run --prefix tools/testing test:coverage
- name: Firefox and WebKit workflows
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
- name: Shared UI design preview interactions
run: node tools/testing/node_modules/@playwright/test/cli.js test --config docs/ui-preview/playwright.config.mjs
- name: Upload frontend reports
if: always()
uses: actions/upload-artifact@v7
+8
View File
@@ -51,6 +51,14 @@ all engines.
## Coverage and diagnosis
The standalone shared UI design preview has a separate Playwright configuration.
Run `node tools/testing/node_modules/@playwright/test/cli.js test --config
docs/ui-preview/playwright.config.mjs` after installing the browser dependencies.
It runs in the existing frontend CI job and writes screenshots/traces under
`tools/testing/test-results/ui-preview/`. See
[the preview guide](docs/ui-preview/README.md) for local review and its limits.
These checks cover a mockup, not production widget or renderer compatibility.
Python CI checks all twelve Python/OS combinations and enforces 85% combined
statement/branch coverage. Every production `web/*.js` file appears in frontend
coverage, including code without behavioral coverage. V8 unit coverage and
+157
View File
@@ -0,0 +1,157 @@
# ComfyAssets UI foundation
Status: proposed. This document defines the implementation sequence; it does not
claim that the current extensions meet these requirements.
Tracking: [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
## Problem and intended result
KikoTools extensions independently construct controls, styles, windows, and host
integration. The Local Image Loader combines these concerns in
`web/kiko_local_image_loader.js`. PromptManager has useful `--pm-*` theme tokens,
but its pages and dialogs have different behavior and lifecycle requirements.
Build an original ComfyAssets foundation so both projects can offer reusable
controls, consistent editor layouts, shared settings/help/window behavior, and
tested Classic/Nodes 2.0 compatibility. Both projects are one toolset and must
look identical. Preserve existing node interfaces, workflows, routes, and features.
Architecture inspiration: [Pixaroma's editor framework](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/framework/index.mjs)
and [shared infrastructure](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/shared/index.mjs).
Credit this inspiration in the shipped documentation. Write our own implementation
and visual design; do not copy or vendor Pixaroma code, assets, or styles.
## Boundaries
| Layer | Owns | Must remain independent of |
| --- | --- | --- |
| Controls | Buttons, fields, selections, accessible labels and state | Node names, HTTP routes, renderer internals |
| Layout | Title, toolbar, workspace, optional sidebar and status slots | Gallery fetching and workflow serialization |
| Services | Settings, help, floating windows, cleanup scopes | Product colors and node-specific data |
| ComfyUI adapter | DOM widget attachment, renderer changes, zoom/resize, graph edits | Product layouts and themes |
| Product controllers | API calls, selection, commands, existing persistence | Renderer-specific implementation details |
| Theme | Scoped tokens, typography, spacing, colors, focus and interaction states | Business behavior |
Use native DOM modules and semantic controls for the pilot. Introduce no frontend
framework just for this work. Controls receive values and callbacks; controllers
retain state. Mounting returns an idempotent disposer. Disposal cancels pending
requests and timers, disconnects observers, removes listeners, and closes owned
windows. Late asynchronous responses cannot update a removed or superseded view.
Keep ComfyUI imports behind the host adapter. Detect supported capabilities and
subscribe to host changes where available. Any fallback watcher belongs in the
adapter, has one explicit owner, and stops on disposal. Preserve existing host
hooks and their return values. Do not scatter renderer checks through controls.
## Independent installation and shared source
During the pilot, keep the original source in KikoTools under `web/ui/`, with
product controllers outside that directory. Avoid a premature published package
or a second implementation in PromptManager.
Before PromptManager adopts controls, extract the proven core into one versioned
ComfyAssets source package. Use it as a pinned build-time dependency. Commit the
generated browser distribution, license, version, source revision, and integrity
metadata in each consumer so ordinary git/Manager installations require neither
npm nor a second custom node pack. A documented update command must regenerate
the distribution; CI must reject hand-edited or stale output. Package hosting
and release automation are decisions for that extraction PR.
Each consumer uses relative imports and its own root namespace. Avoid global
custom-element registration and mutable page-wide singletons in the pilot.
Settings and storage keys include a product identifier. Styles remain below the
consumer root. Floating windows inherit that consumer's tokens even when attached
to the document body. Different bundled versions must coexist without selecting
whichever version happened to load first. Test this explicitly during extraction.
## Agreed styling and theme direction
Both projects share exactly the same identity: controls, typography, spacing,
colors and interaction states. Follow ComfyUI's light/dark mode with our own
styling. Use ComfyUI-style blue for the primary accent and a strong, readable red
for errors and destructive actions. Pixaroma's orange is not our accent.
The maintainer's screenshot references establish compact, uniform buttons,
pill switches, segmented selections, structured editable rows, and correct
resizing. Existing styles are migration context, not the target. Define shared
semantic tokens and present the [interactive pilot mockup](ui-preview/README.md)
for review before production migration. Keep behavioral APIs independent of theme.
Follow host light/dark changes while mounted. Standalone PromptManager pages
need an explicit theme/settings bridge because a separate
page does not inherit its CSS variables. Any light/dark or accent choices must
preserve readable text and focus indicators.
Review the pilot in light and dark themes, at narrow and wide node sizes, and
with keyboard focus visible before extending the style system to other nodes.
Record screenshots and concrete contrast measurements; do not treat screenshot
existence as proof of accessibility. No global resets or host-wide style overrides.
## Pilot: Local Image Loader
Keep browsing, autocomplete, saved paths, sorting, hidden-folder filtering,
pagination, refresh, image/video/audio selection, and existing API error handling.
Keep node names, inputs/outputs, widget serialization, and persisted settings
compatible. Characterize existing saved workflows before changing implementation.
First extract the shell, button, select, text field, checkbox, and status controls
actually used by this node. Add sliders and number fields when a second editor
needs them. Windows/help/settings services follow after the pilot; do not add
unused abstractions to satisfy a component checklist.
## Acceptance and test strategy
Use TDD for each behavioral slice: record a failing regression or new contract
test, implement the minimum change, then run affected suites and required gates.
Characterization tests should pass before refactoring. Never weaken existing
coverage thresholds. Extend source discovery and coverage to nested `web/ui/`
modules before introducing them; the current collector only includes `web/*.js`.
| Boundary | Required evidence |
| --- | --- |
| Controls/services | Unit tests for state, callbacks, disposal, settings isolation and repeat mount/unmount |
| Product workflows | Existing browser fixture tests plus request races, errors and preserved selection/settings |
| Real host | Actual ComfyUI frontend, graph, settings, widgets and workflow serialization; no substituted host objects |
| Visual/keyboard | Theme screenshots, keyboard operation, visible focus, accessible names and measured contrast |
| Distribution | Both packs separately and together, with matching and different UI package versions |
The real-host harness must pin ComfyUI and frontend versions and record them in
test artifacts. Select and document a baseline that actually supports both
renderers before claiming compatibility. Test initial Classic and Nodes 2.0,
switching both ways without reload, zoom/pan/resize, node delete/recreate,
save/reload of an existing workflow, and duplicate-node behavior.
Opening or closing a view, changing theme, and moving a floating window must not
create graph edits. Committed node value changes must use the host's normal edit
path and participate in undo/redo. Assert serialized values and history behavior;
do not use canvas redraw as evidence of correct graph persistence.
Run the pinned baseline as a PR check once reproducible. Keep the existing fast
fixtures and cross-browser checks. Run a separately pinned newer-host matrix on
a schedule to detect upstream changes without silently changing PR dependencies.
Missing renderer support must fail or be reported as unsupported, never silently
skip the renderer and report compatibility. Model inference and GPU execution
are outside this UI suite; startup and UI interaction should use disposable data.
## Delivery sequence
1. **Proposal:** agree on these boundaries, pilot behavior, and test evidence.
2. **Host harness:** add reproducible real-ComfyUI tests for the existing loader;
report any existing failures separately before the refactor.
3. **Pilot:** make nested source coverage exhaustive; add tokens, minimal controls,
shell, adapter and loader migration in reviewable TDD slices. Include visual
evidence and preserve the existing suites.
4. **Shared behavior:** migrate the Kiko image viewer; extract settings/help/window
services from demonstrated needs and test cleanup, persistence and keyboard use.
5. **Second consumer:** extract and version the core, add reproducibility checks,
then migrate one PromptManager dialog and its theme bridge. Prove standalone
and combined installation before wider adoption.
6. **Incremental adoption:** migrate remaining editors individually. Remove old
code only after replacement behavior is covered; document package updates and
compatibility support.
Each PR links its tracked issue, includes validation commands/results, and states
which real-host and visual checks were performed. Keep runtime changes out of the
proposal PR. No backend or workflow schema change is planned for the UI pilot.
+60
View File
@@ -0,0 +1,60 @@
# ComfyAssets UI preview
Interactive design review for [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
This standalone mockup changes no shipped nodes, APIs, settings, or workflows.
## Open the preview
From the KikoTools repository:
```sh
python3 -m http.server 8769 --bind 127.0.0.1 --directory docs/ui-preview
```
Open <http://127.0.0.1:8769/>. Stop the server with Ctrl+C.
No build, ComfyUI installation, or external requests are needed. Paths, files,
gallery images and prompt rows are sample content; actions affect this page only.
Browse and refresh intentionally reuse the same samples. Nothing is persisted.
## What to try
- Switch Dark/Light to simulate ComfyUI's mode. Production will follow the host;
this review toggle is not a new product theme setting.
- Change Preview node width and Thumbnail size. Also resize the browser.
- Select and clear images, filter file names, switch Grid/List and change sorting.
- Operate switches with the keyboard; add, disable, remove and reset prompt rows.
- Show the error example and Retry to compare the red treatment against blue.
Both product samples use exactly the same visual tokens and controls. Blue
`#0877bb` and red `#d52430` are review candidates chosen to keep white button text
readable; they are not claimed to be exact colors sampled from the references.
Give feedback on these colors, density, borders, control proportions and resize
behavior before production migration.
Captured review views: [dark](screenshots/dark.png), [light](screenshots/light.png),
and [narrow](screenshots/narrow.png). These show the current candidate design,
not production screenshots or automatic visual regression baselines.
## Automated review checks
With Node.js 24 and the existing frontend test dependencies installed:
```sh
npm ci --prefix tools/testing --ignore-scripts
npx --prefix tools/testing playwright install chromium firefox
node tools/testing/node_modules/@playwright/test/cli.js test \
--config docs/ui-preview/playwright.config.mjs --project chromium --project firefox
```
Stop the manual preview server first; the suite starts its own disposable server
on port 8769. CI also runs WebKit and touch Chromium using the same configuration.
The tests cover basic interactions, resizing, keyboard operation, theme
consistency and text contrast on sampled surfaces. Screenshots are written under
`tools/testing/test-results/ui-preview/` locally. The suite does not certify screen
reader usability, all state/border contrast, or real ComfyUI compatibility.
See the [foundation proposal](../shared-ui-foundation.md) for the renderer adapter,
real-host tests and incremental migration plan. Pixaroma's control organization
inspired this direction; this preview's code, styles and sample illustrations are
original and include none of his implementation or assets.
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Local Image Loader · ComfyAssets UI preview</title>
<link rel="stylesheet" href="preview.css">
<script type="module" src="preview.mjs"></script>
</head>
<body class="ca-preview">
<svg class="ca-symbols" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<symbol id="folder" viewBox="0 0 24 24"><path d="M3 6h7l2 3h9v11H3Z"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M20 10a8 8 0 1 0-1 8M20 4v6h-6"/></symbol>
<symbol id="up" viewBox="0 0 24 24"><path d="m6 11 6-6 6 6M12 5v15"/></symbol>
<symbol id="star" viewBox="0 0 24 24"><path d="m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1Z"/></symbol>
<symbol id="grid" viewBox="0 0 24 24"><path d="M3 3h7v7H3ZM14 3h7v7h-7ZM3 14h7v7H3ZM14 14h7v7h-7Z"/></symbol>
<symbol id="list" viewBox="0 0 24 24"><path d="M9 5h12M9 12h12M9 19h12M3 5h1M3 12h1M3 19h1"/></symbol>
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 5 5L20 7"/></symbol>
<symbol id="alert" viewBox="0 0 24 24"><path d="m12 3 10 18H2ZM12 9v5M12 17v1"/></symbol>
</svg>
<header class="ca-page-header">
<a class="ca-wordmark" href="#main"><span class="ca-brand-mark" aria-hidden="true">C<span>A</span></span> ComfyAssets</a>
<span class="ca-caption">Shared UI / Design preview 01</span>
</header>
<main id="main" class="ca-page-main">
<div class="ca-intro">
<div><h1>One set of tools. One design.</h1><p>Local Image Loader + PromptManager · compact controls, shared behavior, your ComfyUI theme.</p></div>
<span class="ca-preview-badge">Interactive mockup</span>
</div>
<section class="ca-review-controls" aria-label="Preview controls">
<fieldset class="ca-segments"><legend>ComfyUI theme preview</legend>
<label><input type="radio" name="theme" value="dark" checked><span>Dark</span></label>
<label><input type="radio" name="theme" value="light"><span>Light</span></label>
</fieldset>
<label class="ca-width-control" for="node-width">Preview node width <input id="node-width" type="range" min="320" max="800" step="20" value="800"><output for="node-width" data-width>800 px</output></label>
<span class="ca-review-hint">Try resizing and switching themes</span>
</section>
<div class="ca-review-grid">
<div class="ca-canvas">
<article class="ca-node ca-loader" data-loader data-product-sample>
<header class="ca-titlebar"><h2><span class="ca-node-dot" aria-hidden="true"></span>Local Image Loader</h2><span class="ca-product-label">KikoTools</span></header>
<div class="ca-ports"><span class="ca-port">directory</span><span>media <i aria-hidden="true"></i></span></div>
<div class="ca-node-body">
<div class="ca-row ca-favorites"><label for="saved-paths" class="ca-visually-hidden">Saved paths</label><select id="saved-paths"><option value="/input/references">References</option><option value="/input/portraits">Portraits</option><option value="/input">All input files</option></select><button type="button" data-save-path><svg><use href="#star"/></svg>Save path</button></div>
<form class="ca-path-form"><label for="directory" class="ca-field-label">Directory</label><div class="ca-path-row"><button type="button" class="ca-icon-button" aria-label="Parent folder" data-parent><svg><use href="#up"/></svg></button><input id="directory" value="/input/references" spellcheck="false"><button type="submit" class="ca-primary"><svg><use href="#folder"/></svg>Browse</button></div></form>
<div class="ca-options">
<label class="ca-switch" for="hide-dot"><input id="hide-dot" type="checkbox" role="switch" checked>Hide dot folders</label>
<div class="ca-row ca-sort"><label for="sort">Sort</label><select id="sort"><option value="name">Name</option><option value="date">Date</option><option value="size">Size</option></select><button type="button" class="ca-icon-button" aria-label="Sort descending" aria-pressed="false" data-sort-order>↑</button><button type="button" class="ca-icon-button" aria-label="Refresh sample folder" data-refresh><svg><use href="#refresh"/></svg></button></div>
</div>
<div class="ca-gallery-tools"><label for="filter" class="ca-visually-hidden">Filter sample files</label><input id="filter" type="search" placeholder="Filter files…"><fieldset class="ca-segments ca-view-options"><legend class="ca-visually-hidden">Gallery layout</legend><label><input type="radio" name="view" value="grid" checked><span><svg aria-hidden="true"><use href="#grid"/></svg>Grid</span></label><label><input type="radio" name="view" value="list"><span><svg aria-hidden="true"><use href="#list"/></svg>List</span></label></fieldset></div>
<div class="ca-error" role="alert" hidden><svg aria-hidden="true"><use href="#alert"/></svg><span><strong>Folder unavailable</strong><br>Check the path and try again.</span><button type="button" data-retry>Retry</button></div>
<div class="ca-gallery" data-gallery data-view="grid"></div>
<p class="ca-empty" data-empty hidden>No matching sample files.</p>
<div class="ca-gallery-footer"><span data-count>6 sample files</span><span class="ca-page-count">Page 1 of 1</span></div>
<div class="ca-thumbnail-control"><label for="thumbnail-size">Thumbnail size</label><input id="thumbnail-size" type="range" min="112" max="208" step="8" value="144"><output for="thumbnail-size" data-thumbnail-size>144 px</output></div>
<div class="ca-selection"><span class="ca-selection-icon" aria-hidden="true"><svg><use href="#check"/></svg></span><div><span class="ca-field-label">Selected image</span><strong data-selection>No image selected</strong></div><button type="button" data-clear disabled>Clear</button></div>
</div>
<footer class="ca-node-status" role="status" data-status>Sample folder ready · choose an image</footer>
</article>
<p class="ca-canvas-caption">Same component system at every node size. All files shown here are demo content.</p>
</div>
<aside class="ca-reference">
<div class="ca-reference-heading"><h2>The same family</h2><p>Same controls. Same spacing. Same blue.</p></div>
<article class="ca-node ca-prompt-sample" data-product-sample>
<header class="ca-titlebar"><h3><span class="ca-node-dot" aria-hidden="true"></span>Prompt editor</h3><span class="ca-product-label">PromptManager</span></header>
<div class="ca-node-body">
<div class="ca-prompt-rows" data-prompt-rows></div>
<div class="ca-row"><button type="button" data-add-row>+ Add row</button><button type="button" data-reset-rows disabled>Reset</button></div>
</div>
</article>
<section class="ca-reference-panel"><h3>One accent, clear states</h3><div class="ca-state-buttons"><button type="button" class="ca-primary" data-primary-example>Primary action</button><button type="button" class="ca-danger" data-error-example>Show error example</button><button type="button" disabled>Unavailable</button></div><p class="ca-muted-sample">Blue for active controls.<br>Strong red when something needs attention.</p></section>
<section class="ca-reference-panel ca-design-notes"><h3>What to review</h3><ul><li>Button weight and spacing</li><li>Blue and red in both themes</li><li>Switches and segmented choices</li><li>Narrow and wide node layouts</li></ul><p>This page previews appearance and interactions. ComfyUI renderer compatibility comes in the integration phase.</p></section>
</aside>
</div>
</main>
<footer class="ca-page-footer"><span>ComfyAssets · KikoTools + PromptManager</span><span>Original implementation · inspired by Pixaroma’s thoughtful controls</span></footer>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('./', import.meta.url));
export default defineConfig({
testDir: './tests',
outputDir: '../../tools/testing/test-results/ui-preview',
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: {
baseURL: 'http://127.0.0.1:8769',
viewport: { width: 1440, height: 1050 },
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: 'python3 -m http.server 8769 --bind 127.0.0.1',
cwd: root,
url: 'http://127.0.0.1:8769/',
reuseExistingServer: false,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
+184
View File
@@ -0,0 +1,184 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ca-page: #19191c;
--ca-canvas: #202023;
--ca-surface: #2b2b2e;
--ca-title: #242427;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeeF1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-control-border: #7d7d87;
--ca-accent: #0877bb;
--ca-accent-hover: #096ba6;
--ca-accent-text: #ffffff;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-bg: #391d23;
--ca-error-text: #ffadb3;
--ca-grid: #29292d;
--ca-shadow: 0 3px 9px #00000030;
}
:root[data-theme="light"] {
color-scheme: light;
--ca-page: #f4f4f6;
--ca-canvas: #ededf0;
--ca-surface: #ffffff;
--ca-title: #f6f6f8;
--ca-field: #f5f5f7;
--ca-control: #eeeeF1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-control-border: #85858f;
--ca-focus: #0066a6;
--ca-error-bg: #fff0f1;
--ca-error-text: #a20d20;
--ca-grid: #e1e1e7;
--ca-shadow: 0 3px 9px #1b1b2612;
}
.ca-preview { margin: 0; background: var(--ca-page); color: var(--ca-text); font-size: 13px; line-height: 1.5; }
.ca-preview :where(article, section, header, footer, div, input, button, select, textarea, fieldset, label, aside, main) { box-sizing: border-box; }
.ca-preview :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-preview :where(button, select, input:not([type="radio"], [type="range"], [role="switch"]), textarea) { border: 1px solid var(--ca-control-border); border-radius: 5px; min-block-size: 30px; }
.ca-preview button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 11px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-preview button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-preview button:disabled { opacity: .42; cursor: default; }
.ca-preview svg:not(.ca-symbols) { inline-size: 15px; block-size: 15px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ca-symbols { position: absolute; inline-size: 0; block-size: 0; overflow: hidden; }
.ca-preview :where(input:not([type="radio"], [type="range"], [role="switch"]), select, textarea) { background: var(--ca-field); padding: 4px 9px; min-inline-size: 0; }
.ca-preview select { padding-inline-end: 22px; }
.ca-preview :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-preview :where(button, input, select, textarea, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.ca-preview .ca-primary { background: var(--ca-accent); color: var(--ca-accent-text); border-color: var(--ca-accent); }
.ca-preview .ca-primary:hover:not(:disabled) { background: var(--ca-accent-hover); }
.ca-preview .ca-danger { background: var(--ca-error); color: #ffffff; border-color: var(--ca-error); }
.ca-preview .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-page-header, .ca-page-footer { max-inline-size: 1280px; margin-inline: auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.ca-page-header { border-block-end: 1px solid var(--ca-line); }
.ca-wordmark { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ca-text); font-size: 17px; font-weight: 650; letter-spacing: -.4px; }
.ca-brand-mark { color: var(--ca-accent); font-weight: 800; letter-spacing: -3px; font-size: 22px; padding-inline-end: 4px; }
.ca-brand-mark span { color: var(--ca-text); }
.ca-caption, .ca-page-footer, .ca-review-hint, .ca-canvas-caption { color: var(--ca-muted); font-size: 12px; }
.ca-page-main { max-inline-size: 1280px; margin-inline: auto; padding: 32px; }
.ca-intro { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-block-end: 24px; }
.ca-intro h1 { font-size: 25px; line-height: 1.3; font-weight: 600; letter-spacing: -.7px; margin: 0 0 8px; }
.ca-intro p { color: var(--ca-muted); margin: 0; }
.ca-preview-badge { color: var(--ca-muted); border: 1px solid var(--ca-line); padding: 4px 9px; border-radius: 4px; font-size: 11px; }
.ca-review-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; padding: 16px 0; border-block: 1px solid var(--ca-line); margin-block-end: 24px; }
.ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; align-items: center; gap: 2px; min-inline-size: 0; }
.ca-segments legend { float: inline-start; margin-inline-end: 12px; color: var(--ca-muted); }
.ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-segments input { position: absolute; inset: 0; opacity: 0; margin: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-segments label > span { display: inline-flex; gap: 5px; align-items: center; justify-content: center; padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-control-border); border-radius: 4px; }
.ca-segments input:checked + span { color: var(--ca-accent-text); background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.ca-width-control { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--ca-muted); }
.ca-preview input[type="range"] { min-inline-size: 60px; block-size: 30px; margin: 0; cursor: pointer; }
.ca-width-control input { inline-size: 120px; }
.ca-preview output { font-variant-numeric: tabular-nums; font-size: 12px; min-inline-size: 44px; }
.ca-review-hint { margin-inline-start: auto; }
.ca-review-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; align-items: start; }
.ca-canvas { padding: 26px 18px 14px; border: 1px solid var(--ca-line); border-radius: 8px; background-color: var(--ca-canvas); background-image: linear-gradient(var(--ca-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ca-grid) 1px, transparent 1px); background-size: 16px 16px; min-inline-size: 0; }
.ca-node { background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 8px; box-shadow: var(--ca-shadow); overflow: hidden; }
.ca-loader { inline-size: min(100%, var(--ca-preview-width, 800px)); margin-inline: auto; container-type: inline-size; }
.ca-titlebar { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--ca-title); padding: 9px 12px; border-block-end: 1px solid var(--ca-line); }
.ca-titlebar :where(h2, h3) { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 550; margin: 0; }
.ca-node-dot { inline-size: 8px; block-size: 8px; border-radius: 50%; background: var(--ca-accent); flex-shrink: 0; }
.ca-product-label { color: var(--ca-muted); font-size: 10px; white-space: nowrap; }
.ca-ports { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; font-size: 11px; color: var(--ca-muted); }
.ca-ports span { display: flex; align-items: center; gap: 6px; }
.ca-ports i { inline-size: 6px; block-size: 6px; border: 1px solid var(--ca-control-border); background: var(--ca-focus); border-radius: 50%; }
.ca-node-body { padding: 12px; display: grid; gap: 14px; }
.ca-row { display: flex; align-items: center; gap: 7px; min-inline-size: 0; }
.ca-favorites select { flex: 1; }
.ca-field-label { color: var(--ca-muted); font-size: 11px; display: block; margin-block-end: 5px; }
.ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 6px; }
.ca-preview .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-options { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
.ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-control-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-sort { font-size: 12px; color: var(--ca-muted); }
.ca-sort select { inline-size: 90px; color: var(--ca-text); }
.ca-gallery-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.ca-gallery-tools > input { flex: 1; inline-size: 120px; }
.ca-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); gap: 8px; }
.ca-preview .ca-file { position: relative; padding: 4px; display: flex; flex-direction: column; gap: 0; background: var(--ca-field); border-color: var(--ca-control-border); white-space: normal; text-align: start; overflow: hidden; min-inline-size: 0; }
.ca-preview .ca-file[aria-pressed="true"] { border: 2px solid var(--ca-focus); padding: 3px; }
.ca-file-name { inline-size: 100%; font-size: 11px; padding: 6px 3px 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.ca-file-meta { inline-size: 100%; color: var(--ca-muted); font-size: 10px; padding: 1px 3px 4px; }
.ca-file-check { display: none; position: absolute; inset-block-start: 9px; inset-inline-end: 9px; background: var(--ca-accent); color: white; border: 1px solid white; border-radius: 3px; inline-size: 20px; block-size: 20px; text-align: center; }
.ca-file[aria-pressed="true"] .ca-file-check { display: block; }
.ca-scene { position: relative; display: block; inline-size: 100%; aspect-ratio: 1.5; overflow: hidden; border-radius: 3px; background: linear-gradient(170deg, #719ca5, #b9cfbf 55%, #46647b 56%, #203947); }
.ca-scene::before, .ca-scene::after { content: ""; position: absolute; inset: 12% -8% 25%; clip-path: polygon(0 100%, 35% 5%, 48% 43%, 64% 20%, 100% 100%); background: #40596c; }
.ca-scene::after { inset: 38% -20% -15% 20%; background: #1e3549; }
.ca-scene-dune { background: linear-gradient(150deg, #e2c9a8, #8b8d98); }
.ca-scene-dune::before { background: #b79a83; clip-path: polygon(0 100%, 75% 10%, 100% 90%); }
.ca-scene-dune::after { background: #705f64; clip-path: polygon(0 60%, 80% 20%, 100% 100%, 0 100%); }
.ca-scene-forest { background: linear-gradient(160deg, #9eb7a9, #3f6259); }
.ca-scene-forest::before { background: #45665b; clip-path: polygon(0 100%, 16% 5%, 27% 85%, 45% 0, 61% 100%, 80% 10%, 100% 100%); }
.ca-scene-forest::after { background: #243e39; }
.ca-scene-coast { background: linear-gradient(170deg, #a3bfce 40%, #4e8a9e 41%, #294c65); }
.ca-scene-coast::before { background: #a1b1a0; clip-path: polygon(0 0, 100% 80%, 0 100%); }
.ca-scene-coast::after { background: #526f69; clip-path: polygon(0 30%, 70% 100%, 0 100%); }
.ca-scene-studio { background: linear-gradient(135deg, #b0a5b6, #5e5d78); }
.ca-scene-studio::before { inset: 18% 28%; border-radius: 40px 40px 0 0; clip-path: none; background: #d2c2be; }
.ca-scene-studio::after { inset: 40% 35% 0; clip-path: none; border-radius: 40px 40px 0 0; background: #636378; }
.ca-scene-night { background: linear-gradient(140deg, #49516a, #202a42); }
.ca-scene-night::before { background: #6a6580; }
.ca-scene-night::after { background: #273044; }
.ca-gallery[data-view="list"] { grid-template-columns: 1fr; }
.ca-preview .ca-gallery[data-view="list"] .ca-file { display: grid; grid-template-columns: 64px minmax(0, 1fr); column-gap: 8px; justify-content: start; }
.ca-gallery[data-view="list"] .ca-scene { grid-row: span 2; }
.ca-gallery[data-view="list"] .ca-file-meta { align-self: start; }
.ca-gallery-footer { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--ca-muted); }
.ca-thumbnail-control { display: flex; align-items: center; gap: 10px; color: var(--ca-muted); font-size: 11px; }
.ca-thumbnail-control input { flex: 1; }
.ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; display: flex; align-items: center; gap: 10px; min-inline-size: 0; }
.ca-selection > div { flex: 1; min-inline-size: 0; }
.ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.ca-selection .ca-field-label { margin-block-end: 0; }
.ca-selection-icon { color: var(--ca-focus); }
.ca-node-status { padding: 7px 12px; border-block-start: 1px solid var(--ca-line); background: var(--ca-title); color: var(--ca-muted); font-size: 11px; }
.ca-canvas-caption { text-align: center; margin: 14px 6px 0; }
.ca-reference { display: grid; gap: 20px; }
.ca-reference-heading h2 { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.ca-reference-heading p { margin: 0; color: var(--ca-muted); font-size: 12px; }
.ca-prompt-sample .ca-titlebar { align-items: start; flex-direction: column; gap: 4px; }
.ca-prompt-sample .ca-titlebar .ca-product-label { padding-inline-start: 16px; }
.ca-prompt-rows { display: grid; gap: 8px; }
.ca-prompt-row { background: var(--ca-field); border: 1px solid var(--ca-line); padding: 8px; border-radius: 5px; display: grid; gap: 8px; }
.ca-prompt-row .ca-row { justify-content: space-between; }
.ca-preview .ca-prompt-row textarea { inline-size: 100%; min-block-size: 78px; resize: vertical; font-size: 12px; }
.ca-reference-panel { padding: 16px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-surface); }
.ca-reference-panel h3 { margin: 0 0 12px; font-size: 12px; font-weight: 600; }
.ca-state-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.ca-state-buttons button { flex: 1 1 100%; }
.ca-muted-sample { color: var(--ca-muted); background: var(--ca-surface); font-size: 12px; margin: 12px 0 0; }
.ca-design-notes ul { padding-inline-start: 16px; color: var(--ca-muted); line-height: 1.9; font-size: 12px; }
.ca-design-notes p { font-size: 11px; color: var(--ca-muted); border-block-start: 1px solid var(--ca-line); padding-block-start: 10px; margin-block-end: 0; }
.ca-error { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--ca-error-bg); color: var(--ca-error-text); border: 1px solid var(--ca-error); border-radius: 5px; font-size: 12px; }
.ca-error span { flex: 1; }
.ca-error strong { font-weight: 600; }
.ca-preview [hidden] { display: none; }
.ca-empty { text-align: center; padding: 32px 12px; color: var(--ca-muted); }
.ca-page-footer { border-block-start: 1px solid var(--ca-line); padding-block: 18px; flex-wrap: wrap; font-size: 11px; }
.ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
@container (max-width: 400px) {
.ca-options { align-items: stretch; }
.ca-sort { flex: 1; }
.ca-sort select { flex: 1; }
.ca-gallery { grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); }
.ca-options > .ca-switch { flex-basis: 100%; }
}
@media (max-width: 1000px) { .ca-review-grid { grid-template-columns: minmax(0, 1fr); } .ca-reference { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } .ca-reference-heading { grid-column: 1 / -1; } }
@media (max-width: 600px) { .ca-page-main { padding: 20px 12px; } .ca-page-header, .ca-page-footer { padding-inline: 16px; } .ca-caption { display: none; } .ca-canvas { padding: 14px 6px; } .ca-review-hint { margin-inline-start: 0; } .ca-intro h1 { font-size: 22px; } .ca-width-control { gap: 8px; } }
@media (pointer: coarse) { .ca-preview :where(button, select), .ca-segments label > span { min-block-size: 40px; } .ca-switch { min-block-size: 40px; } }
@media (forced-colors: active) { :root, :root[data-theme="light"] { --ca-accent: Highlight; --ca-accent-text: HighlightText; --ca-focus: Highlight; --ca-control-border: ButtonText; --ca-line: CanvasText; } .ca-file[aria-pressed="true"] { outline: 2px solid Highlight; } .ca-switch input:checked::before { background: HighlightText; } }
+142
View File
@@ -0,0 +1,142 @@
const root = document.documentElement;
const loader = document.querySelector('[data-loader]');
const gallery = document.querySelector('[data-gallery]');
const status = document.querySelector('[data-status]');
const selection = document.querySelector('[data-selection]');
const clear = document.querySelector('[data-clear]');
const error = document.querySelector('.ca-error');
const filter = document.querySelector('#filter');
const path = document.querySelector('#directory');
const sort = document.querySelector('#sort');
let selected = '';
let descending = false;
const files = [
{ name: 'alpine-lake.png', scene: 'lake', size: '1536 × 1024', order: 3 },
{ name: 'desert-dunes.png', scene: 'dune', size: '1536 × 1024', order: 6 },
{ name: 'forest-study.png', scene: 'forest', size: '1024 × 1536', order: 4 },
{ name: 'coastal-light.png', scene: 'coast', size: '1536 × 1024', order: 1 },
{ name: 'studio-shapes.png', scene: 'studio', size: '1024 × 1024', order: 2 },
{ name: 'night-ridge.png', scene: 'night', size: '1536 × 1024', order: 5 },
];
function renderFiles() {
const visible = files.filter(file => file.name.includes(filter.value.toLowerCase()));
visible.sort((a, b) => {
const order = sort.value === 'name' ? a.name.localeCompare(b.name)
: sort.value === 'size' ? a.size.localeCompare(b.size) : a.order - b.order;
return descending ? -order : order;
});
gallery.replaceChildren(...visible.map(file => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ca-file';
button.setAttribute('aria-label', `Select ${file.name}`);
button.setAttribute('aria-pressed', String(file.name === selected));
const scene = document.createElement('span');
scene.className = `ca-scene ca-scene-${file.scene}`;
scene.setAttribute('aria-hidden', 'true');
const check = document.createElement('span');
check.className = 'ca-file-check';
check.textContent = '✓';
check.setAttribute('aria-hidden', 'true');
const name = document.createElement('span');
name.className = 'ca-file-name';
name.textContent = file.name;
const meta = document.createElement('span');
meta.className = 'ca-file-meta';
meta.textContent = `${file.size} · PNG`;
button.append(scene, check, name, meta);
button.addEventListener('click', () => {
selected = file.name;
selection.textContent = file.name;
clear.disabled = false;
for (const item of gallery.children) item.setAttribute('aria-pressed', String(item === button));
status.textContent = `Selected ${file.name} · demo selection`;
});
return button;
}));
document.querySelector('[data-empty]').hidden = visible.length > 0;
document.querySelector('[data-count]').textContent = `${visible.length} sample files`;
}
document.querySelectorAll('[name="theme"]').forEach(input => {
input.addEventListener('change', () => { root.dataset.theme = input.value; });
});
document.querySelector('#node-width').addEventListener('input', event => {
loader.style.setProperty('--ca-preview-width', `${event.target.value}px`);
document.querySelector('[data-width]').value = `${event.target.value} px`;
});
document.querySelectorAll('[name="view"]').forEach(input => {
input.addEventListener('change', () => { gallery.dataset.view = input.value; });
});
document.querySelector('#thumbnail-size').addEventListener('input', event => {
gallery.style.setProperty('--ca-thumbnail-size', `${event.target.value}px`);
document.querySelector('[data-thumbnail-size]').value = `${event.target.value} px`;
});
filter.addEventListener('input', renderFiles);
sort.addEventListener('change', renderFiles);
document.querySelector('[data-sort-order]').addEventListener('click', event => {
descending = !descending;
event.currentTarget.setAttribute('aria-pressed', String(descending));
event.currentTarget.setAttribute('aria-label', descending ? 'Sort ascending' : 'Sort descending');
event.currentTarget.textContent = descending ? '↓' : '↑';
renderFiles();
});
document.querySelector('#hide-dot').addEventListener('change', event => {
status.textContent = event.target.checked ? 'Dot folders hidden · sample content unchanged' : 'Dot folders visible · no hidden folders in this sample';
});
function browse() {
error.hidden = true;
status.textContent = `Previewing ${path.value || '/input'} · sample content only`;
renderFiles();
}
document.querySelector('.ca-path-form').addEventListener('submit', event => { event.preventDefault(); browse(); });
document.querySelector('[data-refresh]').addEventListener('click', browse);
document.querySelector('[data-retry]').addEventListener('click', browse);
document.querySelector('#saved-paths').addEventListener('change', event => { path.value = event.target.value; browse(); });
document.querySelector('[data-save-path]').addEventListener('click', () => {
if (!path.value.trim()) { status.textContent = 'Enter a directory before saving a sample path'; return; }
const presets = document.querySelector('#saved-paths');
if (![...presets.options].some(option => option.value === path.value)) presets.add(new Option(path.value, path.value));
presets.value = path.value;
status.textContent = 'Path saved for this preview session';
});
document.querySelector('[data-parent]').addEventListener('click', () => {
path.value = path.value.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
browse();
});
clear.addEventListener('click', () => {
selected = '';
selection.textContent = 'No image selected';
clear.disabled = true;
renderFiles();
status.textContent = 'Selection cleared';
});
document.querySelector('[data-error-example]').addEventListener('click', () => {
error.hidden = false;
status.textContent = 'Error example shown · use Retry to return';
});
document.querySelector('[data-primary-example]').addEventListener('click', () => {
status.textContent = 'Primary action example · blue is shared across both tools';
});
let nextRow = 1;
const rows = document.querySelector('[data-prompt-rows]');
const reset = document.querySelector('[data-reset-rows]');
function addRow() {
const id = nextRow++;
const row = document.createElement('div');
row.className = 'ca-prompt-row';
row.innerHTML = `<div class="ca-row"><label class="ca-switch" for="prompt-enabled-${id}"><input id="prompt-enabled-${id}" type="checkbox" role="switch" checked>Row ${id}</label><button type="button" class="ca-icon-button" aria-label="Remove row ${id}">×</button></div><label for="prompt-${id}" class="ca-visually-hidden">Prompt row ${id}</label><textarea id="prompt-${id}" placeholder="Write a prompt…"></textarea>`;
row.querySelector('button').addEventListener('click', () => {
row.remove();
reset.disabled = rows.children.length === 1 && rows.firstChild.querySelector('textarea').value === '';
document.querySelector('[data-add-row]').focus();
});
row.querySelector('textarea').addEventListener('input', () => { reset.disabled = false; });
row.querySelector('input').addEventListener('change', event => { row.querySelector('textarea').disabled = !event.target.checked; reset.disabled = false; });
rows.append(row);
}
document.querySelector('[data-add-row]').addEventListener('click', () => { addRow(); reset.disabled = false; rows.lastChild.querySelector('textarea').focus(); });
reset.addEventListener('click', () => { rows.replaceChildren(); nextRow = 1; addRow(); reset.disabled = true; document.querySelector('[data-add-row]').focus(); });
addRow();
renderFiles();
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+95
View File
@@ -0,0 +1,95 @@
import { test, expect } from '../../../tools/testing/node_modules/@playwright/test/index.mjs';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('theme preview updates native color scheme and both product samples together', async ({ page }) => {
await page.getByRole('radio', { name: 'Light', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
const samples = page.locator('[data-product-sample]');
expect(await samples.count()).toBe(2);
const backgrounds = await samples.evaluateAll(elements => elements.map(el => getComputedStyle(el).backgroundColor));
expect(new Set(backgrounds).size).toBe(1);
await page.getByRole('radio', { name: 'Dark', exact: true }).check();
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
});
test('gallery selection, filtering and layout controls operate on sample content', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toBeVisible();
await page.getByRole('button', { name: 'Select alpine-lake.png' }).click();
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('[data-selection]')).toContainText('alpine-lake.png');
await page.getByRole('radio', { name: 'List', exact: true }).check();
await expect(page.locator('[data-gallery]')).toHaveAttribute('data-view', 'list');
await page.getByLabel('Filter sample files').fill('dune');
await expect(page.locator('[data-gallery] button')).toHaveCount(1);
await page.getByLabel('Filter sample files').fill('missing');
await expect(page.getByText('No matching sample files.')).toBeVisible();
});
test('prompt rows support editing, disabling, removing and reset', async ({ page }) => {
await page.getByRole('button', { name: '+ Add row', exact: true }).click();
await page.getByLabel('Prompt row 2', { exact: true }).fill('A quiet lake at dawn');
await page.getByRole('switch', { name: 'Row 2', exact: true }).uncheck();
await expect(page.getByLabel('Prompt row 2', { exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Remove row 1', exact: true }).click();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Reset', exact: true }).click();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toBeEnabled();
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: 'Reset', exact: true })).toBeDisabled();
});
test('switches support keyboard and error example has an actionable recovery', async ({ page }) => {
const toggle = page.getByRole('switch', { name: 'Hide dot folders' });
await toggle.focus();
await page.keyboard.press('Space');
await expect(toggle).not.toBeChecked();
await expect(toggle).toBeFocused();
await expect(toggle).toHaveCSS('outline-style', 'solid');
await page.getByRole('button', { name: 'Show error example' }).click();
await expect(page.getByRole('alert')).toContainText('Folder unavailable');
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByRole('alert')).not.toBeVisible();
});
test('node controls resize without horizontal overflow and range supports keyboard', async ({ page }) => {
for (const width of [320, 460, 800]) {
await page.getByLabel('Preview node width').fill(String(width));
await page.getByLabel('Preview node width').dispatchEvent('input');
expect((await page.locator('[data-loader]').boundingBox()).width).toBeLessThanOrEqual(width);
const overflow = await page.locator('[data-loader]').evaluate(el => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
}
await page.setViewportSize({ width: 375, height: 850 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: test.info().outputPath('narrow.png'), fullPage: true });
const size = page.getByLabel('Thumbnail size');
await size.focus();
await page.keyboard.press('ArrowRight');
await expect(size).toHaveValue('152');
});
test('text, accent and error treatments have measurable contrast in both themes', async ({ page }) => {
const luminance = color => {
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
for (const theme of ['Light', 'Dark']) {
await page.getByRole('radio', { name: theme, exact: true }).check();
for (const selector of ['.ca-node', '.ca-primary', '.ca-danger', '.ca-muted-sample']) {
const colors = await page.locator(selector).first().evaluate(el => {
const style = getComputedStyle(el);
return [style.color, style.backgroundColor];
});
const [a, b] = colors.map(luminance).sort((x, y) => y - x);
expect((a + 0.05) / (b + 0.05), `${theme} ${selector}`).toBeGreaterThanOrEqual(4.5);
}
await page.screenshot({ path: test.info().outputPath(`${theme.toLowerCase()}.png`), fullPage: true });
}
});