diff --git a/tests/browser/server.py b/tests/browser/server.py index dc1e783..b2575af 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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, diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs new file mode 100644 index 0000000..99e7c38 --- /dev/null +++ b/tests/browser/workspace-visual.spec.mjs @@ -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'); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index ba64873..7dc713b 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -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); }); diff --git a/tests/browser/workspace_visual_fixture.py b/tests/browser/workspace_visual_fixture.py new file mode 100644 index 0000000..3504d46 --- /dev/null +++ b/tests/browser/workspace_visual_fixture.py @@ -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() diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 3806c1e..532fc1f 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -214,5 +214,11 @@ "branches": 80, "functions": 93, "statements": 94 + }, + "workspace/icons.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 } } diff --git a/web/js/workspace/icons.mjs b/web/js/workspace/icons.mjs new file mode 100644 index 0000000..2d87210 --- /dev/null +++ b/web/js/workspace/icons.mjs @@ -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; +} diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index cf225b7..ff655fd 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -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; } diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index af347e6..5d8ed64 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -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); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 4fc6c02..48d560a 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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; } diff --git a/web/js/workspace/style.css b/web/js/workspace/style.css index d0a7b23..7baa396 100644 --- a/web/js/workspace/style.css +++ b/web/js/workspace/style.css @@ -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; } diff --git a/web/workspace.html b/web/workspace.html index 3fe3de1..0057c5a 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -17,8 +17,7 @@