style(workspace): align approved shell and visual fixtures
This commit is contained in:
+22
-1
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -214,5 +214,11 @@
|
||||
"branches": 80,
|
||||
"functions": 93,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/icons.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user