Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42149d424 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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; } }
|
||||
@@ -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 |
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user