style(workspace): align browsing with the approved concept (#219)

* style(workspace): align approved shell and visual fixtures

* test(workflows): preserve evidence across browser projects

* style(workspace): match approved image browsing geometry

* style(workspace): match natural library card layout

* fix(workspace): retain wheel motion during row correction

* fix(workspace): contain zoomed controls and image metadata (#215)
This commit is contained in:
Vito
2026-10-18 02:53:26 -07:00
committed by GitHub
parent 4ef236aaf7
commit e7cfcc2bfb
25 changed files with 1749 additions and 180 deletions
+33
View File
@@ -0,0 +1,33 @@
const paths = Object.freeze({
"images": "M3 3h18v18H3z M3 16l5-5 5 5 4-4 4 4 M8 7h.01",
"library": "M4 4h5v16H4z M11 4h4v16h-4z M17 5l3-1 3 15-3 1z",
"tags": "M3 3h8l10 10-8 8L3 11V3ZM7 7h.01",
"upload": "M12 16V3m-5 5 5-5 5 5M3 15v6h18v-6",
"overview": "M3 12h4l3-8 4 16 3-8h4",
"settings": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2",
"grid": "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
"list": "M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01",
"masonry": "M3 3h7v11H3zM14 3h7v6h-7zM3 18h7v3H3zM14 13h7v8h-7z",
"search": "M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm5.5 13 5 5",
"info": "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z",
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
});
/** Create an approved decorative outline icon without injecting markup. */
export function createWorkspaceIcon(name, doc = document) {
if (typeof name !== 'string' || !Object.hasOwn(paths, name)) {
throw new TypeError('Unknown workspace icon');
}
const namespace = 'http://www.w3.org/2000/svg';
const icon = doc.createElementNS(namespace, 'svg');
for (const [attribute, value] of Object.entries({
class: 'workspace-icon', viewBox: '0 0 24 24', 'aria-hidden': 'true',
focusable: 'false', fill: 'none', stroke: 'currentColor',
'stroke-width': '1.65', 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
})) icon.setAttribute(attribute, value);
const path = doc.createElementNS(namespace, 'path');
path.setAttribute('d', paths[name]);
icon.append(path);
return icon;
}
+17 -6
View File
@@ -1,14 +1,25 @@
export function layoutImages(images, width, mode, minWidth = 240) {
const gap = 16;
const columns = mode === 'list' ? 1 : Math.max(1, Math.floor((width + gap) / (minWidth + gap)));
export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) {
if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density');
const columns = viewportWidth <= 700 || density === 'large' ? 2 :
density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) :
viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3;
const gap = 13, minimumCardWidth = 120;
const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap)));
return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97};
}
export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) {
const {gap = 13, footerHeight = 54, listHeight = 97} = metrics;
const columns = mode === 'list' ? 1 : metrics.columns ?? Math.max(1, Math.floor((width + gap) / (minWidth + gap)));
const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns);
const bottoms = Array(columns).fill(0);
let maxHeight = 0;
const positions = images.map((image, index) => {
const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns;
const ratio = image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
const height = mode === 'list' ? 106 :
(mode === 'masonry' ? Math.max(130, Math.min(440, cardWidth / ratio)) : cardWidth * .75) + 72;
const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
const mediaWidth = Math.max(1, cardWidth - 2);
const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth * .75;
const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth * .75) + footerHeight + 2;
maxHeight = Math.max(maxHeight, height);
const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column];
bottoms[column] = y + height + gap;
+64 -45
View File
@@ -1,34 +1,42 @@
.sidebar { position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; }
.brand-mark { position: relative; }
.brand-mark small { position: absolute; inset-inline-end: 4px; inset-block-end: 2px; font-size: 9px; color: white; }
main.images-main { max-inline-size: none; padding: 28px; }
.image-filters { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); }
.image-filters .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
.image-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.image-filters .ca-field { display: flex; flex: 1 1 130px; min-inline-size: 0; flex-direction: column; align-items: stretch; gap: 5px; }
.image-filters .ca-field:first-child { flex: 3 1 260px; }
.image-filters .ca-field-label { font-size: 11px; }
.image-filters :where(input, select) { inline-size: 100%; min-block-size: 36px; }
.image-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 18px; }
.image-toolbar p { margin: 0; flex: 1 1 160px; font-size: 12px; }
.image-layouts { display: inline-flex; gap: 3px; }
.image-filters .ca-field-label { font-size: 10px; color: var(--ca-muted); }
.ca-tools .image-filters :where(input, select) { inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 11px; }
.image-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-block: 14px 22px; }
.ca-tools .image-view-controls button { min-block-size: 36px; font-size: 11px; }
.image-view-controls > .workspace-link { gap: 7px; min-block-size: 36px; font-size: 11px; margin-inline-start: auto; }
.image-view-controls .ca-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-inline-size: 0; }
.image-view-controls .ca-field-label { font-size: 10px; color: var(--ca-muted); }
.ca-tools .image-view-controls select { min-block-size: 36px; font-size: 11px; }
.image-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin-block: 0 13px; }
.image-toolbar h2 { font-size: 14px; font-weight: 600; margin: 0; }
.image-toolbar p { margin: 0; font-size: 11px; }
.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
.image-layouts .workspace-icon { inline-size: 14px; block-size: 14px; }
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 22px; align-items: start; }
.images-main { container: images-page / inline-size; }
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
.image-collection { min-inline-size: 0; }
.image-grid { position: relative; inline-size: 100%; }
.image-card { position: absolute; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) 72px; }
.image-card.selected { border-color: var(--ca-focus); box-shadow: 0 0 0 1px var(--ca-focus); }
.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 8px 8px 0 0; background: var(--ca-field); }
.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); }
.image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; }
.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); }
.image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
.image-card-footer { position: relative; min-inline-size: 0; padding: 12px 45px 10px 12px; }
.image-card-footer strong { display: block; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 10px; margin-block-start: 4px; }
.image-card-footer button { position: absolute; inset-inline-end: 8px; inset-block-start: 16px; }
.image-grid[data-layout="list"] .image-card { grid-template-columns: 125px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.image-grid[data-layout="list"] .image-open { border-radius: 8px 0 0 8px; }
.image-grid[data-layout="list"] .image-card-footer { align-self: center; }
.image-details { position: sticky; inset-block-start: 18px; padding: 18px; max-block-size: calc(100dvh - 36px); overflow: auto; scrollbar-gutter: stable; }
.image-details h2 { font-size: 15px; overflow-wrap: anywhere; margin-block: 8px 16px; }
.image-details > :where(img, video) { display: block; inline-size: 100%; aspect-ratio: 4 / 3; object-fit: contain; background: var(--ca-field); border-radius: 6px; }
.image-facts { font-size: 11px; margin-block: 18px; }
.image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; }
.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; }
.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
.image-card-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; }
.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; }
.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; }
.image-facts { font-size: 10px; margin-block: 17px; }
.image-facts > div { padding-block: 8px; border-block-end: 1px solid var(--ca-line); }
.image-facts dt { color: var(--ca-muted); margin-block-end: 4px; }
.image-facts dd { margin: 0; overflow-wrap: anywhere; }
@@ -42,34 +50,45 @@ main.images-main { max-inline-size: none; padding: 28px; }
.image-preview-stage { position: relative; block-size: min(75dvh, 850px); background: var(--ca-field); }
.image-preview-stage > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
.image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; }
@media (max-width: 1150px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 250px; gap: 16px; }
main.images-main { padding: 24px 18px; }
@media (max-width: 1250px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; }
}
@media (max-width: 780px) {
.sidebar { position: static; block-size: auto; }
@media (max-width: 950px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 280px; }
}
@media (max-width: 700px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; padding: 17px; }
.image-details > :where(img, video) { block-size: 200px; }
.image-filters { gap: 8px; }
.image-filters .ca-field { flex-basis: 140px; }
.image-filters .ca-field:first-child { flex-basis: 100%; }
.image-view-controls { gap: 8px; }
.image-view-controls > .workspace-link { margin-inline-start: 0; }
}
@container images-page (inline-size < 600px) {
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; }
.image-details > :where(img, video) { max-block-size: 280px; }
.image-filters { gap: 10px; padding: 12px; }
.image-filters .ca-field { flex-basis: 120px; }
.image-layouts { flex-wrap: wrap; }
}
.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
.options-status { font-size: 12px; padding-inline: 4px; }
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
.prompt-block { border: 1px solid var(--ca-line); border-inline-start: 3px solid var(--prompt-role); border-radius: 7px; padding: 14px; margin-block-end: 14px; min-inline-size: 0; }
.prompt-positive { --prompt-role: #6bd6a4; }
[data-ca-theme="light"] .prompt-positive { --prompt-role: #14784f; }
.prompt-negative { --prompt-role: #ff7f7f; }
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b51d2d; }
.prompt-block h3 { font-size: 12px; margin: 0 0 8px; color: var(--prompt-role); }
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 11px; }
.prompt-text { font: inherit; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 12px; }
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
.prompt-positive { --prompt-role: #73dda9; }
[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; }
.prompt-negative { --prompt-role: #ff8c98; }
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; }
.prompt-source { margin-block: 6px 10px; }
.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; }
.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.prompt-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.prompt-actions button { font-size: 11px; }
.prompt-actions { display: flex; flex-wrap: wrap; gap: 4px; }
.ca-tools .prompt-actions button { display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
.prompt-actions .prompt-send-button span { display: none; }
.prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; }
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
.prompt-copy-status:empty { display: none; }
.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); }
+71 -17
View File
@@ -1,8 +1,9 @@
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
import { createImageStream, imageKey } from './images-state.mjs';
import { layoutImages, visibleImages } from './image-layout.mjs';
import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs';
import { mountPromptInspector } from './prompt-inspector.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { createWorkspaceIcon } from './icons.mjs';
function el(tag, text, className) {
const element = document.createElement(tag);
@@ -28,18 +29,24 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let detailInspector, previewInspector;
scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); });
const visit = embedded ? null : lastVisit;
let density = visit?.density || 'comfortable';
let metrics = workspaceImageMetrics(window.innerWidth, density);
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
scope.cleanup(() => {
if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, selection, scrollY: window.scrollY } : null;
if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
if (!embedded) main.append(el('p', 'Browse your images, explore the details, keep your creative context.', 'intro'));
if (!embedded) {
const intro = el('p', 'Browse your images, explore the details, keep your creative context.', 'intro');
(main.querySelector('.workspace-heading-copy') || main).append(intro);
scope.cleanup(() => intro.remove());
}
const filtersBar = el('div', undefined, 'image-filters');
const search = el('input');
search.value = filters.q;
@@ -54,12 +61,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
if (!embedded) main.append(filtersBar, optionsStatus);
const controls = el('div', undefined, 'image-view-controls');
const toolbar = el('div', undefined, 'image-toolbar');
toolbar.append(el('h2', embedded ? 'Linked images' : 'All images'));
const count = el('p', 'Loading images…', 'muted');
const layouts = el('div', undefined, 'image-layouts');
layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout');
for (const mode of ['grid', 'list', 'masonry']) {
const button = createButton(mode[0].toUpperCase() + mode.slice(1));
button.prepend(createWorkspaceIcon(mode));
button.setAttribute('aria-pressed', String(mode === layout));
scope.listen(button, 'click', () => {
layout = mode;
@@ -89,9 +99,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
});
const currentGallery = el('a', 'Current gallery', 'workspace-link');
currentGallery.href = resolveUrl('/prompt_manager/gallery');
toolbar.append(count, layouts);
if (!embedded) toolbar.append(refresh, currentGallery);
main.append(toolbar);
const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], '');
thumbnails.value = density;
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density'));
if (!embedded) {
const group = el('a', 'Group by positive prompt', 'workspace-link');
group.href = '#/library'; group.prepend(createWorkspaceIcon('branch'));
controls.append(group, refresh);
}
main.append(controls);
toolbar.append(count);
const body = el('div', undefined, 'image-workspace');
const collection = el('section', undefined, 'image-collection');
collection.setAttribute('aria-label', 'Images');
@@ -103,7 +121,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const retry = createButton('Retry');
const end = el('p', '', 'muted');
continuation.append(message, more, retry, end);
collection.append(grid, continuation);
collection.append(toolbar, grid, continuation);
if (!embedded) collection.append(currentGallery);
const details = el('section', undefined, 'image-details panel');
details.setAttribute('aria-label', 'Image details');
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
@@ -164,7 +183,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const prompts = el('div');
const legacy = el('p', 'Open Current gallery for tagging.', 'muted');
legacy.hidden = embedded;
details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, preview, download, prompts, metadata, legacy);
details.replaceChildren(thumbnail, el('h2', image.filename), preview, download, prompts, metadata, legacy);
detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope});
for (const [key, card] of rendered) {
card.classList.toggle('selected', key === selection);
@@ -205,8 +224,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
open.append(img);
const footer = el('div', undefined, 'image-card-footer');
const title = el('strong', image.filename); title.title = image.path;
const sub = el('small', `${formatBytes(image.size)} · ${image.media_type === 'video' ? 'Video' : 'Image'}`);
const preview = createButton('↗', 'ca-icon-button');
const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : '';
const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase();
const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`);
const preview = createButton('', 'ca-icon-button');
preview.append(createWorkspaceIcon('images'));
preview.setAttribute('aria-label', `Preview ${image.filename}`);
footer.append(title, sub, preview); card.append(open, footer);
return card;
@@ -221,11 +243,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function renderVisible() {
if (!state || scope.signal.aborted) return;
const top = Math.max(0, -grid.getBoundingClientRect().top);
const bounds = grid.getBoundingClientRect();
const scale = bounds.width / Number.parseFloat(getComputedStyle(grid).width) || 1;
const top = Math.max(0, -bounds.top / scale);
const viewportHeight = window.innerHeight / scale;
const active = document.activeElement?.closest('article[data-image-key]')?.dataset.imageKey;
const focused = active ? itemIndexes.get(active) ?? -1 : -1;
const indexes = state.items.length <= 120 ? state.items.map((_, index) => index) :
visibleImages(geometry.positions, top, window.innerHeight, focused, geometry.maxHeight);
visibleImages(geometry.positions, top, viewportHeight, focused, geometry.maxHeight);
const keep = new Set(indexes.map(index => imageKey(state.items[index])));
for (const [key, card] of rendered) if (!keep.has(key)) {
for (const video of card.querySelectorAll('video')) releaseVideo(video);
@@ -244,6 +269,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
previous = card;
}
// Natural footer measurements and absolute reservations share unzoomed CSS units.
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight));
if (footerHeight > metrics.footerHeight) {
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;
metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)};
updateGeometry();
for (const index of indexes) positionCard(rendered.get(imageKey(state.items[index])), index);
// A later mounted footer must not move the current visible card.
const correction = anchor === undefined ? 0 : geometry.positions[anchor].y - anchorTop;
if (top > 0 && correction) window.scrollBy(0, correction * scale);
scheduleVisible();
}
}
function positionCard(card, index) {
const image = state.items[index], position = geometry.positions[index];
@@ -270,7 +308,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const index = Number(card.dataset.index);
const columns = layout === 'list' ? 1 : Math.max(1, Math.floor((grid.clientWidth + 16) / 256));
const columns = layout === 'list' ? 1 : metrics.columns;
const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1,
ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key];
if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); }
@@ -284,12 +322,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
});
function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); }
function updateGeometry() {
geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics);
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
function updateLayout() {
if (!state || scope.signal.aborted) return;
grid.dataset.layout = layout;
cancelAnimationFrame(frame); frame = 0;
geometry = layoutImages(state.items, grid.clientWidth, layout);
grid.style.height = `${geometry.height}px`;
const width = Number.parseFloat(getComputedStyle(grid).width);
const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null;
galleryWidth = width;
metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth);
if (retained) {
metrics.footerHeight = retained.footerHeight;
metrics.listHeight = retained.listHeight;
}
grid.dataset.layout = layout;
grid.dataset.density = density;
updateGeometry();
renderVisible();
}
let resizeFrame = 0, observedWidth;
@@ -304,6 +355,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
});
resize.observe(grid);
scope.listen(window, 'scroll', scheduleVisible);
scope.listen(window, 'resize', () => {
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
function renderState(next) {
const changed = state?.items !== next.items;
+26 -10
View File
@@ -4,6 +4,7 @@ import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary } from './library.mjs';
import { createWorkspaceIcon } from './icons.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -33,13 +34,24 @@ function mountWorkspace() {
const theme = mountWorkspaceTheme({ root: document.body, scope });
const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], '');
preference.value = theme.preference;
appearance.replaceChildren(createField('Appearance', preference, 'workspace-appearance'));
const appearanceField = createField('Appearance', preference, 'workspace-appearance');
appearanceField.classList.add('workspace-appearance');
appearance.replaceChildren(appearanceField);
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
nav.replaceChildren(...routes.map(route => {
const link = element('a', route.label);
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' };
const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]];
nav.replaceChildren(...groups.map(([name, label, ids]) => {
const group = element('div', undefined, `workspace-nav-${name}`);
group.append(element('p', label, 'sidebar-label'));
for (const id of ids) {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icons[id]), element('span', route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
group.append(link);
}
return group;
}));
let viewScope;
let firstRender = true;
@@ -49,13 +61,17 @@ function mountWorkspace() {
viewScope?.dispose();
viewScope = createScope();
const view = viewScope;
for (const link of nav.children) {
for (const link of nav.querySelectorAll('[data-route]')) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
document.title = `${route.label} · Prompt Manager`;
const heading = element('h1', route.label);
main.replaceChildren(element('p', 'YOUR CREATIVE LIBRARY', 'eyebrow'), heading);
const headingCopy = element('div', undefined, 'workspace-heading-copy');
headingCopy.append(heading);
const headingRegion = element('header', undefined, 'workspace-heading');
headingRegion.append(headingCopy);
main.replaceChildren(headingRegion);
if (!firstRender) main.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = route.label;
@@ -68,7 +84,7 @@ function mountWorkspace() {
return;
}
if (route.id !== 'overview') {
main.append(element('p', route.description, 'intro'));
headingCopy.append(element('p', route.description, 'intro'));
const panel = element('section', undefined, 'panel handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
@@ -76,7 +92,7 @@ function mountWorkspace() {
main.append(panel);
return;
}
main.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
headingCopy.append(element('p', 'A home for your prompts, images and creative history.', 'intro'));
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
+42
View File
@@ -0,0 +1,42 @@
export function layoutLibraryRows(heights, columns, {gap = 18, estimate = 468} = {}) {
if (!Number.isSafeInteger(columns) || columns < 1) throw new TypeError('Invalid Library columns');
if (!Number.isFinite(gap) || gap < 0 || !Number.isFinite(estimate) || estimate <= 0) throw new TypeError('Invalid Library geometry');
const rows = [];
let top = 0;
for (let first = 0; first < heights.length; first += columns) {
const last = Math.min(first + columns, heights.length) - 1;
let height = 0;
for (let index = first; index <= last; index++) {
const measured = heights[index];
height = Math.max(height, Number.isFinite(measured) && measured > 0 ? measured : estimate);
}
rows.push({first, last, top, height});
top += height + gap;
}
return {rows, height: rows.length ? top - gap : 0};
}
function lowerBound(rows, after) {
let low = 0, high = rows.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (after(rows[middle])) low = middle + 1;
else high = middle;
}
return low;
}
export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1) {
if (!rows.length) return [];
top = Number.isFinite(top) ? Math.max(0, top) : 0;
viewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
const first = Math.min(rows.length - 1, lowerBound(rows, row => row.top + row.height <= top));
const end = Math.max(first + 1, lowerBound(rows, row => row.top < top + viewportHeight));
const visible = new Set();
for (let index = Math.max(0, first - 2); index < Math.min(rows.length, end + 2); index++) visible.add(index);
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0) {
const focus = lowerBound(rows, row => row.last < focusedIndex);
if (focus < rows.length && rows[focus].first <= focusedIndex) visible.add(focus);
}
return [...visible].sort((a, b) => a - b);
}
+20 -18
View File
@@ -1,29 +1,31 @@
main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding: 28px; }
main.library-main { --library-positive: #73dda9; }
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
.library-main .library-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-block: 20px 16px; }
.library-main .library-toolbar .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; }
.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; }
.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; }
.library-main .library-toolbar :where(input, select) { inline-size: 100%; min-inline-size: 0; min-block-size: 36px; }
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 12px; color: var(--ca-muted); margin-block: 16px; }
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.library-main .library-card { box-sizing: border-box; block-size: 560px; display: grid; grid-template-rows: 170px minmax(0, 1fr) auto; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; }
.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; }
.ca-tools .library-main .library-toolbar > button { inline-size: auto; }
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; }
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; }
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; }
.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; }
.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; }
.library-main .library-card-art > :only-child { grid-column: 1 / -1; }
.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); }
.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; }
.library-main .library-card-title { max-block-size: 72px; overflow: auto; flex-shrink: 0; display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.library-main .library-card-title h2 { margin: 0; font-size: 16px; min-inline-size: 0; overflow-wrap: anywhere; }
.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
.library-main .library-title-link { color: var(--ca-text); text-decoration: none; }
.library-main .library-title-link:hover { color: var(--ca-accent); }
.library-main .library-card-prompts { flex: 1 1 auto; overflow: auto; }
.library-main .library-card-prompts .prompt-block { margin: 0; }
.library-main .library-card-prompts .prompt-text { max-block-size: 100px; }
.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 11px; color: var(--ca-muted); }
.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 4px 8px; border: 1px solid var(--ca-line); border-radius: 5px; font-size: 11px; color: var(--ca-muted); overflow-wrap: anywhere; }
.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; }
.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); }
.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; }
.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); }
.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; max-block-size: 50px; overflow: auto; }
.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; }
.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; }
.library-main .library-rating { color: var(--ca-muted); }
.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); }
.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; }
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; }
@@ -41,6 +43,6 @@ main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding:
.library-main .library-shared-gallery { margin-block-start: 24px; }
.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; }
@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { main.library-main { padding: 20px 14px; } .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } }
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
[data-ca-theme="light"] main.library-main { --library-positive: #14784f; }
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
+128 -43
View File
@@ -2,7 +2,9 @@ import {createScope,createButton,createField,createSelect} from '../shared/toolk
import {createPromptSender} from './prompt-sender.mjs';
import {mountPromptBlocks} from './prompt-inspector.mjs';
import {mountImages} from './images.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles,libraryWindow} from './library-state.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
let shelfVisit=null;
const variantSelections=new Map();
@@ -11,17 +13,15 @@ function el(tag,text,className) {
}
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;}
function branchIcon() {
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('aria-hidden','true');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8');
const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('d','M6 8v8m0-4h6a6 6 0 0 0 6-6M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM21 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z');svg.append(path);return svg;
}
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
main.classList.add('library-main');scope.cleanup(()=>main.classList.remove('library-main'));
main.classList.add('library-main');
const sender=createPromptSender({scope});
if(positiveId!==undefined) {mountHistory();return;}
mountShelf();
if(positiveId!==undefined) mountHistory();
else mountShelf();
// Save the visit before removing the class that defines the shelf's scroll range.
scope.cleanup(()=>main.classList.remove('library-main'));
function mountShelf() {
const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
@@ -41,10 +41,17 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link');
continuation.append(message,retry,more);section.append(introduction,toolbar,optionsStatus,legacy,caption,grid,continuation);main.append(section);
const cards=new Map();let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1,restoreFrame=0;
(main.querySelector('.workspace-heading-copy') || section).append(introduction);
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,caption,grid,continuation,legacy);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
function removeCard(card) {card.blocks.dispose();card.node.remove();}
const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64;
let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map();
let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY;
let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null;
if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height);
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
function cardFor(item,index) {
const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true');
@@ -65,38 +72,106 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`));
const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip'));
content.append(heading,prompts,stats,vocabulary);
const footer=el('footer',undefined,'library-card-footer');footer.append(el('span',item.rating?`${item.rating} / 5`:'Unrated','muted'));
const history=link('',`#/library/${item.id}`,'library-history-link');history.append(branchIcon(),el('span','Explore history'));footer.append(history);
const footer=el('footer',undefined,'library-card-footer');
const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating);
const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history);
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
}
function renderWindow() {
if(!state || scope.signal.aborted) return;
const active=document.activeElement?.closest('article[data-prompt-id]');
const focused=focusOverride>=0?focusOverride:active?Number(active.dataset.index):-1;
const size=columns();const top=Math.max(0,-grid.getBoundingClientRect().top);
const indexes=libraryWindow(state.items.length,size,top,window.innerHeight,focused);const keep=new Set(indexes.map(index=>state.items[index].id));
for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);}
const children=[];let previousRow=-1;
for(const index of indexes) {
const row=Math.floor(index/size);
if(index%size===0 && row>previousRow+1) {
const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${(row-previousRow-1)*578-18}px`;children.push(gap);
}
let card=cards.get(state.items[index].id);
if(!card) {card=cardFor(state.items[index],index);cards.set(state.items[index].id,card);}
card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total);children.push(card.node);previousRow=row;
function captureAnchor() {
// Mounted rows can already differ from estimates before their observer batch.
for(const row of grid.children) {
if(!row.classList.contains('library-row'))continue;
const bounds=row.getBoundingClientRect();
if(bounds.bottom>0 && bounds.top<window.innerHeight)return {id:Number(row.firstElementChild.dataset.promptId),offset:bounds.top};
}
const remaining=Math.ceil(state.items.length/size)-previousRow-1;
if(remaining>0) {const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${remaining*578-18}px`;children.push(gap);}
// Remove obsolete spacers first: moving a focused card past an old spacer loses focus.
for(const child of [...grid.children]) if(!children.includes(child)) child.remove();
let previous=null;
for(const child of children) {if(previous?previous.nextElementSibling!==child:grid.firstElementChild!==child) grid.insertBefore(child,previous?previous.nextElementSibling:grid.firstChild);previous=child;}
return null;
}
const schedule=()=>{if(!frame) frame=requestAnimationFrame(()=>{frame=0;renderWindow();});};
function reconcileScroll() {
const delta=window.scrollY-observedScrollY;observedScrollY=window.scrollY;
if(!delta)return;
// Keep pending height compensation while honoring movement since it was queued.
if(pendingAnchor)pendingAnchor={...pendingAnchor,offset:pendingAnchor.offset-delta};
else anchor=captureAnchor();
}
function spacer(key,height) {
let node=spacers.get(key);
if(!node) {node=el('div',undefined,'library-row-spacer');node.setAttribute('aria-hidden','true');spacers.set(key,node);}
const size=`${height}px`;if(node.style.blockSize!==size)node.style.blockSize=size;
return node;
}
function renderWindow(topOverride) {
if(!state || scope.signal.aborted) return;
const active=document.activeElement, activeCard=active?.closest('article[data-prompt-id]');
const focused=focusOverride>=0?focusOverride:activeCard?Number(activeCard.dataset.index):-1;
const top=topOverride??Math.max(0,-grid.getBoundingClientRect().top);
const visible=state.items.length<=120?layout.rows.map((_,index)=>index):visibleLibraryRows(layout.rows,top,window.innerHeight,focused);
const keep=new Set(), children=[];let previous=-1;
for(const rowIndex of visible) {
const row=layout.rows[rowIndex];
if(rowIndex>previous+1) {
const end=previous<0?0:layout.rows[previous].top+layout.rows[previous].height+18;
children.push(spacer(`${previous}:${rowIndex}`,row.top-end-18));
}
let rowNode=rowNodes.get(rowIndex);
if(!rowNode) {rowNode=el('div',undefined,'library-row');rowNode.setAttribute('role','presentation');rowNodes.set(rowIndex,rowNode);}
if(!rowNode.isConnected)grid.append(rowNode);
for(let index=row.first;index<=row.last;index++) {
const item=state.items[index];keep.add(item.id);let card=cards.get(item.id);
if(!card) {card=cardFor(item,index);cards.set(item.id,card);rowNode.append(card.node);measurementObserver?.observe(card.node);}
else if(card.node.parentElement!==rowNode)rowNode.append(card.node);
card.node.dataset.index=index;card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total);
}
children.push(rowNode);previous=rowIndex;
}
for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);}
if(previous>=0 && previous<layout.rows.length-1) {const end=layout.rows[previous].top+layout.rows[previous].height;children.push(spacer(`${previous}:end`,layout.height-end-18));}
for(const child of [...grid.children]) if(!children.includes(child)) child.remove();
let sibling=null;
for(const child of children) {if(sibling?sibling.nextElementSibling!==child:grid.firstElementChild!==child)grid.insertBefore(child,sibling?sibling.nextElementSibling:grid.firstChild);sibling=child;}
for(const [key,node] of rowNodes)if(!node.isConnected)rowNodes.delete(key);
for(const [key,node] of spacers)if(!node.isConnected)spacers.delete(key);
if(activeCard && active.isConnected && document.activeElement!==active)active.focus({preventScroll:true});
}
function flush() {
frame=0;if(scope.signal.aborted)return;
reconcileScroll();
const width=readInlineSize(), size=columns();
if(width!==inlineSize || size!==columnCount) {pendingAnchor ||= anchor;inlineSize=width;columnCount=size;heights.clear();dirty=true;}
for(const [id,measurement] of pendingMeasurements) {
if(measurement.width===inlineSize && indexesById.has(id) && Math.abs((heights.get(id)??0)-measurement.height)>.5) {heights.set(id,measurement.height);dirty=true;}
}
pendingMeasurements.clear();
const restore=dirty?(pendingAnchor || anchor):null;
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;}
const index=restore?indexesById.get(restore.id):undefined;
const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset);
renderWindow(target);
const card=index===undefined?null:cards.get(restore.id);
if(card) {
const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5)window.scrollBy(0,delta);
anchor=restore;
} else {
if(restoreScrollY!==null)window.scrollTo(0,restoreScrollY);
anchor=captureAnchor();
}
// Read the actual clamped result now; its later scroll event is not new motion.
observedScrollY=window.scrollY;
restoreScrollY=null;pendingAnchor=null;scheduleLoad();
}
const schedule=()=>{if(!frame)frame=requestAnimationFrame(flush);};
const measurementObserver=typeof ResizeObserver==='function'?new ResizeObserver(entries=>{
const width=readInlineSize();let changed=width!==inlineSize || columns()!==columnCount;
for(const entry of entries) {
const id=Number(entry.target.dataset.promptId);if(!cards.has(id) || cards.get(id).node!==entry.target)continue;
const height=entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
if(Number.isFinite(height) && height>0 && (width!==inlineSize || Math.abs((pendingMeasurements.get(id)?.height??heights.get(id)??0)-height)>.5)) {pendingMeasurements.set(id,{height,width});changed=true;}
}
if(changed) {pendingAnchor ||= anchor;schedule();}
}):null;
measurementObserver?.observe(grid);
function focusCard(index,last=false) {
if(index<0 || index>=state.items.length) return;
focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
}
scope.listen(grid,'keydown',event=>{
@@ -110,18 +185,26 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
}
});
function render(next) {
if(items!==next.items) {
pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true;
if(!items.length) {
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
for(const card of cards.values())removeCard(card);
cards.clear();rowNodes.clear();spacers.clear();grid.replaceChildren();
}
}
state=next;
caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`;
message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'';
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
renderWindow();scheduleLoad();
schedule();scheduleLoad();
}
const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined});
function maybeLoad() {
if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return;
const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore();
}
function scheduleLoad() {if(!loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});}
function scheduleLoad() {if(observer && !loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});}
if(typeof IntersectionObserver==='function') {observer=new IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting)) maybeLoad();},{rootMargin:'250px'});observer.observe(continuation);}
async function loadCategories() {
optionsRequest?.abort();const request=optionsRequest=new AbortController();
@@ -130,14 +213,16 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}}
}
const reset=()=>stream.reset(filters);
const reset=()=>{restoreScrollY=null;return stream.reset(filters);};
scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200));
scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200));
scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();});
scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore());
scope.listen(window,'scroll',schedule);scope.listen(window,'resize',schedule);
scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);cancelAnimationFrame(restoreFrame);for(const card of cards.values()) removeCard(card);cards.clear();});
loadCategories();if(visit) {render(stream.state);restoreFrame=requestAnimationFrame(()=>{if(!scope.signal.aborted) {window.scrollTo(0,visit.scrollY);renderWindow();scheduleLoad();}});if(visit.state.loading || visit.state.error) stream.loadMore();} else reset();
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
// Mobile viewport resizing can adjust scroll before delivering its resize event.
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset();
}
function mountHistory() {
+11 -4
View File
@@ -1,5 +1,6 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createSendButton } from './prompt-sender.mjs';
import { createWorkspaceIcon } from './icons.mjs';
export function inspectPath(image) {
return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?root=${image.root_index}`;
@@ -100,7 +101,9 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const record = state.prompts?.[role];
const block = el('section', undefined, `prompt-block prompt-${role}`);
block.setAttribute('aria-label', title);
block.append(el('h3', title));
const heading = el('div', undefined, 'prompt-block-heading');
heading.append(el('h3', title));
block.append(heading);
if (Object.hasOwn(roles, role)) {
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
@@ -112,11 +115,15 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const actions = el('div', undefined, 'prompt-actions');
const send = createSendButton(`Send ${role} prompt`);
send.dataset.sendRole = role;
const copy = createButton(`Copy ${role} prompt`);
const copy = createButton('', 'ca-icon-button');
copy.setAttribute('aria-label', `Copy ${role} prompt`);
copy.title = `Copy ${role} prompt`;
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
actions.append(send, copy);
actions.append(copy, send);
heading.append(actions);
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
block.append(actions, status);
block.append(status);
} else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
container.append(block);
}
+55 -25
View File
@@ -2,30 +2,42 @@
* { box-sizing: border-box; }
body.ca-tools {
margin: 0; --workspace-page: #1b1c1f; --workspace-sidebar: #202124;
background: var(--workspace-page); font-size: 14px;
background: var(--workspace-page); --workspace-active: #14364d;
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
}
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; }
body[data-ca-theme="light"] { --workspace-page: #f3f4f6; --workspace-sidebar: #fff; --workspace-active: #e4f1fb; }
[hidden] { display: none !important; }
a { color: var(--ca-focus); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100dvh; }
.sidebar { background: var(--workspace-sidebar); border-right: 1px solid var(--ca-line); padding: 28px 18px; display: flex; flex-direction: column; gap: 24px; }
.brand { display: flex; gap: 12px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 16px; }
.brand small { display: block; color: var(--ca-muted); font-size: 11px; font-weight: 400; margin-top: 2px; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--ca-accent); color: white; }
.workspace-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-block-size: 100dvh; }
.sidebar { background: var(--workspace-sidebar); border-inline-end: 1px solid var(--ca-line); padding: 26px 14px 16px; display: flex; flex-direction: column; gap: 5px; position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; overflow-y: auto; }
.brand { display: flex; gap: 11px; align-items: center; color: var(--ca-text); font-weight: 650; font-size: 14px; margin: 0 4px 28px; line-height: 1.3; }
.brand small { display: block; color: var(--ca-muted); font-size: 9px; letter-spacing: 1.8px; font-weight: 400; margin-block-start: 6px; }
.brand-mark { flex: 0 0 34px; inline-size: 34px; block-size: 36px; color: var(--ca-focus); font-size: 29px; position: relative; }
.brand-mark > span { font-size: 15px; position: absolute; inset-block-end: -2px; inset-inline-end: -2px; }
.eyebrow { font-size: 10px; letter-spacing: 1.6px; text-transform: uppercase; color: var(--ca-muted); margin: 0; }
nav { display: grid; gap: 6px; }
nav a { color: var(--ca-muted); padding: 11px 14px; border-radius: 7px; min-height: 44px; }
nav a:hover { background: var(--ca-hover); color: var(--ca-text); text-decoration: none; }
nav a[aria-current="page"] { color: #fff; background: var(--ca-accent); }
.sidebar-footer { margin-top: auto; color: var(--ca-muted); font-size: 11px; display: flex; align-items: center; gap: 8px; }
.connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ca-muted); }
.workspace-body { min-width: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 32px; border-bottom: 1px solid var(--ca-line); font-size: 12px; }
.sidebar-label { margin: 14px 12px 6px; font-size: 9px; font-weight: 650; letter-spacing: 1.1px; color: var(--ca-muted); }
nav[aria-label="Workspace"] { display: flex; flex-direction: column; flex: 1; gap: 5px; }
.workspace-nav-primary, .workspace-nav-tools { display: grid; gap: 3px; }
.workspace-nav-tools { margin-block-start: auto; padding-block-start: 32px; }
nav [data-route] { display: flex; align-items: center; gap: 11px; color: var(--ca-muted); padding: 8px 12px; border-radius: 6px; min-block-size: 38px; font-size: 12px; overflow-wrap: anywhere; }
nav [data-route]:hover { background: var(--ca-field); color: var(--ca-text); text-decoration: none; }
nav [aria-current="page"] { color: var(--ca-focus); background: var(--workspace-active); }
.workspace-icon { display: block; inline-size: 17px; block-size: 17px; flex: 0 0 auto; }
.sidebar-footer { margin: 17px 6px 0; padding-block-start: 17px; border-block-start: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 10px; display: flex; align-items: center; gap: 8px; }
.connection-dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; }
.workspace-body { min-inline-size: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; block-size: 68px; padding-inline: 30px; border-block-end: 1px solid var(--ca-line); font-size: 12px; }
#appearance-control { min-inline-size: 0; max-inline-size: 100%; }
.ca-tools .workspace-appearance { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; min-inline-size: 0; max-inline-size: 100%; font-size: 11px; }
.ca-tools .workspace-appearance select { min-inline-size: 0; max-inline-size: 100%; font-size: 11px; }
.muted { color: var(--ca-muted); }
main { flex: 1; padding: 36px; width: 100%; max-width: 1500px; margin: 0 auto; }
h1 { font-size: clamp(25px, 4vw, 34px); line-height: 1.3; letter-spacing: -1px; margin: 10px 0; font-weight: 620; }
main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-inline-size: 0; margin-inline: auto; }
h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; }
.workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-block-end: 22px; }
.workspace-heading-copy { min-inline-size: 0; }
.workspace-heading-copy .intro { font-size: 12px; line-height: 1.5; margin: 6px 0 0; }
h2 { font-size: 18px; margin: 0 0 8px; }
h3 { font-size: 15px; margin: 0; }
p { line-height: 1.6; }
@@ -45,19 +57,37 @@ p { line-height: 1.6; }
.page-footer { display: flex; gap: 20px; justify-content: space-between; margin: 0 32px; padding: 20px 0; border-top: 1px solid var(--ca-line); color: var(--ca-muted); font-size: 11px; }
.skip { position: fixed; top: 10px; left: 10px; z-index: 10; clip-path: inset(50%); width: 1px; height: 1px; overflow: hidden; }
.skip:focus { clip-path: none; width: auto; height: auto; padding: 12px; background: var(--ca-surface); }
@media (max-width: 780px) {
@media (max-width: 1250px) {
.workspace-shell { grid-template-columns: 185px minmax(0, 1fr); }
.sidebar { padding-inline: 9px; }
.brand { font-size: 12px; }
.topbar { padding-inline: 24px; }
main { padding: 24px; }
}
@media (max-width: 950px) {
.workspace-shell { grid-template-columns: minmax(0, 1fr); }
.sidebar { padding: 18px; gap: 16px; border-right: 0; border-bottom: 1px solid var(--ca-line); }
nav { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
nav a { padding: 10px 6px; font-size: 12px; text-align: center; }
.sidebar > .eyebrow, .sidebar-footer { display: none; }
.topbar { padding: 14px 18px; flex-wrap: wrap; }
main { padding: 26px 18px; }
.sidebar { position: static; block-size: auto; padding: 12px 18px; border-inline-end: 0; border-block-end: 1px solid var(--ca-line); overflow: visible; }
.brand { margin: 0 0 12px; }
.sidebar-label, .sidebar-footer { display: none; }
.workspace-nav-primary, .workspace-nav-tools { display: flex; flex-wrap: wrap; gap: 4px; }
.workspace-nav-tools { margin: 0; padding-block-start: 5px; border-block-start: 1px solid var(--ca-line); }
nav [data-route] { min-block-size: 32px; padding: 5px 10px; }
.topbar { block-size: auto; min-block-size: 58px; padding: 10px 24px; flex-wrap: wrap; }
}
@media (max-width: 700px) {
main { padding: 20px 14px; }
.topbar { padding-inline: 14px; }
.topbar > span { overflow-wrap: anywhere; }
.workspace-heading { align-items: start; flex-direction: column; }
h1 { font-size: 25px; }
nav [data-route] { font-size: 11px; gap: 6px; }
nav .workspace-icon { inline-size: 14px; block-size: 14px; }
.brand { font-size: 14px; }
.stat-grid { gap: 8px; }
.stat { padding: 16px; }
.stat dd { font-size: 26px; }
.panel { padding: 18px; }
.page-footer { margin: 0 18px; }
.page-footer { margin: 0 14px; flex-wrap: wrap; }
}
@media (forced-colors: active) {
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
+1 -2
View File
@@ -17,8 +17,7 @@
<a class="skip" href="#workspace-main">Skip to content</a>
<div class="workspace-shell">
<aside class="sidebar">
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P<small>±</small></span><span>Prompt Manager<small>ComfyAssets</small></span></a>
<p class="eyebrow">Your workspace</p>
<a class="brand" href="#/images"><span class="brand-mark" aria-hidden="true">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
<nav aria-label="Workspace"></nav>
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
</aside>