diff --git a/docs/concepts/workspace/README.md b/docs/concepts/workspace/README.md new file mode 100644 index 0000000..2b3be94 --- /dev/null +++ b/docs/concepts/workspace/README.md @@ -0,0 +1,106 @@ +# Prompt Manager application redesign concept + +Status: interactive proposal for maintainer review, 2026-10-17. + +## Review the mockup + +Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and +open `/workspace/`. The original approved timeline remains available at `/`. +The server exposes an explicit allowlist of concept files and pinned assets. +All examples are synthetic; there are no application API calls, database access, +real model downloads, or ComfyUI handoffs. Local file inspection previews an +image in the browser without uploading it. Exports contain sample data only. + +The sidebar opens Images, Prompt library, Tags & categories, Inspect a file, +Activity, System and Design review. System includes Overview, Appearance, +Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and +Diagnostics & logs. The in-app Design review contains the feature map below. + +## Proposed direction + +One application shell replaces separate Admin, Gallery and Metadata shells. +Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary +actions, green positive roles, red negative roles and errors. Preserve all +existing controls and services while making their scope clearer. + +Browsing is image-led with readable thumbnails and one metadata inspector. +Prompt library groups by positive; negative variants and runs share its gallery. +The full selected negative appears directly beneath the positive in history. +Favorites express preference independently of the existing quality rating. +Collections organize projects without duplicating recipe text; saved views keep +search/filter context. New capabilities are proposals, not implemented features. + +Maintenance moves out of the browsing toolbar. Checks, impact preview, backups, +and apply steps have separate responsibilities. Distinguish prompt text reuse +from image-file duplicates, and metadata enrichment from thumbnail generation. +No repeated global thumbnail-generation prompt on opening the gallery. + +## Send to ComfyUI + +Use the same original paper-plane outline, blue accent and accessible role label +on every positive/negative prompt block, including image details and the viewer. +A pair action sends both. Preview destination and exact fields before applying; +positive-only and negative-only actions leave the other role untouched. An +explicitly empty negative means clear the target field; unknown metadata must +never be interpreted as empty. Sending text neither queues execution nor +replaces a workflow. + +Production needs a shared host bridge with versioned payloads, origin validation, +destination discovery, capability checks, disconnected-host handling, linked-input +checks and acknowledgements. Preview actual target values and report success only +after acknowledgement. Default to asking for a destination; never guess between +multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes +2.0 with both packs installed independently and together. + +## Existing feature inventory + +| Current feature | Proposed home / behavior | Disposition | +| --- | --- | --- | +| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt | +| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated | +| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained | +| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated | +| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified | +| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained | +| Grid/list, thumbnail size, columns, pages/infinite scroll | One Appearance configuration; contextual layout / density overrides | Simplified | +| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated | +| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained | +| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained | +| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow | +| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained | +| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained | +| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained | +| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained | +| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained | +| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal | +| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept | +| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal | +| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded | +| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal | +| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal | + +## Implementation boundaries and test requirements + +Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts; +this mock uses their actual checked-in files without modifying them. Preserve +legacy routes during the transition, current node layouts/schemas, existing +controllers where reusable, and all existing test/coverage gates. + +The timeline needs persisted execution records to represent real runs. Saved +views, collections, metadata review resolution, activity history, scheduled +backups, portable JSON import, and the ComfyUI bridge need separate implementation +and persistence work. Preserve source evidence and distinguish recorded from +inferred metadata. Existing schema migration is automatic; old image enrichment +remains an optional explicit scan. The one-time upgrade notice belongs in the +new shell, with Scan and Later choices. + +Implement in reviewed slices: shared application shell and inspector; reusable +prompt blocks/send bridge; image workspace; grouped library/history; settings and +maintenance; optional organization and workflow improvements. Write regressions +first for production behavior. Retain Python, frontend unit, Chromium/Firefox/ +WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared +filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe +maintenance, backup/restore, paired metadata and correct send destinations. + +Screenshots in `screenshots/` record the reviewed starting point. The approved +standalone timeline in `../prompt-variant-timeline.html` remains unchanged. diff --git a/docs/concepts/workspace/app.js b/docs/concepts/workspace/app.js new file mode 100644 index 0000000..9e77031 --- /dev/null +++ b/docs/concepts/workspace/app.js @@ -0,0 +1,1487 @@ +import { + createButton, + createScope, + createSwitch, + mountTheme, +} from "/toolkit/core.js"; +const scope = createScope(); +mountTheme(document.body, null, scope); +const $ = (id) => document.getElementById(id); +const esc = (value) => + String(value ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +const paths = { + send: "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13", + 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", + activity: "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", + 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", + grid: "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", + list: "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01", + copy: "M8 8h13v13H8z M16 8V3H3v13h5", + info: "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z", + folder: "M3 5h7l2 3h9v12H3z", + star: "m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z", + check: "m5 12 4 4L19 6", + upload: "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6", + review: "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5", + history: "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2", + plus: "M12 4v16M4 12h16", +}; +function icon(name) { + return ``; +} +function button(label, action, extra = "", className = "") { + const control = createButton("", className); + control.dataset.action = action; + return control.outerHTML.replace( + ">", + `${extra ? " " + extra : ""}>${label}`, + ); +} +function sendButton(p, role = "pair", variant = 0, withLabel = false) { + return button( + icon("send") + (withLabel ? " Send to ComfyUI" : ""), + "send", + `data-prompt="${p.id}" data-role="${role}" data-variant="${variant}" aria-label="Send ${role === "pair" ? "prompt pair" : role + " prompt"} to ComfyUI: ${esc(p.title)}"`, + "send " + (withLabel ? "" : "icon-btn"), + ); +} +const prompts = [ + { + id: "alpine", + title: "Alpine observatory", + category: "Landscapes", + tags: ["cinematic", "blue_hour", "architecture"], + positive: + "A remote alpine observatory at blue hour, warm light through glass, dramatic mountain peaks, cinematic composition, atmospheric mist, fine detail", + negatives: [ + { name: "Quality baseline", text: "blurry, low quality, low resolution" }, + { + name: "Clean architecture", + text: "blurry, low quality, low resolution, distorted architecture, watermark, text", + }, + { name: "No negative", text: "" }, + ], + scene: "alpine", + color: ["#14233c", "#9aaac3", "#d8d9dd"], + rating: 5, + }, + { + id: "dunes", + title: "Quiet geometry", + category: "Architecture", + tags: ["minimal", "warm_light", "editorial"], + positive: + "Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm terracotta, late afternoon sun, architectural photography, quiet geometry", + negatives: [ + { + name: "Natural materials", + text: "plastic, harsh shadows, noise, watermark", + }, + { + name: "Clean lines", + text: "plastic, harsh shadows, noise, watermark, distorted geometry", + }, + ], + scene: "desert", + color: ["#6c4948", "#ddad91", "#efe0c5"], + rating: 4, + }, + { + id: "forest", + title: "Somewhere in the pines", + category: "Landscapes", + tags: ["forest", "mist", "peaceful"], + positive: + "A quiet cedar cabin above a still forest lake, towering evergreens, soft morning mist, muted jade and moss, a small amber window, cinematic still", + negatives: [ + { + name: "Soft atmosphere", + text: "oversaturated, blurry, low quality, text", + }, + ], + scene: "forest", + color: ["#163b3d", "#6f9691", "#c0cfc1"], + rating: 5, + }, + { + id: "city", + title: "After the rain", + category: "Environments", + tags: ["neon", "night", "cinematic"], + positive: + "Rain-soaked city street at night, blue neon and rose reflections, deep perspective, cinematic science fiction, solitary architecture, detailed atmosphere", + negatives: [ + { + name: "Clean atmosphere", + text: "blurry, low resolution, watermark, oversaturated", + }, + { + name: "Less signage", + text: "blurry, low resolution, watermark, oversaturated, text, signs", + }, + ], + scene: "city", + color: ["#1a2143", "#6869a1", "#cf8fbc"], + rating: 4, + }, + { + id: "lunar", + title: "The long way home", + category: "Illustration", + tags: ["space", "minimal", "concept_art"], + positive: + "An isolated lunar research outpost, a luminous planet above the horizon, sweeping pale terrain, quiet science fiction, finely detailed concept art", + negatives: [ + { name: "Quality baseline", text: "blurry, low quality, watermark" }, + ], + scene: "lunar", + color: ["#19283b", "#7398ac", "#e2d3c4"], + rating: 5, + }, + { + id: "coast", + title: "Between sea and sky", + category: "Landscapes", + tags: ["coast", "soft_light", "peaceful"], + positive: + "A solitary lighthouse on a rugged coastal cliff, sea mist, glints of morning light on turquoise water, atmospheric perspective, fine art landscape", + negatives: [ + { + name: "Natural light", + text: "oversaturated, flat lighting, watermark, text", + }, + ], + scene: "coast", + color: ["#255565", "#8cbcc2", "#e8d9bf"], + rating: 4, + }, +]; +const images = Array.from({ length: 4 }, (_, j) => + prompts.map((p, i) => ({ + id: `${p.id}-${j}`, + prompt: p.id, + index: j, + variant: j % p.negatives.length, + seed: 42001 + i * 100 + j, + starred: j === 0 && i % 2 === 0, + filename: `${p.id}_${String(j + 1).padStart(5, "0")}.png`, + })), +).flat(); +const state = { + page: "images", + system: "general", + query: "", + category: "", + filter: "all", + view: "grid", + sort: "recent", + size: "comfortable", + selectedImage: images[0].id, + selection: new Set(), + prompt: "alpine", + variant: 0, + compare: false, + activity: [ + { + title: "Library metadata enriched", + detail: + "12 existing images linked · 0 duplicates · completed 12 minutes ago", + done: true, + }, + { + title: "3 images need metadata review", + detail: "Ambiguous sampler paths · your existing links are preserved", + done: false, + }, + ], + saved: [], + collection: "", + notice: true, + settingsDirty: false, +}; +let viewer = null, + viewImage = null, + sendPayload = null, + toastTimer, + slideTimer = null; +function prompt(id) { + return prompts.find((p) => p.id === id) || prompts[0]; +} +function image(id) { + return images.find((i) => i.id === id) || images[0]; +} +function art(p, n = 0) { + const [a, b, c] = p.color; + const shift = n * 13; + let shape = ""; + if (p.scene === "alpine") + shape = ``; + if (p.scene === "desert") + shape = ``; + if (p.scene === "forest") { + shape = ``; + for (let i = 0; i < 15; i++) { + const x = i * 65 - 30; + shape += ``; + } + shape += ``; + } + if (p.scene === "city") { + shape = ''; + for (let i = 0; i < 12; i++) { + const x = i < 6 ? i * 59 : 465 + (i - 6) * 60; + const h = 140 + ((i * 81 + shift) % 180); + shape += ``; + } + shape += ``; + } + if (p.scene === "lunar") + shape = ``; + if (p.scene === "coast") + shape = ``; + const svg = `${shape}`; + return "data:image/svg+xml," + encodeURIComponent(svg); +} +function notify(message) { + $("toast").textContent = message; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => ($("toast").textContent = ""), 6500); +} +function dialog(title, body) { + $("modal-title").textContent = title; + $("modal-body").innerHTML = body; + if (!$("modal").open) $("modal").showModal(); +} +function closeDialog() { + $("modal").close(); +} +function go(hash) { + closeDialog(); + $("viewer-dialog").close(); + if (location.hash.slice(1) === hash) render(); + else location.hash = hash; +} +function promptBlock(p, role, variant = 0) { + const negative = p.negatives[variant] || p.negatives[0]; + const text = role === "positive" ? p.positive : negative.text; + return `
${role === "positive" ? "+ Positive prompt" : "− Negative prompt"}
${button(icon("copy"), "copy", `data-prompt="${p.id}" data-role="${role}" data-variant="${variant}" aria-label="Copy ${role} prompt: ${esc(p.title)}"`, "icon-btn")}${sendButton(p, role, variant)}

