style(workspace): align approved shell and visual fixtures

This commit is contained in:
Vito Sansevero
2026-10-17 23:31:46 -07:00
parent 4ef236aaf7
commit 0db03f55ad
11 changed files with 495 additions and 45 deletions
+22 -1
View File
@@ -14,6 +14,8 @@ from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_visual_fixture import seed_workspace_visual
class TestModel:
default_prompt = "Describe the image"
@@ -223,6 +225,7 @@ def create_app(root: Path) -> web.Application:
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
workspace_visual = data.get("workspace_visual", False)
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
prompt_inspector = data.get("prompt_inspector", False)
@@ -230,7 +233,22 @@ def create_app(root: Path) -> web.Application:
extra_prompts = data.get("extra_prompts", 0)
extra_variants = data.get("extra_variants", 0)
if (
isinstance(extra, bool)
not isinstance(workspace_visual, bool)
or (
workspace_visual
and any(
key in data
for key in (
"extra_images",
"include_video",
"prompt_inspector",
"native_library",
"extra_prompts",
"extra_variants",
)
)
)
or isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
@@ -244,6 +262,9 @@ def create_app(root: Path) -> web.Application:
or not 0 <= extra_variants <= 200
):
raise web.HTTPBadRequest()
if workspace_visual:
await api._run_in_executor(seed_workspace_visual, api, root)
return web.json_response({"success": True})
await api._run_in_executor(
seed,
extra,
+171
View File
@@ -0,0 +1,171 @@
import { writeFile } from 'node:fs/promises';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
const primary = ['images', 'library', 'tags', 'inspect'];
const tools = ['overview', 'system'];
async function noOverflow(page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
}
async function groupedNavigation(page) {
for (const [group, routes] of [['primary', primary], ['tools', tools]]) {
const region = page.locator(`.workspace-nav-${group}`);
await expect(region).toHaveCount(1);
expect(await region.locator('[data-route]').evaluateAll(nodes => nodes.map(node => node.dataset.route))).toEqual(routes);
}
}
async function attachEvidence(page, testInfo, name) {
await page.evaluate(async () => {
await document.fonts.ready;
for (const image of document.images) image.loading = 'eager';
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
});
const screenshotPath = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' });
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1'].map(selector => {
const node = document.querySelector(selector), box = node.getBoundingClientRect(), style = getComputedStyle(node);
return [selector, { x: box.x, y: box.y, width: box.width, height: box.height, padding: style.padding, fontSize: style.fontSize }];
})));
const geometryPath = testInfo.outputPath(`${name}-geometry.json`);
await writeFile(geometryPath, JSON.stringify(geometry, null, 2));
await testInfo.attach(`${name}-geometry`, { path: geometryPath, contentType: 'application/json' });
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
test('visual fixture exposes real stable media and library records', async ({ request }) => {
expect((await request.post('/__test__/reset', { data: { workspace_visual: 'true' } })).status()).toBe(400);
for (const option of ['extra_images', 'include_video', 'prompt_inspector', 'native_library', 'extra_prompts', 'extra_variants']) {
expect((await request.post('/__test__/reset', { data: { workspace_visual: true, [option]: option.startsWith('extra') ? 1 : true } })).status()).toBe(400);
}
await expect(await request.post('/__test__/reset', { data: { workspace_visual: true } })).toBeOK();
const images = await (await request.get('/prompt_manager/images/output?limit=40')).json();
expect(images.total).toBe(24);
expect(new Set(images.images.map(image => image.root_index))).toEqual(new Set([0, 1]));
const image = images.images[0];
await expect(await request.get(image.url)).toBeOK();
const thumbnail = await request.get(image.thumbnail_url);
await expect(thumbnail).toBeOK();
expect(thumbnail.headers()['content-type']).toBe('image/png');
const inspection = await (await request.get(`/prompt_manager/images/inspect/${encodeURIComponent(image.path)}?root=${image.root_index}`)).json();
expect(inspection.success).toBe(true);
expect(inspection.image.path).toBe(image.path);
expect(inspection.prompts.positive.text).toContain('Amber geometry');
expect(inspection.prompts.negative.text).toBe('blurred edges, watermark');
expect(new Set(images.images.map(image => image.modified_time)).size).toBe(24);
const library = await (await request.get('/prompt_manager/library')).json();
expect(library.total).toBe(6);
expect(library.items.reduce((sum, item) => sum + item.variant_count, 0)).toBe(10);
const recipes = await Promise.all(library.items.map(async item => (await request.get(`/prompt_manager/library/${item.id}`)).json()));
expect(recipes.flatMap(recipe => recipe.variants).some(variant => variant.negative_text === '')).toBe(true);
const options = await (await request.get('/prompt_manager/library/options')).json();
expect(options.success).toBe(true);
await request.post('/__test__/reset', { data: { workspace_visual: true } });
const repeated = await (await request.get('/prompt_manager/images/output?limit=40')).json();
const identity = items => items.map(({root_index, path, modified_time}) => ({root_index, path, modified_time}));
expect(identity(repeated.images)).toEqual(identity(images.images));
await expect(await request.post('/__test__/reset')).toBeOK();
expect((await (await request.get('/prompt_manager/stats')).json()).stats.total_prompts).toBe(2);
});
test('shell matches approved stable geometry in both themes', async ({ page, request }, testInfo) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
for (const [width, height] of [[1600, 1100], [390, 844]]) {
await page.setViewportSize({ width, height });
for (const route of ['images', 'library']) {
await page.goto(`${workspace}#/${route}`);
if (width === 1600) await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
await expect(page.locator('.workspace-heading > .workspace-heading-copy > h1')).toBeVisible();
await groupedNavigation(page);
await expect(page.locator(route === 'images' ? 'article[data-image-key]' : 'article[data-prompt-id]').first()).toBeVisible();
await expect(page.locator('.brand')).toHaveAttribute('href', '#/images');
await expect(page.locator('#workspace-main > .eyebrow')).toHaveCount(0);
for (const [theme, active] of [['dark', 'rgb(20, 54, 77)'], ['light', 'rgb(228, 241, 251)']]) {
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
await expect(page.locator('nav [aria-current="page"]')).toHaveCSS('background-color', active);
await expect(page.locator('h1')).toHaveCSS('font-weight', '620');
await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px');
await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px');
await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px');
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px');
if (width === 1600) {
await expect(page.locator('.sidebar')).toHaveCSS('width', '218px');
await expect(page.locator('.topbar')).toHaveCSS('height', '68px');
} else {
await expect(page.locator('.sidebar')).toHaveCSS('position', 'static');
expect(await page.locator('.workspace-shell').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
}
await noOverflow(page);
await attachEvidence(page, testInfo, `${route}-${width}-${theme}`);
}
}
}
});
test('long content and zoom keep grouped navigation reachable', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/library`);
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
await groupedNavigation(page);
for (const route of [...primary, ...tools]) {
const link = page.locator(`nav [data-route="${route}"]`);
await link.focus(); await expect(link).toBeFocused();
}
await page.getByRole('combobox', { name: 'Appearance', exact: true }).focus();
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeFocused();
const text = page.locator('.prompt-text').filter({ hasText: 'A study in very long creative context' });
await text.scrollIntoViewIfNeeded();
expect((await text.textContent()).length).toBeGreaterThan(1500);
await text.focus(); await expect(text).toBeFocused();
expect(await text.evaluate(node => {
const scroller = node.closest('.library-card-prompts');
scroller.scrollTop = scroller.scrollHeight;
return scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1;
})).toBe(true);
await noOverflow(page);
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
for (const width of [1251, 1250, 951, 950, 780, 700]) {
await page.setViewportSize({ width, height: 1100 });
await expect(page.locator('.sidebar')).toHaveCSS('position', width > 950 ? 'sticky' : 'static');
if (width > 950) await expect(page.locator('.sidebar')).toHaveCSS('width', width > 1250 ? '218px' : '185px');
await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width > 1250 ? '30px' : width > 700 ? '24px' : '14px');
await noOverflow(page);
}
});
test('approved icons are decorative safe SVGs and reject unknown vocabulary', async ({ page }) => {
await page.goto(`${workspace}#/images`);
const icons = await page.evaluate(async () => {
const { createWorkspaceIcon } = await import('/prompt_manager/js/workspace/icons.mjs');
const names = ['images', 'library', 'tags', 'upload', 'overview', 'settings', 'grid', 'list', 'masonry', 'search', 'info', 'copy', 'branch'];
const otherDocument = document.implementation.createHTMLDocument();
const rendered = names.map(name => {
const icon = createWorkspaceIcon(name, otherDocument);
return { name, svg: icon instanceof SVGSVGElement, owner: icon.ownerDocument === otherDocument,
viewBox: icon.getAttribute('viewBox'), hidden: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable'),
fill: icon.getAttribute('fill'), stroke: icon.getAttribute('stroke'), width: icon.getAttribute('stroke-width'),
path: icon.querySelector('path').getAttribute('d'), children: icon.childElementCount };
});
const unknown = ['missing', '__proto__', '', null, 1].map(name => {
try { createWorkspaceIcon(name); return false; } catch (error) { return error instanceof TypeError; }
});
return { rendered, unknown };
});
expect(icons.unknown).toEqual([true, true, true, true, true]);
for (const icon of icons.rendered) {
expect(icon).toMatchObject({ svg: true, owner: true, viewBox: '0 0 24 24', hidden: 'true', focusable: 'false', fill: 'none', stroke: 'currentColor', width: '1.65', children: 1 });
expect(icon.path.length).toBeGreaterThan(10);
}
expect(icons.rendered.find(icon => icon.name === 'overview').path).toBe('M3 12h4l3-8 4 16 3-8h4');
});
+22
View File
@@ -13,6 +13,17 @@ const routes = [
['system', 'System', '/prompt_manager/admin'],
];
async function countAppearanceWrites(page) {
await page.evaluate(() => {
window.__appearanceChanges = 0;
const original = Storage.prototype.setItem;
Storage.prototype.setItem = function (...args) {
if (args[0] === 'pm_workspace_theme') window.__appearanceChanges++;
return original.apply(this, args);
};
});
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
@@ -191,7 +202,13 @@ test('leaving a loading overview prevents its delayed result from replacing the
await finished;
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(page.locator('#workspace-main')).not.toContainText('999');
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
await countAppearanceWrites(page);
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption('light');
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
});
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
@@ -211,8 +228,13 @@ test('cached-page restoration remounts one working shell and refreshes its data'
expect(reads).toBe(3);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
await expect(page.locator('.workspace-nav-primary')).toHaveCount(1);
await expect(page.locator('.workspace-nav-tools')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toHaveCount(1);
await countAppearanceWrites(page);
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
expect(await page.evaluate(() => window.__appearanceChanges)).toBe(1);
});
+161
View File
@@ -0,0 +1,161 @@
"""Deterministic original artwork and saved prompts for workspace visual checks."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from PIL import Image, ImageDraw
from PIL.PngImagePlugin import PngInfo
from utils.hashing import generate_prompt_hash
if TYPE_CHECKING:
from py.api import PromptManagerAPI
_POSITIVES = (
(
"Amber geometry\nLayered sandstone arches, warm afternoon light, "
"precise silhouettes."
),
(
"Tidal observatory\nCobalt circles over a quiet sea, "
"a pale horizon and measured reflections."
),
(
"Forest atlas\nOverlapping emerald triangles, paper textures "
"and a soft misty background."
),
(
"Rose pavilion\nCoral blocks and floating windows, "
"balanced shadows on a terracotta plaza."
),
"Violet orbit\nConcentric rings, lavender haze and a tiny silver moon.",
"A study in very long creative context\n"
+ "Preserve every geometric detail, layered color relationship and quiet edge. "
* 18
+ "unbroken-"
+ "context" * 40,
)
_NEGATIVES = (
("blurred edges, watermark", "flat lighting, clipped highlights", ""),
("low contrast, noise", "distorted horizon"),
("oversaturated foliage", "soft geometry"),
("unbalanced composition",),
("banding, harsh gradients",),
("cropped details, illegible marks",),
)
def _write_art(path: Path, index: int, positive: str, negative: str) -> None:
sizes = ((720, 480), (480, 720), (560, 560), (900, 400))
palettes = (
("#252c46", "#e0a75e", "#b76445"),
("#153b57", "#64bfc6", "#dfdba8"),
("#1d4947", "#71ac83", "#d5d8aa"),
("#523547", "#ef9c87", "#bc6764"),
("#342b59", "#a79bd6", "#e5c0bf"),
("#303d4d", "#b1c4c5", "#d6a05f"),
)
width, height = sizes[index % len(sizes)]
background, accent, highlight = palettes[index // 4]
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
}
info = PngInfo()
info.add_text("prompt", json.dumps(graph))
path.parent.mkdir(parents=True, exist_ok=True)
with Image.new("RGB", (width, height), background) as image:
draw = ImageDraw.Draw(image)
draw.rectangle((0, height * 3 // 4, width, height), fill=highlight)
radius = min(width, height) // 3
center_x, center_y = width // 2 + (index % 3 - 1) * 25, height // 2
draw.ellipse(
(
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
),
fill=accent,
)
draw.polygon(
(
(width // 8, height * 7 // 8),
(width // 2, height // 4),
(width * 7 // 8, height * 7 // 8),
),
outline=highlight,
width=8,
)
draw.line(
(width // 6, height // 3, width * 5 // 6, height // 3),
fill=highlight,
width=4,
)
image.save(path, pnginfo=info)
stamp = 1_760_000_000 - index * 60
os.utime(path, (stamp, stamp))
def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompts",
):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
for parent_index, (positive, negatives) in enumerate(zip(_POSITIVES, _NEGATIVES)):
category = ("Architecture", "Landscape", "Abstract")[parent_index % 3]
tags = ["geometric", ("warm", "cool")[parent_index % 2]]
if parent_index == 5:
tags.append("long-context-" + "detail" * 28)
prompt_id = api.db.save_prompt(
positive,
category=category,
tags=tags,
prompt_hash=generate_prompt_hash(positive),
rating=parent_index if parent_index else None,
)
for variant_index, negative in enumerate(negatives):
api.db.save_prompt_pair(
positive,
negative,
title=f"Composition {variant_index + 1}",
category=category,
tags=tags,
)
for offset in range(4):
index = parent_index * 4 + offset
directory = ("output", "input")[offset % 2]
filename = f"study-{index + 1:02}.png"
if index == 23:
filename = (
"a-very-long-geometric-composition-"
+ "layered-context-" * 9
+ ".png"
)
path = root / directory / category.lower() / filename
_write_art(path, index, positive, negatives[offset % len(negatives)])
api.db.link_image_to_prompt(prompt_id, str(path))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
api.invalidate_gallery_cache()
+6
View File
@@ -214,5 +214,11 @@
"branches": 80,
"functions": 93,
"statements": 94
},
"workspace/icons.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
}
}
+33
View File
@@ -0,0 +1,33 @@
const paths = Object.freeze({
"images": "M3 3h18v18H3z M3 16l5-5 5 5 4-4 4 4 M8 7h.01",
"library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z",
"tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01",
"upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6",
"overview": "M3 12h4l3-8 4 16 3-8h4",
"settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2",
"grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
"list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
"masonry": "M3 3h7v11H3zM14 3h7v6h-7zM3 18h7v3H3zM14 13h7v8h-7z",
"search": "M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm5.5 13 5 5",
"info": "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z",
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
});
/** Create an approved decorative outline icon without injecting markup. */
export function createWorkspaceIcon(name, doc = document) {
if (typeof name !== 'string' || !Object.hasOwn(paths, name)) {
throw new TypeError('Unknown workspace icon');
}
const namespace = 'http://www.w3.org/2000/svg';
const icon = doc.createElementNS(namespace, 'svg');
for (const [attribute, value] of Object.entries({
class: 'workspace-icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
focusable: 'false', fill: 'none', stroke: 'currentColor',
'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
})) icon.setAttribute(attribute, value);
const path = doc.createElementNS(namespace, 'path');
path.setAttribute('d', paths[name]);
icon.append(path);
return icon;
}
-6
View File
@@ -1,7 +1,3 @@
.sidebar { position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; }
.brand-mark { position: relative; }
.brand-mark small { position: absolute; inset-inline-end: 4px; inset-block-end: 2px; font-size: 9px; color: white; }
main.images-main { max-inline-size: none; padding: 28px; }
.image-filters { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); }
.image-filters .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
.image-filters .ca-field:first-child { flex: 3 1 260px; }
@@ -44,10 +40,8 @@ main.images-main { max-inline-size: none; padding: 28px; }
.image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; }
@media (max-width: 1150px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 250px; gap: 16px; }
main.images-main { padding: 24px 18px; }
}
@media (max-width: 780px) {
.sidebar { position: static; block-size: auto; }
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; }
.image-details > :where(img, video) { max-block-size: 280px; }
+23 -9
View File
@@ -4,6 +4,7 @@ import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary } from './library.mjs';
import { createWorkspaceIcon } from './icons.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -35,11 +36,20 @@ function mountWorkspace() {
preference.value = theme.preference;
appearance.replaceChildren(createField('Appearance', preference, 'workspace-appearance'));
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
nav.replaceChildren(...routes.map(route => {
const link = element('a', route.label);
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' };
const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]];
nav.replaceChildren(...groups.map(([name, label, ids]) => {
const group = element('div', undefined, `workspace-nav-${name}`);
group.append(element('p', label, 'sidebar-label'));
for (const id of ids) {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icons[id]), element('span', route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
group.append(link);
}
return group;
}));
let viewScope;
let firstRender = true;
@@ -49,13 +59,17 @@ function mountWorkspace() {
viewScope?.dispose();
viewScope = createScope();
const view = viewScope;
for (const link of nav.children) {
for (const link of nav.querySelectorAll('[data-route]')) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
document.title = `${route.label} · Prompt Manager`;
const heading = element('h1', route.label);
main.replaceChildren(element('p', 'YOUR CREATIVE LIBRARY', 'eyebrow'), heading);
const headingCopy = element('div', undefined, 'workspace-heading-copy');
headingCopy.append(heading);
const headingRegion = element('header', undefined, 'workspace-heading');
headingRegion.append(headingCopy);
main.replaceChildren(headingRegion);
if (!firstRender) main.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = route.label;
@@ -68,7 +82,7 @@ function mountWorkspace() {
return;
}
if (route.id !== 'overview') {
main.append(element('p', route.description, 'intro'));
headingCopy.append(element('p', route.description, 'intro'));
const panel = element('section', undefined, 'panel handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
@@ -76,7 +90,7 @@ function mountWorkspace() {
main.append(panel);
return;
}
main.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
+2 -2
View File
@@ -1,4 +1,4 @@
main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding: 28px; }
main.library-main { --library-positive: #6bd6a4; }
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
.library-main .library-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-block: 20px 16px; }
.library-main .library-toolbar .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
@@ -41,6 +41,6 @@ main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding:
.library-main .library-shared-gallery { margin-block-start: 24px; }
.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; }
@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { main.library-main { padding: 20px 14px; } .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } }
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } }
[data-ca-theme="light"] main.library-main { --library-positive: #14784f; }
+54 -25
View File
@@ -2,30 +2,41 @@
* { box-sizing: border-box; }
body.ca-tools {
margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124;
background: var(--workspace-page); font-size: 14px;
background: var(--workspace-page); --workspace-active: #14364d;
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
}
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; }
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; --workspace-active: #e4f1fb; }
[hidden] { display: none !important; }
a { color: var(--ca-focus); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100dvh; }
.sidebar { background: var(--workspace-sidebar); border-right: 1px solid var(--ca-line); padding: 28px 18px; display: flex; flex-direction: column; gap: 24px; }
.brand { display: flex; gap: 12px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 16px; }
.brand small { display: block; color: var(--ca-muted); font-size: 11px; font-weight: 400; margin-top: 2px; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--ca-accent); color: white; }
.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-block-size: 100dvh; }
.sidebar { background: var(--workspace-sidebar); border-inline-end: 1px solid var(--ca-line); padding: 26px 14px 16px; display: flex; flex-direction: column; gap: 5px; position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; overflow-y: auto; }
.brand { display: flex; gap: 11px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 14px; margin: 0 4px 28px; line-height: 1.3; }
.brand small { display: block; color: var(--ca-muted); font-size: 9px; letter-spacing: 1.8px; font-weight: 400; margin-block-start: 6px; }
.brand-mark { flex: 0 0 34px; inline-size: 34px; block-size: 36px; color: var(--ca-focus); font-size: 29px; position: relative; }
.brand-mark > span { font-size: 15px; position: absolute; inset-block-end: -2px; inset-inline-end: -2px; }
.eyebrow { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--ca-muted); margin: 0; }
nav { display: grid; gap: 6px; }
nav a { color: var(--ca-muted); padding: 11px 14px; border-radius: 7px; min-height: 44px; }
nav a:hover { background: var(--ca-hover); color: var(--ca-text); text-decoration: none; }
nav a[aria-current="page"] { color: #fff; background: var(--ca-accent); }
.sidebar-footer { margin-top: auto; color: var(--ca-muted); font-size: 11px; display: flex; align-items: center; gap: 8px; }
.connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ca-muted); }
.workspace-body { min-width: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 32px; border-bottom: 1px solid var(--ca-line); font-size: 12px; }
.sidebar-label { margin: 14px 12px 6px; font-size: 9px; font-weight: 650; letter-spacing: 1.1px; color: var(--ca-muted); }
nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; gap: 5px; }
.workspace-nav-primary, .workspace-nav-tools { display: grid; gap: 3px; }
.workspace-nav-tools { margin-block-start: auto; padding-block-start: 32px; }
nav [data-route] { display: flex; align-items: center; gap: 11px; color: var(--ca-muted); padding: 8px 12px; border-radius: 6px; min-block-size: 38px; font-size: 12px; overflow-wrap: anywhere; }
nav [data-route]:hover { background: var(--ca-field); color: var(--ca-text); text-decoration: none; }
nav [aria-current="page"] { color: var(--ca-focus); background: var(--workspace-active); }
.workspace-icon { display: block; inline-size: 17px; block-size: 17px; flex: 0 0 auto; }
.sidebar-footer { margin: 17px 6px 0; padding-block-start: 17px; border-block-start: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 10px; display: flex; align-items: center; gap: 8px; }
.connection-dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; }
.workspace-body { min-inline-size: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; block-size: 68px; padding-inline: 30px; border-block-end: 1px solid var(--ca-line); font-size: 12px; }
.workspace-appearance { display: flex; align-items: center; gap: 7px; font-size: 11px; }
.ca-tools .workspace-appearance select { font-size: 11px; }
.muted { color: var(--ca-muted); }
main { flex: 1; padding: 36px; width: 100%; max-width: 1500px; margin: 0 auto; }
h1 { font-size: clamp(25px, 4vw, 34px); line-height: 1.3; letter-spacing: -1px; margin: 10px 0; font-weight: 620; }
main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-inline-size: 0; margin-inline: auto; }
h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; }
.workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-block-end: 22px; }
.workspace-heading-copy { min-inline-size: 0; }
.workspace-heading-copy .intro { font-size: 12px; margin: 6px 0 0; }
h2 { font-size: 18px; margin: 0 0 8px; }
h3 { font-size: 15px; margin: 0; }
p { line-height: 1.6; }
@@ -45,19 +56,37 @@ p { line-height: 1.6; }
.page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 11px; }
.skip { position: fixed; top: 10px; left: 10px; z-index: 10; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
.skip:focus { clip-path: none; width: auto; height: auto; padding: 12px; background: var(--ca-surface); }
@media (max-width: 780px) {
@media (max-width: 1250px) {
.workspace-shell { grid-template-columns: 185px minmax(0, 1fr); }
.sidebar { padding-inline: 9px; }
.brand { font-size: 12px; }
.topbar { padding-inline: 24px; }
main { padding: 24px; }
}
@media (max-width: 950px) {
.workspace-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { padding: 18px; gap: 16px; border-right: 0; border-bottom: 1px solid var(--ca-line); }
nav { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
nav a { padding: 10px 6px; font-size: 12px; text-align: center; }
.sidebar > .eyebrow, .sidebar-footer { display: none; }
.topbar { padding: 14px 18px; flex-wrap: wrap; }
main { padding: 26px 18px; }
.sidebar { position: static; block-size: auto; padding: 12px 18px; border-inline-end: 0; border-block-end: 1px solid var(--ca-line); overflow: visible; }
.brand { margin: 0 0 12px; }
.sidebar-label, .sidebar-footer { display: none; }
.workspace-nav-primary, .workspace-nav-tools { display: flex; flex-wrap: wrap; gap: 4px; }
.workspace-nav-tools { margin: 0; padding-block-start: 5px; border-block-start: 1px solid var(--ca-line); }
nav [data-route] { min-block-size: 32px; padding: 5px 10px; }
.topbar { block-size: auto; min-block-size: 58px; padding: 10px 24px; flex-wrap: wrap; }
}
@media (max-width: 700px) {
main { padding: 20px 14px; }
.topbar { padding-inline: 14px; }
.topbar > span { overflow-wrap: anywhere; }
.workspace-heading { align-items: start; flex-direction: column; }
h1 { font-size: 25px; }
nav [data-route] { font-size: 11px; gap: 6px; }
nav .workspace-icon { inline-size: 14px; block-size: 14px; }
.brand { font-size: 14px; }
.stat-grid { gap: 8px; }
.stat { padding: 16px; }
.stat dd { font-size: 26px; }
.panel { padding: 18px; }
.page-footer { margin: 0 18px; }
.page-footer { margin: 0 14px; flex-wrap: wrap; }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
+1 -2
View File
@@ -17,8 +17,7 @@
<a class="skip" href="#workspace-main">Skip to content</a>
<div class="workspace-shell">
<aside class="sidebar">
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P<small>±</small></span><span>Prompt Manager<small>ComfyAssets</small></span></a>
<p class="eyebrow">Your workspace</p>
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
<nav aria-label="Workspace"></nav>
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
</aside>