${esc(text || "No negative prompt (explicitly empty)")}

`; +} +function heading(title, description, actions = "") { + return `

${esc(title)}

${description}

${actions}
`; +} +function sectionNav(items, active, action) { + return `
${items.map(([id, title]) => button(title, action, `data-value="${id}" aria-pressed="${active === id}"`)).join("")}
`; +} +function nav() { + const links = (items) => + items + .map( + ([id, name, ico, count]) => + `${icon(ico)}${esc(name)}${count ? `${count}` : ""}`, + ) + .join(""); + $("primary-nav").innerHTML = links([ + ["images", "Images", "images", images.length], + ["library", "Prompt library", "library", prompts.length], + [ + "tags", + "Tags & categories", + "tags", + new Set(prompts.flatMap((p) => p.tags)).size, + ], + ["metadata", "Inspect a file", "upload", ""], + ]); + $("saved-nav").innerHTML = links([ + ["favorites", "Favorites", "star", images.filter((i) => i.starred).length], + ["review", "Needs review", "review", "3"], + ...state.saved.map((view, i) => [`saved-${i}`, view.name, "search", ""]), + ]); + $("collections-nav").innerHTML = links([ + ["collection-landscapes", "Landscapes", "folder", "12"], + ["collection-experiments", "Experiments", "folder", "8"], + ]); + $("system-nav").innerHTML = links([ + ["activity", "Activity", "activity", state.activity.length], + ["system", "System", "settings", ""], + ["design", "Design review", "info", ""], + ]); + $("activity-count").textContent = state.activity.length; +} +function toolbar() { + return `${button("Save view", "save-view")}${button("Filters", "filters")}`; +} +function matches(p, additional = "") { + return ( + (!state.category || p.category === state.category) && + (!state.query || + [ + p.title, + p.positive, + p.category, + ...p.tags, + ...p.negatives.map((n) => n.text), + additional, + ] + .join(" ") + .toLowerCase() + .includes(state.query.toLowerCase())) + ); +} +function visibleImages() { + let result = images.filter( + (i) => + matches(prompt(i.prompt), i.filename) && + (state.filter !== "favorites" || i.starred) && + (!state.collection || + (state.collection === "Landscapes" + ? prompt(i.prompt).category === "Landscapes" + : ["Architecture", "Environments"].includes( + prompt(i.prompt).category, + ))), + ); + if (state.sort === "name") + result.sort((a, b) => a.filename.localeCompare(b.filename)); + if (state.sort === "rating") + result.sort((a, b) => prompt(b.prompt).rating - prompt(a.prompt).rating); + return result; +} +function mediaCard(i) { + const p = prompt(i.prompt); + return `
${button(`${esc(p.title)} · sample illustration ${i.index + 1} 1 ? 'loading="lazy"' : ""}>`, "open-image", `data-id="${i.id}" aria-label="Open ${esc(i.filename)}"`, "image-open")}
${button(i.starred ? "★" : "☆", "favorite", `data-id="${i.id}" aria-pressed="${i.starred}" aria-label="Favorite ${esc(i.filename)}"`, "star-button")}

${esc(p.title)}

1536 × 1152 · PNG · Seed ${i.seed}

${button(icon("info"), "inspect", `data-id="${i.id}" aria-label="Inspect ${esc(i.filename)}"`, "icon-btn")}
`; +} +function imageDetails(i, full = false) { + const p = prompt(i.prompt); + return `${!full ? `` : ""}

${esc(p.title)}

PNG

${i.filename}

Execution recorded${esc(p.category)}
${promptBlock(p, "positive", i.variant)}${promptBlock(p, "negative", i.variant)}
${p.tags.map((t) => button(esc(t), "tag-filter", `data-value="${esc(t)}"`, "chip")).join("")}
${[ + ["Seed", i.seed], + ["Steps", "28"], + ["CFG", "6.5"], + ["Sampler", "dpmpp_2m"], + ["Scheduler", "karras"], + ["Model", "illustrative-model.safetensors"], + ] + .map(([k, v]) => `
${k}
${v}
`) + .join( + "", + )}
${full ? `
File & provenance

output/concepts/${i.filename}
Prompt evidence: execution recorded
Run: sample-${i.seed} · Original file unchanged

Workflow & raw metadata
${esc(JSON.stringify({ sample: true, seed: i.seed, positive: p.positive, negative: p.negatives[i.variant].text, workflow: "Demo workflow — no actual graph" }, null, 2))}
${button("Download sample JSON", "download-workflow", `data-id="${i.id}"`)}
` : ""}
${!full ? '" : ""}`; +} +function renderImages() { + const title = + state.filter === "favorites" ? "Favorites" : state.collection || "Images"; + $("main").innerHTML = + heading( + title, + "Your generations, their prompts, and the next idea.", + button(icon("upload") + " Import", "import") + + button(icon("plus") + " New prompt", "new-prompt", "", "ca-primary"), + ) + + toolbar() + + `
${button(icon("grid") + " Grid", "image-view", 'data-value="grid" aria-pressed="' + (state.view === "grid") + '"')}${button(icon("list") + " List", "image-view", 'data-value="list" aria-pressed="' + (state.view === "list") + '"')}
${icon("folder")} All output folders${button("Group by positive prompt →", "go-library", "", "chip")}Illustrative previews
`; + $("image-size").value = state.size; + updateImages(); +} +function updateBulk() { + $("bulkbar").innerHTML = state.selection.size + ? `
${state.selection.size} selected${button("Add tags", "bulk-tags")}${button("Set category", "bulk-category")}${button("Add to collection", "bulk-collection")}${button("Export metadata", "bulk-export")}${button("Remove links…", "bulk-remove", "", "danger")}${button("Clear", "bulk-clear")}
` + : ""; +} +function updateImages() { + const shown = visibleImages(); + if (shown.length && !shown.some((i) => i.id === state.selectedImage)) + state.selectedImage = shown[0].id; + $("result-count").textContent = + `${shown.length} images · ${new Set(shown.map((i) => i.prompt)).size} positive prompts`; + $("image-grid").className = + `image-grid ${state.size === "comfortable" ? "" : state.size} ${state.view === "list" ? "list-grid" : ""}`; + $("image-grid").innerHTML = shown.length + ? shown.map(mediaCard).join("") + : '
No matching images. Try another search or category.
'; + $("image-inspector").innerHTML = shown.length + ? imageDetails(image(state.selectedImage)) + : '

No image selected

Clear the filters or choose a matching image.

'; + updateBulk(); +} +function renderLibrary() { + const shown = prompts.filter((p) => matches(p)); + if (state.sort === "name") + shown.sort((a, b) => a.title.localeCompare(b.title)); + if (state.sort === "rating") shown.sort((a, b) => b.rating - a.rating); + $("main").innerHTML = + heading( + "Prompt library", + "One positive. Reusable negatives. Every experiment stays connected.", + button("Export", "export-library") + + button("+ New prompt", "new-prompt", "", "ca-primary"), + ) + + toolbar() + + `
${shown.length} positive prompts · ${shown.reduce((n, p) => n + p.negatives.length, 0)} negative variantsImages are grouped by positive prompt
${shown.map((p) => `
${button(esc(p.title), "history", `data-prompt="${p.id}"`)}${esc(p.category)}
${promptBlock(p, "positive")}
− ${p.negatives.length} negative variants4 runs4 images
${p.tags.map((t) => `${t}`).join("")}
`).join("") || '
No matching prompts.
'}
`; +} +function renderHistory() { + const p = prompt(state.prompt), + v = p.negatives[state.variant] || p.negatives[0]; + $("main").innerHTML = + heading( + p.title, + "Prompt history · one positive, one shared gallery", + button("← Prompt library", "go-library") + + button("Edit metadata", "edit-prompt", `data-prompt="${p.id}"`) + + sendButton(p, "pair", state.variant, true), + ) + + `
${esc(p.category)}${promptBlock(p, "positive", state.variant)}${promptBlock(p, "negative", state.variant)}
4 tracked runs4 images${p.negatives.length} negative variants

Negative variants

Choose a red dot to change the negative and filter this gallery.

${button("Compare negatives", "compare-negatives", `aria-pressed="${state.compare}"`)}${button("+ Add negative", "new-negative", `data-prompt="${p.id}"`)}
Positive prompt · unchanged
${p.negatives.map((n, i) => `
${button("−", "variant", `data-index="${i}" aria-pressed="${state.variant === i}" aria-label="Select negative ${esc(n.name)}"`, "variant-dot")}${esc(n.name)} · ${images.filter((im) => im.prompt === p.id && im.variant === i).length} runs${images.filter((im) => im.prompt === p.id && im.variant === i).length > 1 ? button("", "run-filter", `data-prompt="${p.id}" data-variant="${i}" aria-label="Show repeat run for ${esc(n.name)}"`, "run-dot") : ""}
`).join("")}
Green + positive · red − negative · small dot repeat run${button("All images for this positive", "history-all")}
${ + state.compare + ? `

Compare negative variants

Baseline compared with ${esc(v.name)}. Choose another red dot to change the comparison.

${promptBlock(p, "negative", 0)}
${promptBlock(p, "negative", state.variant)}

Added terms

${ + v.text + .split(",") + .map((t) => t.trim()) + .filter( + (t) => + t && + !p.negatives[0].text + .split(",") + .map((x) => x.trim()) + .includes(t), + ) + .map((t) => `+ ${esc(t)}`) + .join("") || '

No added terms.

' + }
` + : "" + }`; +} +const config = { "video-auto": false, "console-log": false }; +function setting(id, label, description, type = "toggle", options = []) { + let control = ""; + const val = config[id]; + if (type === "toggle") + control = ``; + else if (type === "select") + control = ``; + else + control = ``; + return `

${description}

${control}
`; +} +const systemSections = [ + ["general", "Overview"], + ["appearance", "Appearance"], + ["sources", "Sources & scans"], + ["integrations", "Integrations"], + ["tagging", "Auto-tagging"], + ["maintenance", "Maintenance"], + ["backups", "Backup & export"], + ["diagnostics", "Diagnostics & logs"], +]; +function operation( + title, + desc, + action, + label = "Review", + extra = "", + danger = false, +) { + return `

${esc(title)}

${desc}

${button(label, action, extra, danger ? "danger" : "")}
`; +} +function renderSystem() { + let body = ""; + switch (state.system) { + case "general": + body = `
${[ + [prompts.length, "Positive prompts"], + [ + prompts.reduce((n, p) => n + p.negatives.length, 0), + "Negative variants", + ], + [images.length, "Images"], + ["Healthy", "Database integrity"], + ] + .map( + ([n, s]) => + `
${n}${s}
`, + ) + .join( + "", + )}

Library health

${operation("3 ambiguous image links", "Review metadata before assigning a positive / negative pair.", "go-review", "Review metadata")}${operation("Backup status", "Last sample backup today at 09:30 · verification passed.", "system-tab", "View backups", 'data-value="backups"')}${operation("No missing thumbnails", "Generate only missing previews when necessary.", "job", "Review thumbnails", 'data-job="Thumbnail check"')}

Workspace preferences

${operation("One display configuration", "Grid, density and paging settings are shared across views.", "system-tab", "Appearance", 'data-value="appearance"')}${operation("2 sample output sources", "Monitoring and enrichment share one source list.", "system-tab", "Manage sources", 'data-value="sources"')}${operation("Statistics", "Usage by prompt, category, tag and date.", "stats", "Explore")}
`; + break; + case "appearance": + body = `

Browsing & display

One set of defaults; toolbar changes apply to the current view.

${setting("default-view", "Default layout", "Choose your starting image layout.", "select", ["Grid", "List"])}${setting("density", "Thumbnail size", "Readable previews by default; no fixed column-count setting.", "select", ["Comfortable", "Large", "Compact"])}${setting("loading", "Loading behavior", "One choice replaces separate page-size and infinite-scroll controls.", "select", ["Load more on scroll", "Paged results"])}${setting("batch-size", "Results per batch", "Controls request size, independent of thumbnail size.", "select", ["50", "100", "200"])}${setting("metadata-auto", "Load metadata on selection", "Use the same inspector in the gallery and image viewer.")}${setting("metadata-cache", "Cache metadata in this browser", "Reuse fetched metadata until it changes or you clear this cache.")}${setting("image-quality", "Image loading quality", "Preserve original / optimized / fast image-loading preferences.", "select", ["Optimized", "Original", "Fast"])}${setting("video-auto", "Autoplay videos", "Videos stay within the same gallery and use native playback controls.")}${setting("video-muted", "Mute videos by default", "Keep playback quiet while browsing.")}${setting("video-loop", "Loop videos", "Repeat a selected clip until you navigate away.")}${setting("paths", "Show file paths", "Paths appear under File & provenance, not across every card.")}${setting("open-mode", "Open from ComfyUI", "Keep the node pack’s popup / new-tab preference.", "select", ["New tab", "Popup window"])}${setting("timeout", "Node search auto-hide (seconds)", "Node search behavior only. Use 0 to keep results visible.", "number", [30])}
`; + break; + case "sources": + body = `${state.notice ? `

Bring older images into your prompt history

The schema upgrades automatically. An optional metadata scan links existing positives and negatives without regenerating all thumbnails.

${button("Later", "dismiss-notice")}${button("Review scan", "scan-review", "", "ca-primary")}
` : ""}

Image sources

${button("+ Add source", "add-source")}

The same folders power browsing, monitoring and metadata enrichment.

${operation("ComfyUI output", "/ComfyUI/output · watching · sample path", "scan-review", "Scan changes")}${operation("Archive", "/images/archive · manual scan · sample path", "scan-review", "Scan folder")}
${setting("monitoring", "Watch for new outputs", "New files enter the same library. Existing scans do not count as new runs.")}${setting("workers", "Worker threads", "Balance background scans and thumbnail work against generation.", "range", [4])}${operation("Enrich existing metadata", "Resolve both prompts, deduplicate recipes, preserve uncertain links.", "scan-review", "Review scan")}${operation("Thumbnail generation", "Generate missing previews; rebuilding all is a separate explicit option.", "thumbnail-review", "Review options")}
`; + break; + case "integrations": + body = `

ComfyUI

One send contract across cards, prompt history and every metadata view.

${setting("send-destination", "Default send destination", "A destination picker previews both roles before applying.", "select", ["Ask each time", "Selected Prompt Manager node", "Create a Prompt Manager (+/-) node"])}${setting("renderer", "Host compatibility", "Use the shared compatibility layer for Classic and Nodes 2.0.", "select", ["Follow host renderer", "Classic", "Nodes 2.0"])}${setting("return-canvas", "Return to canvas after send", "Apply text without queuing generation.")}

LoRA & resources

Preserves LoraManager detection, data import and positive-only trigger injection.

${setting("lora-enable", "LoraManager integration", "Sample detection: available. Production would detect the installed extension.")}${setting("lora-triggers", "Automatically include trigger words", "Trigger words apply only to positive text; connected node inputs remain authoritative.")}${setting("civitai-key", "CivitAI API key", "Credentials stay in the server configuration. Do not enter a real key into this mock.", "password", [""])}${operation("LoRA reference data", "Review and import resource metadata.", "job", "Simulate import", 'data-job="LoRA reference import"')}
`; + break; + case "tagging": + body = `

Auto-tagging

One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.

${setting("tag-model", "Tagging model", "Preserve all current engines; show download size and loaded status together.", "select", ["WD14 SwinV2 · ~400 MB", "WD14 ViT · ~400 MB", "Ollama · local model", "HuggingFace · ~16 GB"])}${setting("tag-existing", "Existing tags", "Skipping remains the default; replace and merge are explicit.", "select", ["Skip already tagged", "Merge with existing", "Replace after review"])}${setting("threshold-general", "General-tag confidence", "Current WD14 default: 0.35.", "number", [0.35])}${setting("threshold-character", "Character-tag confidence", "Current WD14 default: 0.85.", "number", [0.85])}
${button("Preview tagging review", "tag-review", "", "ca-primary")}${button("Simulate download", "job", 'data-job="Tagging model download"')}${button("Simulate unload", "job", 'data-job="Unload tagging model"')}
`; + break; + case "maintenance": + body = `

Check first. Review changes. Then apply.

One home for database and media maintenance. Scanning is separate from changing or removing records.

${button("Run health checks", "health-check", "", "ca-primary")}

Database

${operation("Integrity & relationship checks", "Validate foreign keys, text hashes, pair uniqueness and image associations.", "health-check", "Check")}${operation("Duplicate prompt text", "Review canonical text reuse and dependency-safe merging.", "maintenance-plan", "Review plan", 'data-plan="duplicate prompts"')}${operation("Optimize database", "Reclaim unused SQLite space. This does not delete prompts or media.", "maintenance-plan", "Review plan", 'data-plan="database optimization"')}${operation("Unused prompt components", "Preview unreferenced components. Pair references and history remain protected.", "maintenance-plan", "Review plan", 'data-plan="unused prompt components"', true)}

Images & cache

${operation("Missing-file links", "Remove stale database links only. Files on disk are never deleted by this operation.", "maintenance-plan", "Review plan", 'data-plan="missing-file links"', true)}${operation("Duplicate image files", "Find matching file content, review groups and choose what to keep. Separate from duplicate prompt text.", "duplicates", "Find duplicates")}${operation("Thumbnail cache", "Generate missing thumbnails or explicitly rebuild all.", "thumbnail-review", "Review options")}${operation("Metadata cache", "Clear only Prompt Manager metadata cached by this browser.", "maintenance-plan", "Review plan", 'data-plan="browser metadata cache"')}
`; + break; + case "backups": + body = `

Database backup

A complete recovery snapshot, including prompt pairs, metadata and image links. Media files are separate.

${button("Simulate backup", "backup", "", "ca-primary")}${button("Review restore…", "restore", "", "danger")}

Sample backup · today 09:30 · verified

Portable export

Export reusable recipes and tags as versioned JSON. This is distinct from a complete database backup.

${button("Export sample JSON", "export-library")}${button("Import / inspect file", "import")}

Recovery policy Proposed

${setting("backup-before", "Create a safety backup before maintenance", "Preview a verified backup before applying operations that change records.")}${setting("backup-schedule", "Scheduled database backups", "Production feature proposal; backup storage must be configured first.", "select", ["Manual only", "Daily", "Weekly"])}

A restore would be staged, migrated and validated before replacing live data.

`; + break; + case "diagnostics": + body = `

Diagnostics

${button("Run checks", "health-check")}
Database
Healthy · sample status
Sources
2 configured · sample status
Viewer.js
1.15.2 · pinned vendor
Host renderers
Classic / Nodes 2.0
${button("Download sample diagnostic report", "diagnostic-export")}${operation("Image-link diagnostics", "Check a file-to-prompt association without changing it.", "metadata-demo", "Inspect sample")}

Logs

${button("Refresh", "refresh-logs")}${button("Download sample log", "download-logs")}
09:42:18 INFO  Scan completed: 12 enriched, 0 duplicate links\n09:42:18 WARNING 3 images have ambiguous prompt metadata\n09:39:04 INFO  Database backup verified\n09:38:52 INFO  Source monitor started: output
${setting("runtime-level", "Runtime log level", "One control replaces a separate gallery debug toggle.", "select", ["INFO", "WARNING", "DEBUG", "ERROR"])}${setting("console-log", "Console logging", "Keep routine browser and server output quiet unless requested.")}
Log files & retention

prompt_manager.log · 1.4 MB · rotated copies: 3

${button("Review log cleanup", "maintenance-plan", 'data-plan="rotated logs"')}
`; + break; + } + $("main").innerHTML = + heading( + "System", + "Configuration, maintenance and recovery—with browsing kept in the workspace.", + 'Sample configuration', + ) + + sectionNav(systemSections, state.system, "system-tab") + + `
${body}
${state.settingsDirty ? "Unsaved changes in this prototype" : "Changes apply to this browser tab only."}${button("Save preferences", "save-settings", "", "ca-primary")}
`; + hydrateSwitches(); +} +function hydrateSwitches() { + for (const previous of document.querySelectorAll( + 'input[data-setting][type="checkbox"]', + )) { + const field = createSwitch("", "", previous.id); + const control = field.querySelector("input"); + control.checked = previous.checked; + control.dataset.setting = previous.dataset.setting; + previous.replaceWith(field); + } +} +function renderTags() { + const tags = [...new Set(prompts.flatMap((p) => p.tags))]; + $("main").innerHTML = + heading( + "Tags & categories", + "Organize once. Reuse everywhere.", + button("+ Create tag", "new-tag") + + button("Auto-tag images", "tag-review", "", "ca-primary"), + ) + + `

Tag vocabulary

Category/tag autocomplete uses this same vocabulary across nodes and dialogs.

${tags.map((t) => ``).join("")}
Reusable tags
TagPromptsActions
${esc(t)}${prompts.filter((p) => p.tags.includes(t)).length}
${button("Browse", "tag-filter", `data-value="${t}"`)}${button("Rename / merge", "rename-tag", `data-value="${t}"`)}

Categories

Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.

${[...new Set(prompts.map((p) => p.category))].map((c) => operation(c, `${prompts.filter((p) => p.category === c).length} positive prompts`, "category-filter", "Browse", `data-value="${c}"`)).join("")}${operation("Manage category", "Rename or consolidate a category with a preview of affected recipes.", "manage-category", "Review")}
`; +} +function renderActivity() { + nav(); + $("main").innerHTML = + heading( + "Activity", + "Scans, tagging, downloads and maintenance share one history.", + 'Unified activity · proposed', + ) + + `

Recent work

${state.activity.map((a, i) => `

${a.done ? "✓" : "○"} ${esc(a.title)}

${a.done ? "Completed" : "Needs attention"}

${esc(a.detail)}

${a.running ? `65%
${button("Simulate completion", "complete-job", `data-index="${i}"`)}${button("Cancel job", "cancel-job", `data-index="${i}"`)}
` : !a.done ? button("Review metadata", "go-review") : ""}
`).join("")}
`; +} +function renderMetadata() { + $("main").innerHTML = + heading( + "Inspect a file", + "A standalone file uses the same image viewer and prompt actions.", + button("Open sample metadata", "metadata-demo", "", "ca-primary"), + ) + + `

Inspect without importing

Keep the existing local-file inspection workflow. Only add the file to your library when you choose to import it.

${icon("upload")}This mock previews your file locally. Sample metadata is available with “Open sample metadata”.
`; +} +function renderReview() { + $("main").innerHTML = + heading( + "Metadata review", + "Uncertain evidence stays visible until you decide.", + button("Open activity", "go-activity"), + ) + + `

3 images need a decision

Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.

Sample review queue
`; +} +const featureMap = [ + [ + "Admin prompt cards / per-prompt gallery", + "Prompt library + one prompt history workspace", + "Retained, rebuilt", + ], + [ + "Standalone image gallery / Admin image modal", + "One Images workspace + shared Viewer.js viewer", + "Consolidated", + ], + [ + "Video playback, autoplay / mute / loop, viewer slideshow and transforms", + "Shared media viewer; playback controls in Appearance; same image navigation", + "Retained", + ], + [ + "Metadata page / viewer sidebar / full-text popups", + "One reusable metadata inspector; inline complete prompt text", + "Consolidated", + ], + [ + "Prompt add, edit, rating, notes, copy, delete", + "Recipe editor, shared details, contextual bulk actions; reference-safe archive", + "Retained, clarified", + ], + [ + "Categories, tag rename / merge, autocomplete", + "Tags & categories; same vocabulary in every editor", + "Retained", + ], + [ + "Grid/list, thumbnail size, columns, pages/infinite scroll", + "One Appearance configuration; contextual layout / density overrides", + "Simplified", + ], + [ + "Scan, rescan, monitoring directories, worker threads", + "Sources & scans; explicit metadata enrichment vs thumbnail work", + "Consolidated", + ], + [ + "Ollama, HuggingFace, WD14 variants, thresholds, review", + "One Auto-tagging configuration and review flow", + "Retained", + ], + [ + "Thumbnail quality, size limit, metadata cache, paths", + "Appearance + cache controls in Maintenance", + "Retained", + ], + [ + "Prompt dedup, hash checks, consistency, unused prompts", + "Maintenance plan with dependency checks and impact preview", + "Retained, safer flow", + ], + [ + "Image dedup, orphan image links, browser cache", + "Separate reviewed operations in Maintenance", + "Retained", + ], + [ + "Database backup / restore, JSON export", + "Backup & export; recovery and portable recipes clearly distinguished", + "Retained", + ], + [ + "Diagnostics, image-link checks, logs, rotation, log levels", + "System → Diagnostics & logs", + "Retained", + ], + [ + "LoRA detection/import, trigger injection, CivitAI key", + "System → Integrations; positive-only triggers", + "Retained", + ], + [ + "Statistics / counts", + "System overview + usage breakdown; context counts in the workspace", + "Retained", + ], + [ + "Send prompts to ComfyUI from every prompt surface", + "Role-aware paper-plane action + destination / overwrite preview", + "New proposal", + ], + [ + "Reusable negatives / approved branch timeline", + "Prompt history with selected negative directly beneath positive", + "Approved concept", + ], + [ + "Favorites, saved searches, project collections", + "Sidebar views; collections do not duplicate recipe text", + "New proposal", + ], + [ + "Ambiguous metadata review, source provenance, run comparisons", + "Needs review queue; recorded vs inferred evidence; pair/run selection", + "New / expanded", + ], + [ + "Unified jobs, cancellation, resumable progress visibility", + "Activity center; cancellation distinguished from hiding progress", + "New proposal", + ], + [ + "Scheduled backups, pre-change safety snapshots", + "Explicit recovery policy after storage configuration", + "New proposal", + ], +]; +function renderDesign() { + $("main").innerHTML = + heading( + "A workspace for the whole prompt lifecycle", + "Design review · current functionality mapped to its proposed home", + ) + + `

Discover an image. Understand its recipe. Compare an experiment. Send exactly the text you want back to ComfyUI. The same controls, vocabulary and inspector follow that journey.

This is an interactive proposal, not a production migration. All data is illustrative. Existing backend routes, node schemas and vendored libraries remain unchanged. The approved timeline is preserved alongside this new concept.

A consistent send action

${icon("send")}${button(icon("send") + " Send positive", "send", 'data-prompt="alpine" data-role="positive"', "send")}${sendButton(prompts[0], "negative", 0)}

One original, simple paper-plane outline in ComfyUI blue. A role-labelled action on every displayed positive or negative, plus a pair action. The picker previews the target node and changed fields. Sending does not queue a generation or load an entire workflow.

What deserves adding

Saved searches, project collections, metadata review, an activity center and explicit recovery plans. A favorites toggle is separate from the existing 1–5 quality rating. These are proposals to evaluate, not promised features.

${featureMap.map((row) => `${row.map((text) => ``).join("")}`).join("")}
Feature inventory & redesign map
TodayProposed home / behaviorDisposition
${text}

Implementation boundaries

Keep Viewer.js 1.15.2 and the shared ComfyAssets toolkit as pinned artifacts. This mock runs the actual vendored viewer. Production work should reuse controllers and services, replace overlapping UI shells, and preserve the existing test matrix.

New services would be needed for persisted execution history, saved searches/collections, job history and the ComfyUI send bridge. The bridge must detect disconnected tabs, reject incompatible or linked target inputs, and never guess a positive/negative destination. Unknown metadata remains unknown; an explicitly empty negative is a deliberate value.

The maintained node layouts are out of scope for this application rebuild. Each implementation slice needs regressions first, page workflows, and relevant Classic / Nodes 2.0 host tests. No test or coverage gate is removed.

`; +} +function openSend(p, role = "pair", variant = 0) { + clearInterval(slideTimer); + slideTimer = null; + sendPayload = { p, role, variant }; + dialog( + "Send to ComfyUI", + `

Prototype: destinations are illustrative. Nothing will be sent to your running ComfyUI.

${role === "pair" ? "Both prompt fields will be replaced together." : `Only ${role} text will change. The other prompt stays untouched.`} Connected inputs would be checked before applying.

${role !== "negative" ? `

+ Positive · replace

${esc(p.positive)}

` : ""}${role !== "positive" ? `

− Negative · ${p.negatives[variant].text ? "replace" : "explicitly clear"}

${esc(p.negatives[variant].text || "No negative prompt (explicitly empty)")}

` : ""}

Text transfer only · does not queue a run · does not replace your workflow

${button("Cancel", "close-dialog")}${button(icon("send") + " Simulate send", "send-confirm", "", "ca-primary")}
`, + ); +} +function openViewer(id) { + viewImage = image(id); + state.selectedImage = id; + const p = prompt(viewImage.prompt); + $("viewer-title").textContent = `${p.title} · ${viewImage.filename}`; + $("viewer-details").innerHTML = imageDetails(viewImage, true); + viewer?.destroy(); + viewer = null; + $("viewer-stage").replaceChildren(); + const img = document.createElement("img"); + img.src = art(p, viewImage.index); + img.alt = `${p.title}, illustrative generated image`; + $("viewer-stage").append(img); + if (!$("viewer-dialog").open) $("viewer-dialog").showModal(); + $("viewer-toolbar").innerHTML = + button("← Previous", "viewer-prev") + + button("−", "viewer-zoom-out", 'aria-label="Zoom out"') + + button("+", "viewer-zoom-in", 'aria-label="Zoom in"') + + button("Fit", "viewer-fit") + + button("Rotate", "viewer-rotate") + + button("Flip", "viewer-flip") + + button(slideTimer ? "Pause" : "Play", "viewer-play") + + button("Download", "viewer-download") + + button("Next →", "viewer-next"); + requestAnimationFrame(() => { + viewer = new window.Viewer(img, { + inline: true, + navbar: false, + title: false, + toolbar: false, + button: false, + keyboard: false, + transition: false, + fullscreen: false, + initialViewIndex: 0, + }); + }); +} +function exportSample(name, data, type = "application/json") { + const blob = new Blob( + [typeof data === "string" ? data : JSON.stringify(data, null, 2)], + { type }, + ); + const url = URL.createObjectURL(blob), + a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + notify("Downloaded sample data only."); +} +function startJob(name) { + closeDialog(); + state.activity.unshift({ + title: name, + detail: + "Simulated background job · 65% · closing this view does not cancel it", + done: false, + running: true, + }); + location.hash = "activity"; + if (state.page === "activity") renderActivity(); + notify("Sample job added to Activity."); +} +function maintenancePlan(name) { + dialog( + "Review maintenance plan", + `

Sample impact preview. No database, media or cache will be modified.

${esc(name)}

Scope
${name === "missing-file links" ? "2 stale image links" : "Selected operation only"}
Protected
Referenced components and run history
Backup
Create and verify before changes
Media on disk
No source files removed

Production should calculate impact before enabling Apply. Cancelling here would leave all data untouched.

${button("Cancel", "close-dialog")}${button("Simulate reviewed operation", "job", `data-job="${esc(name)}"`, "ca-primary")}
`, + ); +} +function editor(p = null, negativeOnly = false, revision = false) { + dialog( + negativeOnly + ? "Add a negative variant" + : revision + ? "Create text revision" + : p + ? "Edit recipe metadata" + : "New prompt", + `

Changes stay in the prototype. Existing recipe text would be immutable while referenced; text edits create a new recipe.

${!negativeOnly ? `
` : ""}
${!negativeOnly ? `
${[...new Set(prompts.map((item) => item.category))].map((c) => ``).join("")}${[...new Set(prompts.flatMap((item) => item.tags))].map((c) => ``).join("")}
` : '

Leave empty for an explicitly empty negative. The positive stays unchanged.

'}
${p && !negativeOnly && !revision ? button("Create text revision", "revise-prompt", `data-prompt="${p.id}"`) + button("Archive…", "archive-prompt", `data-prompt="${p.id}"`, "danger") : ""}${button("Cancel", "close-dialog")}
`, + ); + $("recipe-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (negativeOnly) { + const text = $("recipe-negative").value.trim(); + const existing = p.negatives.findIndex( + (n) => n.text.trim().toLowerCase() === text.toLowerCase(), + ); + if (existing < 0) p.negatives.push({ name: "New experiment", text }); + state.variant = existing < 0 ? p.negatives.length - 1 : existing; + closeDialog(); + renderHistory(); + notify("Negative saved without adding another positive or gallery."); + return; + } + if (p && !revision) { + p.title = $("recipe-title").value; + p.category = $("recipe-category").value; + p.tags = $("recipe-tags") + .value.split(",") + .map((t) => t.trim()) + .filter(Boolean); + p.rating = Number($("recipe-rating").value); + p.notes = $("recipe-notes").value; + } else { + p = { + id: `new-${prompts.length}`, + title: $("recipe-title").value, + positive: $("recipe-positive").value, + category: $("recipe-category").value || "Uncategorized", + tags: $("recipe-tags") + .value.split(",") + .map((t) => t.trim()) + .filter(Boolean), + rating: Number($("recipe-rating").value), + notes: $("recipe-notes").value, + negatives: [ + { name: "Initial negative", text: $("recipe-negative").value }, + ], + scene: "alpine", + color: prompts[0].color, + }; + prompts.push(p); + } + closeDialog(); + nav(); + location.hash = `prompt/${p.id}`; + if (state.page === "prompt") { + state.prompt = p.id; + renderHistory(); + } + notify("Saved in this prototype tab."); + }); +} +function tagReview() { + dialog( + "Review suggested tags", + `

Illustrative suggestions · no tagging model is running.

Review before merging with the existing vocabulary.

Sample alpine observatory
${[ + ["mountain", 0.98], + ["architecture", 0.93], + ["blue_hour", 0.87], + ["snow", 0.81], + ] + .map( + ([t, c]) => + ``, + ) + .join( + "", + )}
${button("Skip image", "skip-tag-review")}${button("Apply selected sample tags", "apply-tags", "", "ca-primary")}
`, + ); +} +function simpleInput(title, label, action, value = "") { + dialog( + title, + `
${button("Cancel", "close-dialog")}${button("Apply in prototype", action, "", "ca-primary")}
`, + ); +} +function navigate(event) { + const hash = decodeURIComponent(location.hash.slice(1) || "images"); + state.page = hash.split("/")[0]; + if (hash.startsWith("prompt/")) { + state.prompt = hash.split("/")[1]; + state.variant = 0; + } + if (state.page === "favorites") { + state.filter = "favorites"; + state.collection = ""; + } else if (state.page.startsWith("collection-")) { + state.collection = + state.page === "collection-landscapes" ? "Landscapes" : "Experiments"; + state.filter = "all"; + } else if (state.page.startsWith("saved-")) { + const saved = state.saved[Number(state.page.split("-")[1])]; + state.query = saved?.query || ""; + state.category = saved?.category || ""; + state.filter = saved?.filter || "all"; + } else if (state.page === "images") { + state.filter = "all"; + state.collection = ""; + } + render(); + if (event) $("page-title")?.focus(); +} +function render() { + nav(); + const page = state.page; + $("breadcrumb").textContent = + page === "prompt" + ? "Prompt history" + : page === "system" + ? "System" + : page === "design" + ? "Design review" + : page === "library" + ? "Prompt library" + : page === "metadata" + ? "Inspect a file" + : page.startsWith("collection-") + ? "Collection" + : page[0].toUpperCase() + page.slice(1); + document.title = `${$("breadcrumb").textContent} · Prompt Manager concept`; + if (page === "library") renderLibrary(); + else if (page === "prompt") renderHistory(); + else if (page === "system") renderSystem(); + else if (page === "tags") renderTags(); + else if (page === "activity") renderActivity(); + else if (page === "metadata") renderMetadata(); + else if (page === "review") renderReview(); + else if (page === "design") renderDesign(); + else renderImages(); +} +const actions = { + "close-dialog": closeDialog, + send: (b) => + openSend( + prompt(b.dataset.prompt), + b.dataset.role || "pair", + Number(b.dataset.variant || 0), + ), + "send-confirm": () => { + const { p, role } = sendPayload; + const destination = $("send-target").selectedOptions[0].textContent; + closeDialog(); + notify( + `Demo: ${role === "pair" ? "positive and negative" : role + " prompt"} would be sent to ${destination}. No run queued.`, + ); + state.activity.unshift({ + title: "Sample prompt handoff", + detail: `${p.title} · ${role} · no actual ComfyUI changes`, + done: true, + }); + nav(); + }, + copy: async (b) => { + const p = prompt(b.dataset.prompt), + text = + b.dataset.role === "negative" + ? p.negatives[Number(b.dataset.variant || 0)].text + : p.positive; + try { + if (!navigator.clipboard) + throw new Error("Clipboard requires a secure context"); + await navigator.clipboard.writeText(text); + notify("Prompt copied."); + } catch { + dialog( + "Copy prompt", + `

Automatic clipboard access requires a secure browser context.

`, + ); + $("copy-text").select(); + } + }, + "open-image": (b) => openViewer(b.dataset.id), + "metadata-demo": () => openViewer(images[0].id), + inspect: (b) => { + state.selectedImage = b.dataset.id; + updateImages(); + }, + favorite: (b) => { + const i = image(b.dataset.id); + i.starred = !i.starred; + nav(); + if ( + ["images", "favorites"].includes(state.page) || + state.page.startsWith("collection-") + ) + updateImages(); + else render(); + notify( + i.starred ? "Added to favorites in this tab." : "Removed from favorites.", + ); + }, + "image-view": (b) => { + state.view = b.dataset.value; + renderImages(); + }, + history: (b) => { + go(`prompt/${b.dataset.prompt}`); + }, + variant: (b) => { + state.variant = Number(b.dataset.index); + renderHistory(); + }, + "compare-negatives": () => { + state.compare = !state.compare; + renderHistory(); + }, + "history-all": () => { + dialog( + "All images for this positive", + `
${images + .filter((i) => i.prompt === state.prompt) + .map(mediaCard) + .join("")}
`, + ); + }, + "run-filter": (b) => { + dialog( + "Repeat run", + `

Same positive and negative, another execution.

${images + .filter( + (i) => + i.prompt === b.dataset.prompt && + i.variant === Number(b.dataset.variant), + ) + .slice(-1) + .map(mediaCard) + .join("")}
`, + ); + }, + "new-prompt": () => editor(), + "new-negative": (b) => editor(prompt(b.dataset.prompt), true), + "edit-prompt": (b) => editor(prompt(b.dataset.prompt)), + "revise-prompt": (b) => editor(prompt(b.dataset.prompt), false, true), + "archive-prompt": (b) => + maintenancePlan("archive " + prompt(b.dataset.prompt).title), + "go-library": () => go("library"), + "go-images": () => go("images"), + "go-activity": () => go("activity"), + "go-review": () => go("review"), + "system-tab": (b) => { + state.system = b.dataset.value; + if (state.page !== "system") location.hash = "system"; + else renderSystem(); + }, + "save-settings": () => { + state.settingsDirty = false; + $("settings-status").textContent = + "Preferences saved for this prototype tab."; + notify("Prototype preferences saved. Server configuration unchanged."); + }, + "dismiss-notice": () => { + state.notice = false; + renderSystem(); + }, + "save-view": () => + simpleInput( + "Save this view", + "View name", + "save-view-confirm", + state.query ? `Search: ${state.query}` : "My workspace view", + ), + "save-view-confirm": () => { + state.saved.push({ + name: $("action-input").value || "Saved view", + query: state.query, + category: state.category, + filter: state.filter, + }); + closeDialog(); + nav(); + notify("Sample saved view added to the sidebar."); + }, + filters: () => { + dialog( + "Refine this view", + `

Tags are interactive in this mock. Folder and evidence options show the proposed filter design.

${button("Clear filters", "clear-filters")}${button("Apply tag filter", "apply-filters", "", "ca-primary")}
`, + ); + }, + "apply-filters": () => { + state.query = $("filter-tags").value; + closeDialog(); + render(); + }, + "clear-filters": () => { + state.query = ""; + state.category = ""; + closeDialog(); + render(); + }, + "category-filter": (b) => { + state.category = b.dataset.value; + go("library"); + }, + "tag-filter": (b) => { + state.query = b.dataset.value; + if (state.page === "images" && !$("viewer-dialog").open) renderImages(); + else go("library"); + }, + "bulk-clear": () => { + state.selection.clear(); + updateImages(); + }, + "bulk-tags": () => + simpleInput( + "Tag selected images", + "Tags, separated by commas", + "bulk-apply", + "cinematic, concept", + ), + "bulk-category": () => + simpleInput( + "Categorize selected images", + "Category", + "bulk-apply", + "Landscapes", + ), + "bulk-collection": () => + simpleInput( + "Add selected images to collection", + "Collection name", + "bulk-apply", + "Experiments", + ), + "bulk-apply": () => { + const value = $("action-input").value; + closeDialog(); + notify( + `Demo: ${state.selection.size} selected images would be updated with “${value}”.`, + ); + }, + "bulk-remove": () => maintenancePlan("selected image links"), + "bulk-export": () => + exportSample( + "selected-sample-images.json", + images.filter((i) => state.selection.has(i.id)), + ), + job: (b) => startJob(b.dataset.job || "Background operation"), + "complete-job": (b) => { + const a = state.activity[Number(b.dataset.index)]; + a.running = false; + a.done = true; + a.detail = "Simulated completion · no production state changed"; + renderActivity(); + }, + "cancel-job": (b) => { + const a = state.activity[Number(b.dataset.index)]; + a.running = false; + a.done = true; + a.detail = + "Cancelled at a sample file boundary · completed work would be preserved"; + renderActivity(); + }, + "scan-review": () => + dialog( + "Review metadata scan", + `

Sample scan plan · nothing is scanned until the production action is started.

Sources
2 configured folders
Scope
New and unresolved files

Enrich positive / negative associations, reuse canonical text and report unchanged files. Do not inflate run counts or recreate existing thumbnails.

${button("Cancel", "close-dialog")}${button("Simulate scan", "job", 'data-job="Metadata enrichment scan"', "ca-primary")}
`, + ), + "thumbnail-review": () => + dialog( + "Thumbnail maintenance", + `
${button("Cancel", "close-dialog")}${button("Simulate thumbnail job", "job", 'data-job="Thumbnail generation"', "ca-primary")}
`, + ), + "maintenance-plan": (b) => maintenancePlan(b.dataset.plan), + "health-check": () => + dialog( + "Sample health report", + `

Illustrative check results.

${[ + ["Integrity check", "Passed"], + ["Foreign keys & pair references", "Passed"], + ["Canonical hashes", "Passed"], + ["Missing files", "2 links need review"], + ["Ambiguous metadata", "3 images need review"], + ] + .map( + ([k, v]) => + `

${k}

${v}
`, + ) + .join( + "", + )}
${button("Close", "close-dialog")}${button("Review missing links", "maintenance-plan", 'data-plan="missing-file links"', "ca-primary")}
`, + ), + duplicates: () => + dialog( + "Review duplicate image files", + `

Sample duplicate group · matching content, separate file locations.

${[0, 1].map((n) => `
Sample duplicate ${n + 1}
`).join("")}

Production should preview file and database-link consequences before enabling deletion. This prototype changes neither.

${button("Cancel", "close-dialog")}${button("Review duplicate cleanup", "maintenance-plan", 'data-plan="duplicate image groups"')}
`, + ), + backup: () => { + state.activity.unshift({ + title: "Sample database backup", + detail: + "Verified recovery snapshot · demonstration only, no real database downloaded", + done: true, + }); + nav(); + notify("Sample backup completed. Your real database was not accessed."); + }, + restore: () => + dialog( + "Restore preview", + `

Production restore must stage, migrate and verify the uploaded database, then back up current data before replacement.

Sample snapshot
Today · 09:30
Integrity
Verified
Migration
Compatible pair schema
Safety backup
Required before applying
${button("Cancel", "close-dialog")}${button("Simulate restore", "job", 'data-job="Staged database restore"', "danger")}
`, + ), + "export-library": () => + exportSample("prompt-manager-sample-recipes.json", { + version: 1, + sample: true, + prompts, + }), + import: () => + dialog( + "Import & inspect", + `

Image inspection, output scanning and recipe import have different effects. Choose the workflow you want.

${operation("Inspect a local image", "Read embedded metadata before deciding to add it.", "inspect-file", "Open inspector")}${operation("Scan an output folder", "Discover and enrich existing files using the configured sources.", "scan-review", "Review scan")}${operation("Import portable recipes", "Proposed: preview conflicts and reuse existing text before applying.", "sample-import", "Preview sample")}
`, + ), + "inspect-file": () => { + closeDialog(); + location.hash = "metadata"; + }, + "sample-import": () => + dialog( + "Recipe import preview · proposed", + `

Sample JSON import proposal. Current production JSON export does not yet imply JSON import.

New positives
2
Reused text
4 components
New negatives
3
Metadata conflicts
1 · review required

Existing metadata wins by default. Import should be transactional and repeatable.

${button("Close preview", "close-dialog")}
`, + ), + "add-source": () => + simpleInput( + "Add image source", + "Folder path (sample only)", + "source-confirm", + "/images/new-collection", + ), + "source-confirm": () => { + const path = $("action-input").value; + closeDialog(); + notify( + `Demo source “${path}” reviewed. No filesystem access was requested.`, + ); + }, + "tag-review": tagReview, + "skip-tag-review": () => { + closeDialog(); + notify("Sample suggestions skipped."); + }, + "apply-tags": () => { + const selected = [ + ...document.querySelectorAll("[data-tag-suggestion]:checked"), + ].map((i) => i.dataset.tagSuggestion); + prompts[0].tags = [...new Set([...prompts[0].tags, ...selected])]; + closeDialog(); + notify(`${selected.length} sample tags merged in this tab.`); + if (state.page === "tags") renderTags(); + }, + "new-tag": () => simpleInput("Create tag", "Tag name", "new-tag-confirm"), + "new-tag-confirm": () => { + const tag = $("action-input").value.trim(); + if (tag) prompts[0].tags.push(tag); + closeDialog(); + renderTags(); + notify("Sample vocabulary updated."); + }, + "rename-tag": (b) => { + state.renameTag = b.dataset.value; + simpleInput( + "Rename or merge tag", + "New name; existing names merge", + "rename-tag-confirm", + b.dataset.value, + ); + }, + "rename-tag-confirm": () => { + const tag = $("action-input").value.trim(); + if (!tag) return; + prompts.forEach( + (p) => + (p.tags = [ + ...new Set(p.tags.map((t) => (t === state.renameTag ? tag : t))), + ]), + ); + closeDialog(); + renderTags(); + notify("Sample tag renamed or merged without duplicate links."); + }, + "manage-category": () => + simpleInput( + "Manage categories", + "Proposed new category name", + "category-confirm", + ), + "category-confirm": () => { + closeDialog(); + notify("Category management plan previewed; no stored categories changed."); + }, + "resolve-review": () => { + notify( + "Sample association accepted. Production would save an explicit, auditable link.", + ); + location.hash = "images"; + }, + stats: () => + dialog( + "Usage statistics", + `

Sample usage breakdown. Real history would use persisted run records.

${[...new Set(prompts.map((p) => p.category))].map((c) => ``).join("")}
Runs by category
CategoryRunsAvg. rating
${c}${prompts.filter((p) => p.category === c).length * 4}4.5 / 5
`, + ), + "diagnostic-export": () => + exportSample("sample-diagnostics.json", { + sample: true, + database: "healthy", + viewer: "1.15.2", + sources: 2, + }), + "refresh-logs": () => notify("Sample logs refreshed."), + "download-logs": () => + exportSample( + "sample-prompt-manager.log", + $("log-lines").textContent, + "text/plain", + ), + "download-workflow": (b) => { + const i = image(b.dataset.id), + p = prompt(i.prompt); + exportSample("sample-workflow.json", { + sample: true, + seed: i.seed, + positive: p.positive, + negative: p.negatives[i.variant].text, + }); + }, + "viewer-prev": () => + openViewer( + images[(images.indexOf(viewImage) + images.length - 1) % images.length] + .id, + ), + "viewer-next": () => + openViewer(images[(images.indexOf(viewImage) + 1) % images.length].id), + "viewer-zoom-in": () => viewer?.zoom(0.2), + "viewer-zoom-out": () => viewer?.zoom(-0.2), + "viewer-fit": () => viewer?.reset(), + "viewer-rotate": () => viewer?.rotate(90), + "viewer-flip": () => viewer?.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1), + "viewer-play": (b) => { + if (slideTimer) { + clearInterval(slideTimer); + slideTimer = null; + b.textContent = "Play"; + } else { + slideTimer = setInterval(() => actions["viewer-next"](), 3500); + b.textContent = "Pause"; + } + }, + "viewer-download": () => + exportSample( + "sample-illustration.svg", + decodeURIComponent( + art(prompt(viewImage.prompt), viewImage.index).split(",")[1], + ), + "image/svg+xml", + ), +}; +scope.listen(document, "click", (event) => { + const b = event.target.closest("[data-action]"); + if (b) actions[b.dataset.action]?.(b); +}); +scope.listen(document, "input", (event) => { + if (event.target.id === "search") { + state.query = event.target.value; + if ( + ["images", "favorites"].includes(state.page) || + state.page.startsWith("collection-") || + state.page.startsWith("saved-") + ) + updateImages(); + else if (state.page === "library") { + const caret = event.target.selectionStart; + renderLibrary(); + $("search").focus(); + $("search").setSelectionRange(caret, caret); + } + } +}); +scope.listen(document, "change", (event) => { + const target = event.target; + if (target.id === "category-filter") { + state.category = target.value; + if (state.page === "library") renderLibrary(); + else updateImages(); + } + if (target.id === "image-size") { + state.size = target.value; + updateImages(); + } + if (target.id === "sort") { + state.sort = target.value; + if (state.page === "library") renderLibrary(); + else updateImages(); + } + if (target.dataset.select) { + target.checked + ? state.selection.add(target.dataset.select) + : state.selection.delete(target.dataset.select); + updateBulk(); + } + if (target.dataset.setting) { + config[target.dataset.setting] = + target.type === "checkbox" ? target.checked : target.value; + state.settingsDirty = true; + $("settings-status").textContent = "Unsaved changes in this prototype"; + } + if (target.id === "log-level") + $("log-lines").textContent = + target.value === "WARNING" + ? "09:42:18 WARNING 3 images have ambiguous prompt metadata" + : "09:42:18 INFO Scan completed: 12 enriched, 0 duplicates\n09:42:18 WARNING 3 images have ambiguous prompt metadata"; + if (target.id === "local-file") { + const file = target.files[0]; + if (!file) return; + const url = URL.createObjectURL(file), + img = document.createElement("img"); + img.src = url; + img.alt = "Local file preview"; + img.className = "local-file-preview"; + img.addEventListener("load", () => URL.revokeObjectURL(url), { + once: true, + }); + $("local-preview").replaceChildren(img); + notify( + "Local preview only. No file uploaded; sample metadata is available above.", + ); + } +}); +scope.listen($("modal-close"), "click", closeDialog); +scope.listen($("viewer-close"), "click", () => $("viewer-dialog").close()); +scope.listen($("viewer-dialog"), "close", () => { + clearInterval(slideTimer); + slideTimer = null; + viewer?.destroy(); + viewer = null; + if ($("image-inspector")) updateImages(); +}); +scope.listen($("activity-open"), "click", () => { + location.hash = "activity"; +}); +scope.listen($("review-link"), "click", () => { + location.hash = "design"; +}); +scope.listen(document.querySelector(".canvas-link"), "click", (event) => { + event.preventDefault(); + openSend( + prompt(image(state.selectedImage).prompt), + "pair", + image(state.selectedImage).variant, + ); +}); +function setTheme() { + const dark = + $("theme").value === "system" + ? matchMedia("(prefers-color-scheme: dark)").matches + : $("theme").value === "dark"; + document.body.style.setProperty("--bg-color", dark ? "#191a1d" : "#f3f4f6"); +} +scope.listen($("theme"), "change", setTheme); +scope.listen(matchMedia("(prefers-color-scheme: dark)"), "change", setTheme); +scope.listen(window, "hashchange", navigate); +scope.listen(window, "pagehide", () => { + viewer?.destroy(); + clearInterval(slideTimer); + clearTimeout(toastTimer); + scope.dispose(); +}); +setTheme(); +navigate(); diff --git a/docs/concepts/workspace/index.html b/docs/concepts/workspace/index.html new file mode 100644 index 0000000..f9ed018 --- /dev/null +++ b/docs/concepts/workspace/index.html @@ -0,0 +1,105 @@ + + + + + + + Images · Prompt Manager concept + + + + + + + + +
+ +
+
+ +
+ Interactive conceptComfyUI +
+
+
+
+ Design prototype · sample images and data · actions stay in this + tab +
+
+
+
+ +
+ + +
+ +
+ +
+
+

Image details

+

+ One viewer · image, prompts, generation data, workflow +

+
+ +
+
+
+
+
+
+ +
+
+ + diff --git a/docs/concepts/workspace/screenshots/image-viewer.png b/docs/concepts/workspace/screenshots/image-viewer.png new file mode 100644 index 0000000..6966d04 Binary files /dev/null and b/docs/concepts/workspace/screenshots/image-viewer.png differ diff --git a/docs/concepts/workspace/screenshots/images-dark.png b/docs/concepts/workspace/screenshots/images-dark.png new file mode 100644 index 0000000..c47a2bd Binary files /dev/null and b/docs/concepts/workspace/screenshots/images-dark.png differ diff --git a/docs/concepts/workspace/screenshots/images-light.png b/docs/concepts/workspace/screenshots/images-light.png new file mode 100644 index 0000000..edb2e89 Binary files /dev/null and b/docs/concepts/workspace/screenshots/images-light.png differ diff --git a/docs/concepts/workspace/screenshots/maintenance.png b/docs/concepts/workspace/screenshots/maintenance.png new file mode 100644 index 0000000..55ebf38 Binary files /dev/null and b/docs/concepts/workspace/screenshots/maintenance.png differ diff --git a/docs/concepts/workspace/screenshots/mobile.png b/docs/concepts/workspace/screenshots/mobile.png new file mode 100644 index 0000000..ac393cc Binary files /dev/null and b/docs/concepts/workspace/screenshots/mobile.png differ diff --git a/docs/concepts/workspace/screenshots/prompt-history.png b/docs/concepts/workspace/screenshots/prompt-history.png new file mode 100644 index 0000000..d7259a5 Binary files /dev/null and b/docs/concepts/workspace/screenshots/prompt-history.png differ diff --git a/docs/concepts/workspace/screenshots/prompt-library.png b/docs/concepts/workspace/screenshots/prompt-library.png new file mode 100644 index 0000000..294b51e Binary files /dev/null and b/docs/concepts/workspace/screenshots/prompt-library.png differ diff --git a/docs/concepts/workspace/screenshots/send-negative.png b/docs/concepts/workspace/screenshots/send-negative.png new file mode 100644 index 0000000..d53da9e Binary files /dev/null and b/docs/concepts/workspace/screenshots/send-negative.png differ diff --git a/docs/concepts/workspace/screenshots/settings.png b/docs/concepts/workspace/screenshots/settings.png new file mode 100644 index 0000000..40e288b Binary files /dev/null and b/docs/concepts/workspace/screenshots/settings.png differ diff --git a/docs/concepts/workspace/style.css b/docs/concepts/workspace/style.css new file mode 100644 index 0000000..381d198 --- /dev/null +++ b/docs/concepts/workspace/style.css @@ -0,0 +1,1572 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + --bg-color: #191a1d; +} +body.ca-tools { + --page: #1b1c1f; + --sidebar: #202124; + --green: #73dda9; + --red: #ff8c98; + --warning: #e6bd78; + --soft-blue: #14364d; + --line: var(--ca-line); + background: var(--page); + font: + 14px/1.5 Inter, + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + color: var(--ca-text); +} +body[data-ca-theme="light"] { + --page: #f3f4f6; + --sidebar: #fff; + --green: #176b46; + --red: #b71931; + --warning: #815514; + --soft-blue: #e4f1fb; +} +button, +input, +select, +textarea, +a { + touch-action: manipulation; +} +a { + color: var(--ca-focus); + text-decoration: none; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--ca-focus); + outline-offset: 3px; +} +[hidden] { + display: none !important; +} +.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); +} +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 28px; + line-height: 1.3; + font-weight: 620; + letter-spacing: -0.7px; +} +h2 { + font-size: 17px; + font-weight: 600; +} +h3 { + font-size: 14px; + font-weight: 600; +} +p + p { + margin-top: 10px; +} +.muted, +.small { + color: var(--ca-muted); +} +.small { + font-size: 12px; +} +.eyebrow, +.sidebar-label { + font-size: 10px; + font-weight: 650; + letter-spacing: 1.1px; + color: var(--ca-muted); +} +.app-shell { + display: grid; + grid-template-columns: 218px minmax(0, 1fr); + min-height: 100dvh; +} +.sidebar { + background: var(--sidebar); + border-inline-end: 1px solid var(--line); + padding: 26px 14px 16px; + display: flex; + flex-direction: column; + gap: 5px; + position: sticky; + top: 0; + height: 100dvh; + overflow-y: auto; +} +.brand { + display: flex; + gap: 11px; + align-items: center; + color: var(--ca-text); + font-size: 14px; + font-weight: 650; + margin: 0 4px 28px; + line-height: 1.3; +} +.brand small { + display: block; + font-size: 9px; + letter-spacing: 1.8px; + color: var(--ca-muted); + margin-top: 6px; +} +.brand-mark { + width: 34px; + height: 36px; + color: var(--ca-focus); + font-size: 29px; + position: relative; +} +.brand-mark > span { + font-size: 15px; + position: absolute; + bottom: -2px; + right: -2px; +} +.sidebar-label { + margin: 14px 12px 6px; + font-size: 9px; + display: flex; + gap: 6px; + align-items: center; +} +.new-label { + font-size: 7px; + letter-spacing: 0.4px; + opacity: 0.7; +} +.sidebar nav { + display: grid; + gap: 3px; +} +.sidebar nav a { + display: flex; + align-items: center; + gap: 11px; + min-height: 38px; + padding: 8px 12px; + border-radius: 6px; + color: var(--ca-muted); + font-size: 12px; +} +.sidebar nav a:hover { + background: var(--ca-field); + color: var(--ca-text); +} +.sidebar nav a[aria-current="page"] { + color: var(--ca-focus); + background: var(--soft-blue); +} +.sidebar nav .nav-count { + margin-inline-start: auto; + color: var(--ca-muted); + font-size: 10px; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 32px; +} +.connection { + border-top: 1px solid var(--line); + margin: 17px 6px 0; + padding-top: 17px; + display: flex; + gap: 8px; + font-size: 10px; + align-items: flex-start; +} +.connection i { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); + margin-top: 5px; +} +.connection small { + display: block; + font-size: 8px; + color: var(--ca-muted); + margin-top: 4px; +} +.workspace { + min-width: 0; +} +.topbar { + height: 68px; + padding: 0 30px; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} +.breadcrumbs { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--ca-muted); +} +.breadcrumbs span:last-child { + color: var(--ca-text); +} +.top-actions { + display: flex; + align-items: center; + gap: 12px; +} +.ca-tools .top-actions button, +.ca-tools .top-actions select { + font-size: 11px; +} +.theme-label { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: var(--ca-muted); +} +.concept-badge { + color: var(--ca-focus); + border: 1px solid var(--ca-accent); + border-radius: 4px; + padding: 3px 7px; + font-size: 9px; + letter-spacing: 0.4px; +} +.count { + background: var(--ca-field); + border-radius: 4px; + padding: 1px 5px; + font-size: 10px; + margin-left: 4px; +} +.canvas-link { + font-size: 12px; + white-space: nowrap; +} +main { + padding: 30px; + max-width: 1900px; + margin: auto; +} +.heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 22px; +} +.heading .muted { + font-size: 12px; + margin-top: 6px; +} +.heading-actions, +.row-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.ca-tools button { + min-height: 32px; + font-size: 12px; +} +.icon { + width: 17px; + height: 17px; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; +} +.icon-btn { + padding: 6px !important; + min-width: 32px; + display: inline-grid !important; + place-items: center; +} +.ca-tools button.send { + color: var(--ca-focus); + border-color: var(--line); + display: inline-flex; + align-items: center; + gap: 6px; +} +.ca-tools button.send.ca-primary { + color: #fff; + border-color: var(--ca-accent); +} +.toolbar { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 18px; +} +.search-field { + flex: 1; + min-width: 180px; + display: flex; + align-items: center; + gap: 8px; + background: var(--ca-field); + border: 1px solid var(--line); + border-radius: 5px; + padding-inline: 10px; +} +.search-field input { + width: 100%; + border: 0 !important; + background: transparent !important; + min-height: 36px; + font-size: 12px !important; +} +.toolbar select { + font-size: 11px; + min-height: 36px; +} +.segmented { + display: inline-flex; + background: var(--ca-field); + border: 1px solid var(--line); + border-radius: 5px; + padding: 3px; + gap: 2px; +} +.ca-tools .segmented button { + background: transparent; + border: 0; + font-size: 11px; + min-height: 27px; + padding: 4px 10px; +} +.ca-tools .segmented button[aria-pressed="true"] { + background: var(--ca-accent); + color: #fff; +} +.filter-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 22px; +} +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border: 1px solid var(--line); + border-radius: 4px; + font-size: 10px; + color: var(--ca-muted); + background: var(--ca-field); +} +button.chip { + min-height: 27px !important; +} +.chip.green { + color: var(--green); +} +.chip.red { + color: var(--red); +} +.chip.blue { + color: var(--ca-focus); +} +.gallery-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + gap: 22px; + align-items: start; +} +.gallery-body { + min-width: 0; +} +.section-caption { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--ca-muted); + margin-bottom: 12px; +} +.image-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 13px; +} +.image-grid.large { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.image-grid.compact { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} +.media-card { + background: var(--ca-surface); + border: 1px solid var(--line); + border-radius: 7px; + overflow: hidden; + min-width: 0; +} +.media-card.selected { + border-color: var(--ca-focus); + box-shadow: 0 0 0 1px var(--ca-focus); +} +.media-art { + position: relative; +} +.ca-tools .image-open { + display: block; + padding: 0; + border: 0; + border-radius: 0; + width: 100%; + background: var(--ca-field); + overflow: hidden; + min-height: 0; +} +.image-open img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1.33; + object-fit: cover; + transition: transform 0.2s; +} +.image-open:hover img { + transform: scale(1.03); +} +.image-overlays { + position: absolute; + inset: 9px 9px auto; + display: flex; + justify-content: space-between; + pointer-events: none; +} +.image-overlays > label, +.image-overlays > button { + pointer-events: auto; +} +.image-overlays input { + width: 15px; + height: 15px; + accent-color: var(--ca-accent); +} +.ca-tools .star-button { + min-height: 25px; + width: 27px; + padding: 2px; + background: #131c28bb; + color: #fff; + border: 1px solid #ffffff33; +} +.star-button[aria-pressed="true"] { + color: #f1cc84 !important; +} +.media-info { + padding: 10px 11px; + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.media-info h3 { + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.media-info p { + font-size: 9px; + color: var(--ca-muted); + margin-top: 4px; +} +.media-info > div { + min-width: 0; +} +.ca-tools .media-info button { + min-height: 26px; + min-width: 28px; + padding: 4px; +} +.detail-panel { + background: var(--ca-surface); + border: 1px solid var(--line); + border-radius: 7px; + overflow: hidden; + position: sticky; + top: 20px; +} +.detail-cover { + width: 100%; + height: 150px; + object-fit: cover; + display: block; +} +.detail-inner { + padding: 17px; +} +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} +.panel-heading h2 { + font-size: 14px; +} +.prompt-section { + border-top: 1px solid var(--line); + padding-top: 15px; + margin-top: 15px; +} +.prompt-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} +.prompt-heading .role-label { + font-size: 10px; + letter-spacing: 0.7px; + text-transform: uppercase; + font-weight: 650; + color: var(--green); +} +.prompt-section.negative .role-label { + color: var(--red); +} +.prompt-text { + font-size: 12px; + line-height: 1.75; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; +} +.prompt-actions { + display: flex; + align-items: center; + gap: 5px; +} +.ca-tools .prompt-actions button { + min-height: 27px; + min-width: 28px; + padding: 4px; +} +.meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 16px 0; + font-size: 11px; +} +.meta-grid dt { + color: var(--ca-muted); + font-size: 10px; +} +.meta-grid dd { + margin: 3px 0 0; +} +.tags { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-top: 12px; +} +.detail-footer { + border-top: 1px solid var(--line); + padding: 13px 17px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.ca-tools .fullwidth { + width: 100%; + justify-content: center; +} +.app-footer { + padding: 22px 30px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + font-size: 10px; + color: var(--ca-muted); +} +.ca-tools .app-footer button { + font-size: 10px; + background: none; + border: 0; + padding: 0; + color: var(--ca-focus); +} +.bulkbar { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 13px; + background: var(--soft-blue); + border: 1px solid var(--ca-accent); + border-radius: 6px; + margin-bottom: 16px; + flex-wrap: wrap; + font-size: 12px; +} +.library-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} +.prompt-card { + border: 1px solid var(--line); + background: var(--ca-surface); + border-radius: 8px; + overflow: hidden; +} +.prompt-card .card-art { + display: grid; + grid-template-columns: 2fr 1fr; + height: 170px; + gap: 3px; + background: var(--ca-field); +} +.card-art img { + width: 100%; + height: 170px; + object-fit: cover; +} +.card-content { + padding: 18px; +} +.card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.card-title button { + font-size: 16px !important; + font-weight: 600; + background: none !important; + border: 0 !important; + padding: 0 !important; + text-align: start; +} +.card-content .prompt-section { + margin-top: 10px; + padding-top: 10px; +} +.card-meta { + display: flex; + align-items: center; + gap: 9px; + margin: 14px 0 0; + font-size: 10px; + color: var(--ca-muted); +} +.card-footer { + padding: 11px 18px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + color: var(--ca-muted); +} +.surface { + padding: 22px; + background: var(--ca-surface); + border: 1px solid var(--line); + border-radius: 7px; +} +.surface + .surface { + margin-top: 18px; +} +.surface > p { + font-size: 12px; + color: var(--ca-muted); + margin-top: 7px; +} +.stack { + display: grid; + gap: 18px; +} +.two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} +.section-nav { + display: flex; + gap: 6px; + flex-wrap: wrap; + border-bottom: 1px solid var(--line); + padding-bottom: 16px; + margin-bottom: 22px; +} +.section-nav button { + background: none !important; + border-color: transparent !important; + color: var(--ca-muted); +} +.section-nav button[aria-pressed="true"] { + background: var(--soft-blue) !important; + color: var(--ca-focus); + border-color: var(--ca-accent) !important; +} +.settings-layout { + max-width: 1000px; +} +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 17px 0; + border-top: 1px solid var(--line); +} +.setting-row:first-of-type { + margin-top: 15px; +} +.setting-row p { + font-size: 11px; + color: var(--ca-muted); + margin-top: 4px; + max-width: 540px; +} +.setting-row label { + font-size: 12px; + font-weight: 500; +} +.setting-row select, +.setting-row input:not([type="checkbox"]):not([type="range"]) { + max-width: 230px; + min-width: 130px; + font-size: 12px; +} +.setting-row input[type="range"] { + max-width: 180px; + accent-color: var(--ca-accent); +} +.setting-row input[type="checkbox"] { + accent-color: var(--ca-accent); + width: 17px; + height: 17px; +} +.save-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 22px; + gap: 16px; +} +.notice { + border: 1px solid var(--ca-accent); + background: var(--soft-blue); + border-radius: 6px; + padding: 15px 18px; + margin-bottom: 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} +.notice p { + font-size: 12px; + color: var(--ca-muted); + margin-top: 4px; +} +.notice .row-actions { + flex-shrink: 0; +} +.summary-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin: 0 0 22px; +} +.metric { + padding: 18px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--ca-surface); +} +.metric strong { + font-size: 23px; + font-weight: 600; + display: block; + margin-bottom: 3px; +} +.metric span { + font-size: 11px; + color: var(--ca-muted); +} +.operation-list { + margin-top: 15px; +} +.operation { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; + padding: 15px 0; + border-top: 1px solid var(--line); +} +.operation h3 { + font-size: 12px; +} +.operation p { + font-size: 11px; + color: var(--ca-muted); + margin-top: 5px; +} +.operation .row-actions { + flex-shrink: 0; +} +.ca-tools .danger { + color: var(--red); + border-color: var(--red); +} +.warning-note { + font-size: 11px; + color: var(--warning); + margin: 13px 0; +} +.table-wrap { + overflow-x: auto; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 12px; +} +caption { + text-align: start; + font-size: 13px; + font-weight: 600; + margin: 0 0 15px; +} +th, +td { + text-align: start; + border-bottom: 1px solid var(--line); + padding: 13px 10px; +} +th { + color: var(--ca-muted); + font-size: 10px; + font-weight: 500; +} +td .row-actions { + justify-content: flex-end; +} +pre { + background: var(--ca-field); + padding: 15px; + border: 1px solid var(--line); + border-radius: 5px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 11px; + max-height: 350px; + overflow: auto; +} +details { + border-top: 1px solid var(--line); + margin-top: 16px; + padding-top: 13px; +} +summary { + cursor: pointer; + font-size: 12px; +} +.ca-tools dialog { + color: var(--ca-text); + background: var(--ca-surface); + border: 1px solid var(--ca-border); + border-radius: 9px; + padding: 24px; + width: min(650px, calc(100vw - 24px)); + max-height: 90dvh; + overflow: auto; +} +.ca-tools dialog::backdrop { + background: #080b12b8; +} +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + margin-bottom: 20px; +} +.dialog-header h2 { + font-size: 18px; +} +.dialog-header > button { + font-size: 20px; + min-width: 32px; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + margin-top: 23px; +} +.field { + display: grid; + gap: 7px; + font-size: 12px; + margin: 15px 0; +} +.field input, +.field textarea, +.field select { + width: 100%; + font-size: 12px; +} +.field textarea { + min-height: 100px; + resize: vertical; +} +.demo-note { + padding: 10px 12px; + background: var(--soft-blue); + border: 1px solid var(--ca-accent); + border-radius: 5px; + color: var(--ca-focus); + font-size: 11px; + margin-bottom: 16px; +} +.send-preview { + border: 1px solid var(--line); + padding: 13px; + border-radius: 6px; + margin-top: 16px; +} +.send-preview h3 { + font-size: 11px; + color: var(--ca-muted); + margin-bottom: 8px; +} +.send-preview p { + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + background: var(--ca-surface); + border: 1px solid var(--ca-accent); + padding: 12px 18px; + border-radius: 6px; + box-shadow: 0 8px 35px #0004; + z-index: 10; + max-width: min(650px, 90vw); + font-size: 12px; +} +.toast:empty { + display: none; +} +.ca-tools #viewer-dialog { + width: min(1500px, 96vw); + height: min(940px, 94dvh); + max-height: 94dvh; + overflow: hidden; + padding: 18px; +} +.viewer-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 20px; + height: calc(100% - 72px); + min-height: 0; +} +.viewer-left { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--ca-field); + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--line); +} +#viewer-stage { + flex: 1; + position: relative; + min-height: 0; +} +#viewer-stage > img { + width: 100%; + height: 100%; + object-fit: contain; + visibility: hidden; +} +#viewer-stage > .viewer-container { + position: absolute; + inset: 0; +} +.viewer-toolbar { + display: flex; + justify-content: center; + gap: 6px; + flex-wrap: wrap; + padding: 10px; + border-top: 1px solid var(--line); +} +#viewer-details { + overflow-y: auto; + padding: 0 6px 18px; +} +.timeline-panel { + margin-top: 18px; +} +.timeline { + overflow-x: auto; + background: var(--ca-field); + border: 1px solid var(--line); + border-radius: 6px; + padding: 18px 20px; + position: relative; +} +.positive-lane { + height: 40px; + border-top: 2px solid var(--green); + margin: 17px 0 0; + min-width: 520px; + position: relative; +} +.positive-lane:before { + content: "+"; + position: absolute; + top: -13px; + left: 0; + border-radius: 50%; + width: 25px; + height: 25px; + background: var(--green); + color: #172b22; + text-align: center; +} +.positive-lane span { + font-size: 10px; + color: var(--green); + position: absolute; + top: -25px; + left: 38px; +} +.variant-lane { + position: relative; + min-width: 520px; + height: 57px; + margin-left: 38px; + border-top: 2px solid color-mix(in srgb, var(--red) 60%, transparent); +} +.variant-lane:before { + content: ""; + position: absolute; + top: -25px; + left: -30px; + width: 30px; + height: 25px; + border-left: 2px solid var(--red); + border-bottom: 2px solid var(--red); + border-radius: 0 0 0 22px; +} +.ca-tools .variant-dot { + position: absolute; + top: -16px; + left: 0; + border-radius: 50%; + width: 29px; + min-height: 29px; + background: var(--red); + color: #38151b; + border: 2px solid var(--red); + padding: 0; +} +.variant-dot[aria-pressed="true"] { + outline: 3px solid var(--ca-focus); + outline-offset: 4px; +} +.variant-lane span { + position: absolute; + left: 43px; + top: 6px; + font-size: 10px; + color: var(--ca-muted); +} +.ca-tools .run-dot { + position: absolute; + top: -7px; + left: 190px; + border-radius: 50%; + width: 12px; + min-height: 12px; + padding: 0; + background: var(--ca-field); + border: 2px solid var(--red); +} +.timeline-lower { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + margin-top: 14px; +} +.compare-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; +} +.compare-item { + padding: 15px; + background: var(--ca-field); + border: 1px solid var(--line); + border-radius: 6px; +} +.compare-item h3 { + color: var(--red); + font-size: 11px; + margin-bottom: 8px; +} +.diff-added { + background: var(--soft-blue); + color: var(--ca-focus); + padding: 3px 7px; + display: inline-block; + border-radius: 4px; + margin: 5px 5px 0 0; + font-size: 11px; +} +.file-drop { + display: grid; + place-items: center; + min-height: 170px; + border: 1px dashed var(--ca-border); + border-radius: 7px; + background: var(--ca-field); + padding: 22px; + text-align: center; + gap: 12px; +} +.file-drop input { + max-width: 100%; + font-size: 12px; +} +.empty { + padding: 50px; + text-align: center; + grid-column: 1/-1; + border: 1px dashed var(--line); + border-radius: 6px; + color: var(--ca-muted); +} +.sample-tag { + font-size: 9px; + letter-spacing: 1px; + color: var(--ca-muted); + text-transform: uppercase; +} +.review-intro { + max-width: 800px; + margin-bottom: 24px; + line-height: 1.8; +} +.review-intro p { + font-size: 13px; + color: var(--ca-muted); +} +.activity-item { + padding: 16px 0; + border-bottom: 1px solid var(--line); +} +.activity-item h3 { + font-size: 13px; +} +.activity-item p { + font-size: 11px; + color: var(--ca-muted); + margin-top: 6px; +} +progress { + accent-color: var(--ca-accent); + width: 100%; + height: 7px; + margin: 12px 0; +} +.ca-tools .list-grid { + grid-template-columns: 1fr; +} +.list-grid .media-card { + display: flex; +} +.list-grid .media-art { + width: 160px; + flex-shrink: 0; +} +.list-grid .media-info { + flex: 1; +} +.list-grid .image-open img { + height: 95px; + aspect-ratio: auto; +} +.list-grid .image-overlays { + display: none; +} +.icon-set { + display: flex; + align-items: center; + gap: 14px; + margin: 18px 0; +} +.icon-set .icon { + width: 28px; + height: 28px; + color: var(--ca-focus); +} +@media (min-width: 1700px) { + .image-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .image-grid.compact { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + .library-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 1250px) { + .app-shell { + grid-template-columns: 185px minmax(0, 1fr); + } + .sidebar { + padding-inline: 9px; + } + .gallery-layout { + grid-template-columns: minmax(0, 1fr) 275px; + gap: 15px; + } + .image-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .image-grid.compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + main { + padding: 24px; + } + .topbar { + padding-inline: 24px; + } + .concept-badge { + display: none; + } + .viewer-layout { + grid-template-columns: minmax(0, 1fr) 300px; + } + .brand { + font-size: 12px; + } +} +@media (max-width: 950px) { + .app-shell { + grid-template-columns: 1fr; + } + .sidebar { + height: auto; + position: static; + border-inline-end: 0; + border-bottom: 1px solid var(--line); + padding: 12px 18px; + display: block; + overflow: visible; + } + .brand { + margin: 0 0 12px; + } + .sidebar-label, + .sidebar-bottom .connection, + #saved-nav, + #collections-nav { + display: none; + } + .sidebar nav { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + .sidebar nav a { + min-height: 32px; + padding: 5px 10px; + } + .sidebar-bottom { + padding: 0; + margin: 5px 0 0; + } + .sidebar nav .nav-count { + display: none; + } + .sidebar-bottom nav { + border-top: 1px solid var(--line); + padding-top: 5px; + } + .gallery-layout { + grid-template-columns: minmax(0, 1fr) 280px; + } + .topbar { + height: auto; + min-height: 58px; + flex-wrap: wrap; + padding-block: 10px; + } + .summary-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .viewer-layout { + grid-template-columns: minmax(0, 1fr) 270px; + } + .library-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + main { + padding: 20px 14px; + } + .topbar { + padding-inline: 14px; + } + .breadcrumbs { + display: none; + } + .top-actions { + width: 100%; + justify-content: space-between; + gap: 8px; + } + .theme-label { + font-size: 0; + } + .theme-label select { + font-size: 11px; + } + .gallery-layout { + grid-template-columns: 1fr; + } + .detail-panel { + position: static; + } + .detail-cover { + height: 200px; + } + .image-grid, + .image-grid.large { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .image-grid.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .heading { + align-items: flex-start; + flex-direction: column; + } + .heading-actions { + width: 100%; + } + h1 { + font-size: 25px; + } + .toolbar { + gap: 8px; + } + .toolbar .search-field { + flex-basis: 100%; + } + .library-grid, + .two-col, + .compare-grid { + grid-template-columns: 1fr; + } + .notice { + align-items: flex-start; + flex-direction: column; + } + .setting-row { + gap: 16px; + } + .setting-row > div { + max-width: 65%; + } + .setting-row input, + .setting-row select { + min-width: 0 !important; + max-width: 120px !important; + } + .operation { + align-items: flex-start; + flex-direction: column; + } + .operation .row-actions { + flex-wrap: wrap; + } + .app-footer { + padding: 20px 14px; + flex-direction: column; + align-items: flex-start; + } + .ca-tools #viewer-dialog { + height: 94dvh; + overflow: auto; + } + .viewer-layout { + display: flex; + flex-direction: column; + height: auto; + } + .viewer-left { + height: 48dvh; + min-height: 300px; + } + .viewer-toolbar button { + font-size: 10px; + } + #viewer-details { + overflow: visible; + } + .card-art { + height: 150px !important; + } + .card-art img { + height: 150px; + } + .card-content { + padding: 15px; + } + .sidebar nav a { + font-size: 11px; + gap: 6px; + } + .sidebar nav .icon { + width: 14px; + height: 14px; + } + .brand { + font-size: 14px; + } + .table-wrap { + margin-inline: -10px; + } + .meta-grid { + font-size: 12px; + } + .toast { + bottom: 12px; + } + .viewer-dialog .dialog-header h2 { + font-size: 14px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + scroll-behavior: auto !important; + } +} +@media (forced-colors: active) { + .media-card.selected, + .variant-dot[aria-pressed="true"] { + outline: 2px solid Highlight; + } + .icon { + forced-color-adjust: auto; + } +} +@media (pointer: coarse) { + .ca-tools button { + min-height: 40px; + } + .ca-tools .prompt-actions button { + min-height: 36px; + min-width: 36px; + } +} + +.setting-row .ca-switch input { + width: 36px; + height: 24px; +} +.spaced { + margin-top: 18px; +} +.spaced-16 { + margin-top: 16px; +} +.spaced-20 { + margin-top: 20px; +} +.history-gallery { + margin-top: 24px; +} +.sample-preview { + width: 100%; + height: 180px; + object-fit: cover; + border-radius: 5px; +} +.sample-fluid { + width: 100%; + height: auto; +} +.local-file-preview { + width: 100%; + max-height: 400px; + object-fit: contain; + margin-top: 20px; +} +.variant-lane { + margin-left: var(--lane-offset, 38px); +} + +.spaced-15 { + margin-top: 15px; +} diff --git a/tools/preview-prompt-timeline.py b/tools/preview-prompt-timeline.py index bb3b1ed..293c321 100644 --- a/tools/preview-prompt-timeline.py +++ b/tools/preview-prompt-timeline.py @@ -1,4 +1,4 @@ -"""Serve only the timeline concept and its pinned toolkit assets.""" +"""Serve only UI concepts and their pinned toolkit and viewer assets.""" import argparse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -8,6 +8,11 @@ from urllib.parse import urlsplit ROOT = Path(__file__).resolve().parents[1] FILES = { "/": ("docs/concepts/prompt-variant-timeline.html", "text/html; charset=utf-8"), + "/workspace/": ("docs/concepts/workspace/index.html", "text/html; charset=utf-8"), + "/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"), + "/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"), + "/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"), + "/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"), "/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"), "/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"), }