* feat(db): add pair schema and stage restore migrations for #193 * feat(database): persist reusable ordered prompt pairs (#193) * feat(api): expose reusable pairs and atomic metadata enrichment (#193) * feat(metadata): resolve canonical prompt pairs with bounded provenance (#193) * feat(scan): enrich image pairs without guessing or duplicate links (#193) * fix(database): preserve pair tags during global maintenance (#193) * feat(tracking): enrich live image pairs and cancel scans safely (#193) * feat(nodes): encode paired prompts and count cached recipe runs (#193) * fix(database): validate restored pair ownership and provenance (#193) * feat(admin): report pair scan outcomes and cancellation (#193) * feat(ui): add paired prompt editor and explicit recipe library for #193 * fix(autocomplete): include reusable pair metadata vocabulary (#193) * fix(pairs): honor recent sorting and conservative scan outcomes (#193) * fix(ui): protect paired draft saves and clarify negative variants (#193) * fix(metadata): preserve bounded paired execution evidence (#193) * fix(tracking): follow actual pair output roles and prefer execution evidence (#193) * docs(pairs): document live validation and retain renderer screenshots (#193) * test(api): inject disposable databases before API startup (#194) * test(api): canonicalize portable temporary gallery roots (#194) * docs(ui): prototype branching negative variant history (#193) * docs(ui): show selected negative beneath positive in timeline concept (#193) * docs: retain approved timeline for later gallery redesign (#193) * test(db): close restore fixture connections before cleanup (#194) * test(db): reuse existing handle guards in all pair fixtures (#194) * chore: clarify rollback test and intentional exception handling (#193)
117 lines
32 KiB
HTML
117 lines
32 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="referrer" content="no-referrer">
|
||
<title>Prompt history · ComfyAssets prototype</title>
|
||
<link rel="stylesheet" href="/toolkit/controls.css">
|
||
<style>
|
||
*{box-sizing:border-box}body{margin:0;--bg-color:#18191c}body.ca-tools{--page:#191a1d;--green:#58d594;--green-ink:#71e6a8;--red:#f05763;--red-ink:#ff9ba3;background:var(--page);font-size:14px;line-height:1.55}body[data-ca-theme=light]{--page:#f1f2f5;--green:#168553;--green-ink:#126d44;--red:#d52430;--red-ink:#a91c29}button,a,input,select,textarea{touch-action:manipulation}button:focus-visible,a:focus-visible{outline:3px solid var(--ca-focus);outline-offset:3px}a{color:var(--ca-focus)}[hidden]{display:none!important}.skip{position:fixed;top:8px;left:12px;z-index:5;clip-path:inset(50%);width:1px;height:1px;overflow:hidden}.skip:focus{clip-path:none;width:auto;height:auto;padding:8px;background:var(--ca-surface)}.topbar{border-bottom:1px solid var(--ca-line);background:var(--ca-surface)}.topbar-inner{max-width:1440px;margin:auto;padding:16px 30px;display:flex;align-items:center;gap:12px}.brand{font-size:15px;font-weight:650}.brand-icon{color:var(--ca-focus);font-size:22px}.muted{color:var(--ca-muted)}.badge{font-size:11px;border:1px solid var(--ca-line);border-radius:5px;padding:3px 8px;display:inline-flex;gap:6px;align-items:center;white-space:nowrap}.badge.blue{color:var(--ca-focus);border-color:var(--ca-accent)}.topbar .demo{margin-inline-start:auto}.theme-label{display:flex;align-items:center;gap:8px;font-size:12px}main{max-width:1440px;margin:auto;padding:28px 30px 42px}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:20px}h1{font-size:27px;line-height:1.25;font-weight:600;letter-spacing:-.6px;margin:0 0 6px}h2{font-size:16px;font-weight:600;margin:0}h3{font-size:14px;font-weight:600;margin:0 0 10px}p{margin:0 0 10px}.page-heading p{margin:0}.sample-note{max-width:350px;font-size:12px;text-align:right;color:var(--ca-muted)}.card{border:1px solid var(--ca-line);border-radius:9px;background:var(--ca-surface);overflow:hidden}.positive-summary{padding:20px 24px;border-bottom:1px solid var(--ca-line)}.prompt-summary-row{display:flex;gap:16px;align-items:flex-start}.negative-summary{margin:8px 0 16px}.negative-summary .prompt-summary-row{padding-top:14px;border-top:1px solid var(--ca-line)}.negative-summary .prompt-summary-row+.prompt-summary-row{margin-top:12px}.negative-symbol{color:var(--red-ink);background:color-mix(in srgb,var(--red) 10%,transparent);border:1px solid var(--red)}.negative-summary .eyebrow{color:var(--red-ink)}.negative-summary .negative-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:16px;line-height:1.7;margin:0}.positive-summary>.stats{margin-inline-start:46px}.role-symbol{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;font-size:20px;font-weight:600;flex-shrink:0}.positive-symbol{color:var(--green-ink);background:color-mix(in srgb,var(--green) 13%,transparent);border:1px solid var(--green)}.positive-summary .content{flex:1;min-width:0}.eyebrow{font-size:11px;letter-spacing:.7px;text-transform:uppercase;color:var(--green-ink);margin-bottom:5px;font-weight:650}.positive-text{font-size:16px;line-height:1.7;max-width:1050px;margin:0 0 10px}.stats{display:flex;align-items:center;gap:18px;flex-wrap:wrap;color:var(--ca-muted);font-size:12px}.stats strong{font-size:14px;color:var(--ca-text);font-weight:600}.history-toolbar{padding:16px 24px;display:flex;justify-content:space-between;gap:14px;align-items:center}.toolbar-actions{display:flex;gap:8px;flex-wrap:wrap}.ca-tools .toolbar-actions button[aria-pressed=true]{background:var(--ca-accent);color:white;border-color:var(--ca-accent)}.history-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;border-top:1px solid var(--ca-line)}.graph-pane{min-width:0;padding:14px 18px 10px}.graph-scroll{overflow-x:auto;border:1px solid var(--ca-line);border-radius:6px;background:var(--ca-field)}.graph{position:relative;min-width:710px;isolation:isolate}.graph svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.axis-label{position:absolute;top:8px;transform:translateX(-50%);font-size:10px;color:var(--ca-muted);font-variant-numeric:tabular-nums}.lane-label{position:absolute;inset-inline-start:18px;font-size:11px;color:var(--ca-muted);max-width:185px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none}.lane-label.positive{color:var(--green-ink)}.graph .node{position:absolute;display:grid;place-items:center;min-width:40px;min-height:40px;width:40px;height:40px;padding:0;transform:translate(-50%,-50%);border:0;background:transparent;border-radius:50%;z-index:1}.node .circle{width:23px;height:23px;display:grid;place-items:center;border:2px solid var(--red);border-radius:50%;background:var(--red);font-size:16px;line-height:1;color:#fff;font-weight:650;box-shadow:0 0 0 5px var(--ca-field)}.node.positive-node .circle{background:var(--green);border-color:var(--green);color:#102b20}.node.saved .circle{background:var(--ca-field);color:var(--red-ink);border-style:dashed}.node.run-node .circle{width:13px;height:13px;font-size:0;border-width:2px;background:var(--ca-field);box-shadow:0 0 0 4px var(--ca-field)}.graph .node:hover{background:color-mix(in srgb,var(--ca-focus) 10%,transparent)}.node[aria-pressed=true] .circle{outline:3px solid var(--ca-focus);outline-offset:5px}.node.dim{opacity:.34}.dot-caption{position:absolute;top:25px;color:var(--ca-muted);font-size:10px;white-space:nowrap;pointer-events:none}.legend{display:flex;gap:15px;align-items:center;flex-wrap:wrap;font-size:11px;color:var(--ca-muted);padding:12px 4px 4px}.legend-item{display:inline-flex;gap:6px;align-items:center}.mini-dot{width:10px;height:10px;border-radius:50%;display:inline-block;background:var(--red)}.mini-dot.green{background:var(--green)}.mini-dot.hollow{background:transparent;border:1.5px dashed var(--red)}.mini-dot.run{width:7px;height:7px;background:transparent;border:1.5px solid var(--red)}.inspector{border-inline-start:1px solid var(--ca-line);padding:20px;background:color-mix(in srgb,var(--ca-field) 40%,var(--ca-surface));min-width:0}.inspector .eyebrow{color:var(--red-ink)}.inspector .eyebrow.overview{color:var(--ca-muted)}.prompt-block{white-space:pre-wrap;overflow-wrap:anywhere;padding:12px;background:var(--ca-field);border:1px solid var(--ca-line);border-radius:5px;font-size:13px;margin:12px 0;max-height:180px;overflow:auto}.inspector p{font-size:12px;color:var(--ca-muted)}.run-list{display:grid;gap:7px;margin:12px 0;padding:0;list-style:none}.run-list button{width:100%;justify-content:space-between;white-space:normal;text-align:start;padding:8px 10px;border-color:var(--ca-line)}.run-list button[aria-pressed=true]{border-color:var(--ca-focus);color:var(--ca-focus)}.run-detail{font-size:11px;color:var(--ca-muted)}.hint{font-size:12px;color:var(--ca-muted)}.compare-pane{display:grid;gap:8px}.diff-terms{font-size:12px;line-height:2;overflow-wrap:anywhere}.diff-terms ins{background:color-mix(in srgb,var(--ca-accent) 18%,transparent);color:var(--ca-focus);text-decoration:none;padding:2px 4px;border-radius:3px}.diff-terms del{color:var(--red-ink);background:color-mix(in srgb,var(--red) 12%,transparent);padding:2px 4px;border-radius:3px}.gallery{border-top:1px solid var(--ca-line);padding:20px 24px 24px}.gallery-heading{display:flex;justify-content:space-between;align-items:center;gap:15px;flex-wrap:wrap;margin-bottom:14px}.gallery-heading h2{margin-bottom:4px}.gallery-heading p{font-size:12px;margin:0;color:var(--ca-muted)}.filters{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px}.filters button{font-size:12px;border-color:var(--ca-line);min-height:34px}.filters button[aria-pressed=true]{color:white;background:var(--ca-accent);border-color:var(--ca-accent)}.image-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.ca-tools .image-card{display:block;white-space:normal;min-width:0;padding:0;text-align:start;border-color:var(--ca-line);overflow:hidden;background:var(--ca-field)}.image-card img{display:block;width:100%;aspect-ratio:1.6;object-fit:cover}.image-info{display:block;padding:10px 12px}.image-title{display:block;font-size:12px;margin-bottom:3px}.image-meta{display:flex;gap:6px;justify-content:space-between;color:var(--ca-muted);font-size:10px}.negative-badge{color:var(--red-ink)}.empty{padding:36px;text-align:center;border:1px dashed var(--ca-line);border-radius:6px;color:var(--ca-muted);grid-column:1/-1}.status-line{font-size:12px;min-height:20px;padding:12px 0 0;color:var(--ca-muted)}.footer{display:flex;justify-content:space-between;gap:16px;font-size:11px;color:var(--ca-muted);padding:16px 0}.ca-tools dialog{color:var(--ca-text);background:var(--ca-surface);border:1px solid var(--ca-border);border-radius:9px;padding:24px;width:min(610px,calc(100vw - 32px));max-height:90dvh;overflow:auto}.ca-tools dialog::backdrop{background:#0009}.dialog-header{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:18px}.dialog-header h2{font-size:18px}.form-field{display:grid;gap:6px;margin-bottom:16px;font-size:12px}.form-field input,.form-field textarea{width:100%}.form-field textarea{min-height:140px;resize:vertical}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}.preview-image{width:100%;display:block;border-radius:5px;margin-bottom:15px}.dialog-description{font-size:12px;color:var(--ca-muted)}.ca-tools .mobile-summary{display:none}.ca-tools button.expand{font-size:11px;padding:3px 8px;border-color:var(--ca-line)}
|
||
@media(min-width:1500px){.image-grid{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(max-width:1050px){.history-layout{grid-template-columns:minmax(0,1fr) 290px}.image-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.topbar-inner,main{padding-inline:20px}.graph-pane{padding-inline:12px}.inspector{padding:16px}}@media(max-width:780px){.history-layout{grid-template-columns:minmax(0,1fr)}.inspector{border-inline-start:0;border-top:1px solid var(--ca-line)}.history-toolbar{align-items:flex-start;flex-direction:column}.sample-note{display:none}.positive-summary{padding:16px}.history-toolbar,.gallery{padding:16px}.topbar-inner,main{padding-inline:12px}.topbar .crumb{display:none}.image-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.positive-text,.negative-summary .negative-text{font-size:14px}.graph-pane{padding:10px}.footer{flex-direction:column;gap:4px}.theme-label>span{display:none}.page-heading{margin-bottom:16px}.topbar .demo{font-size:10px}h1{font-size:23px}.stats{gap:12px}}@media(pointer:coarse){.graph .node{min-width:44px;min-height:44px}.ca-tools button{min-height:40px}}@media(prefers-reduced-motion:no-preference){button{transition:background-color .12s,border-color .12s}}@media(forced-colors:active){.node .circle{border-color:ButtonText}.node[aria-pressed=true] .circle{outline-color:Highlight}.graph path{stroke:CanvasText!important}}
|
||
</style>
|
||
</head>
|
||
<body class="ca-tools">
|
||
<a class="skip" href="#content">Skip to prompt history</a>
|
||
<header class="topbar"><div class="topbar-inner"><span class="brand-icon" aria-hidden="true">◈</span><span class="brand">ComfyAssets</span><span class="muted crumb">/ Prompt Manager / Admin</span><span class="badge blue demo">Interactive concept</span><label class="theme-label" for="theme"><span>Theme</span><select id="theme"><option value="dark">Dark</option><option value="light">Light</option><option value="system">System</option></select></label></div></header>
|
||
<main id="content" tabindex="-1">
|
||
<div class="page-heading"><div><h1>Prompt history</h1><p class="muted">One positive prompt. Every negative experiment.</p></div><p class="sample-note">Sample data and illustrative previews.<br>Everything here stays in this browser tab.</p></div>
|
||
<article class="card">
|
||
<header class="positive-summary"><div class="prompt-summary-row"><span class="role-symbol positive-symbol" aria-hidden="true">+</span><div class="content"><div class="eyebrow">Positive prompt · unchanged</div><p class="positive-text" id="positive-text">A remote alpine observatory at blue hour, warm light through glass, dramatic mountain peaks, cinematic composition, atmospheric mist, fine detail</p></div><span class="badge">Landscapes</span></div><div class="negative-summary" id="selected-negative"></div><div class="stats" id="stats"></div></header>
|
||
<div class="history-toolbar"><div><h2>Negative variants</h2><span class="hint" id="graph-help">Click a red dot to inspect a negative. Small dots are repeat runs. Select a variant for its full run history.</span></div><div class="toolbar-actions"><button id="compare" type="button" aria-pressed="false">Compare negatives</button><button id="new-variant" class="ca-primary" type="button">+ Add negative</button></div></div>
|
||
<div class="history-layout"><div class="graph-pane"><div class="graph-scroll"><div class="graph" id="graph" role="group" aria-label="Positive prompt and negative variant timeline" aria-describedby="graph-help"></div></div><div class="legend"><span class="legend-item"><i class="mini-dot green" aria-hidden="true"></i>+ Positive</span><span class="legend-item"><i class="mini-dot" aria-hidden="true"></i>− Negative variant</span><span class="legend-item"><i class="mini-dot run" aria-hidden="true"></i>Run</span><span class="legend-item"><i class="mini-dot hollow" aria-hidden="true"></i>Saved, not run</span></div></div><aside class="inspector" id="inspector" aria-label="Selection details"></aside></div>
|
||
<section class="gallery"><div class="gallery-heading"><div><h2>Shared gallery <span class="muted" id="image-count"></span></h2><p id="gallery-description">All images from this positive prompt, together.</p></div><span class="badge">1 positive · 1 gallery</span></div><div class="filters" id="filters" role="group" aria-label="Filter gallery by negative variant"></div><div class="image-grid" id="images"></div></section>
|
||
</article>
|
||
<div class="status-line" role="status" aria-atomic="true" id="status">Select a negative or a run to explore its images.</div>
|
||
<footer class="footer"><span>Concept only · No ComfyUI or database connection</span><span>Time flows left to right · 3 images per sample run</span></footer>
|
||
</main>
|
||
<dialog id="add-dialog" aria-labelledby="add-title"><div class="dialog-header"><h2 id="add-title">Add a negative variant</h2><button type="button" id="close-add" aria-label="Close add negative dialog">×</button></div><p class="dialog-description">The positive stays unchanged. A saved variant starts with a hollow dot until its first run.</p><form id="add-form"><label class="form-field" for="variant-name">Label (optional)<input id="variant-name" name="name" maxlength="60" placeholder="e.g. Cleaner architecture"></label><label class="form-field" for="variant-text">Negative prompt<textarea id="variant-text" name="negative" maxlength="2000" placeholder="blurry, low quality, watermark…" aria-describedby="negative-help"></textarea></label><p class="hint" id="negative-help">Leave empty to explicitly use no negative prompt. Existing text is reused.</p><div class="dialog-actions"><button type="button" id="cancel-add">Cancel</button><button type="submit" class="ca-primary">Save variant</button></div></form></dialog>
|
||
<dialog id="image-dialog" aria-labelledby="image-title"><div class="dialog-header"><h2 id="image-title">Generated image</h2><button type="button" id="close-image" aria-label="Close image preview">×</button></div><div id="image-detail"></div></dialog>
|
||
<script type="module">
|
||
import {createButton,createScope,mountTheme} from '/toolkit/core.js';
|
||
const scope=createScope();mountTheme(document.body,null,scope);
|
||
const $=id=>document.getElementById(id);
|
||
const variants=[
|
||
{id:'n1',name:'Quality baseline',text:'blurry, low quality, low resolution',created:4},
|
||
{id:'n2',name:'Clean architecture',text:'blurry, low quality, low resolution, distorted architecture, watermark, text',created:12},
|
||
{id:'n3',name:'No negative',text:'',created:24},
|
||
{id:'n4',name:'Atmosphere experiment',text:'blurry, low quality, low resolution, flat lighting, oversaturated',created:32},
|
||
];
|
||
const runs=[{id:'r1',variant:'n1',minute:4,seed:42001},{id:'r2',variant:'n2',minute:12,seed:42004},{id:'r3',variant:'n1',minute:16,seed:42007},{id:'r4',variant:'n3',minute:24,seed:42010}];
|
||
let selected=null,selectedRun=null,comparing=false,comparison=[],clock=36,expanded=false;
|
||
const time=m=>new Date(Date.UTC(2026,9,17,12,m)).toISOString().slice(11,16);
|
||
const getVariant=id=>variants.find(v=>v.id===id);
|
||
const variantRuns=id=>runs.filter(r=>r.variant===id);
|
||
const sampleImages=()=>runs.flatMap(run=>Array.from({length:3},(_,index)=>({id:`${run.id}-${index}`,run,seed:run.seed+index,index})));
|
||
function el(tag,className,text){const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=text;return node;}
|
||
function announce(text){$('status').textContent=text;}
|
||
function setTheme(){const dark=$('theme').value==='system'?matchMedia('(prefers-color-scheme: dark)').matches:$('theme').value==='dark';document.body.style.setProperty('--bg-color',dark?'#18191c':'#f1f2f5');}
|
||
scope.listen($('theme'),'change',setTheme);scope.listen(matchMedia('(prefers-color-scheme: dark)'),'change',setTheme);setTheme();
|
||
function choose(id,run=null){
|
||
if(comparing&&id){selectedRun=null;if(comparison.includes(id))comparison=comparison.filter(item=>item!==id);else comparison=comparison.length===2?[comparison[0],id]:[...comparison,id];selected=id;}
|
||
else{selected=id;selectedRun=run;if(!id){comparing=false;comparison=[];}}
|
||
render();announce(comparing?`${comparison.length} of 2 negatives selected for comparison.`:id?`${getVariant(id).name}${run?`, ${run}`:''}. ${visibleImages().length} images shown.`:'All 12 sample images shown.'.replace('12',String(sampleImages().length)));
|
||
}
|
||
function visibleImages(){return sampleImages().filter(image=>comparing?comparison.length===0||comparison.includes(image.run.variant):selectedRun?image.run.id===selectedRun:selected?image.run.variant===selected:true);}
|
||
function renderGraph(){
|
||
const graph=$('graph');const focusKey=graph.contains(document.activeElement)?document.activeElement.dataset.key:null;
|
||
graph.replaceChildren();const shown=expanded?variants:variants.slice(0,5);const width=900,height=100+shown.length*66,end=Math.max(40,clock+4);graph.style.height=`${height}px`;
|
||
const x=minute=>65+minute/end*770;
|
||
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox',`0 0 ${width} ${height}`);svg.setAttribute('preserveAspectRatio','none');svg.setAttribute('aria-hidden','true');graph.append(svg);
|
||
function path(d,color,dashed=false,opacity=1){const p=document.createElementNS(svg.namespaceURI,'path');p.setAttribute('d',d);p.setAttribute('stroke',color);p.setAttribute('fill','none');p.setAttribute('stroke-width','2');p.setAttribute('opacity',opacity);if(dashed)p.setAttribute('stroke-dasharray','5 5');svg.append(p);}
|
||
for(let minute=0;minute<=end;minute+=8){const xx=x(minute);path(`M${xx} 30V${height-20}`,'var(--ca-line)',true,.35);const label=el('span','axis-label',time(minute));label.style.left=`${xx/9}%`;graph.append(label);}
|
||
path(`M${x(0)} 64H${x(end)}`,'var(--green)',false,.75);
|
||
function node({xx,y,label,key,cls,symbol,active,click,caption}){const b=createButton('','node '+cls);b.dataset.key=key;b.style.left=`${xx/9}%`;b.style.top=`${y}px`;b.setAttribute('aria-label',label);b.setAttribute('title',label);b.setAttribute('aria-pressed',String(active));const circle=el('span','circle',symbol);circle.setAttribute('aria-hidden','true');b.append(circle);if(caption){const text=el('span','dot-caption',caption);text.setAttribute('aria-hidden','true');b.append(text);}b.addEventListener('click',click);graph.append(b);}
|
||
node({xx:x(0),y:64,label:'Positive prompt, unchanged. Show all images',key:'positive',cls:'positive-node',symbol:'+',active:!selected&&!comparing,click:()=>choose(null)});
|
||
const title=el('span','lane-label positive','Positive prompt · unchanged');title.style.top='38px';title.style.left=`${x(0)/9+3}%`;graph.append(title);
|
||
shown.forEach((variant,index)=>{
|
||
const y=130+index*66,vruns=variantRuns(variant.id),first=variant.created,active=comparing?comparison.includes(variant.id):selected===variant.id;
|
||
const dim=selected&&!active? .28:.85;
|
||
path(`M${x(Math.max(0,first-6))} 64 C${x(first-2)} 64 ${x(first-4)} ${y} ${x(first)} ${y}H${x(end)}`,'var(--red)',vruns.length===0,dim);
|
||
const label=el('span','lane-label',`${variant.name} · ${vruns.length?`${vruns.length} ${vruns.length===1?'run':'runs'}`:'saved'}`);label.style.top=`${y-25}px`;label.style.left=`${x(first)/9+3}%`;label.style.maxWidth=`${Math.max(55,900-x(first)-40)/9}%`;label.title=variant.name;graph.append(label);
|
||
node({xx:x(first),y,label:`Negative: ${variant.name}. ${variant.text||'Explicitly empty'}. ${vruns.length} runs, ${vruns.length*3} images`,key:variant.id,cls:`${vruns.length?'':'saved'}${selected&&!active?' dim':''}`,symbol:'−',active,click:()=>choose(variant.id)});
|
||
vruns.slice(1).forEach(run=>node({xx:x(run.minute),y,label:`Run ${run.id.slice(1)}: ${variant.name}, ${time(run.minute)}, 3 images, seed ${run.seed}`,key:run.id,cls:`run-node${selected&&!active?' dim':''}`,symbol:'',active:selectedRun===run.id,click:()=>{if(comparing)choose(variant.id);else choose(variant.id,run.id);},caption:`#${run.id.slice(1)}`}));
|
||
});
|
||
if(focusKey)graph.querySelector(`[data-key="${focusKey}"]`)?.focus({preventScroll:true});
|
||
}
|
||
function termDiff(a,b){const before=a.split(',').map(s=>s.trim()).filter(Boolean),after=b.split(',').map(s=>s.trim()).filter(Boolean);const root=el('div','diff-terms');for(const term of before){const node=el(after.includes(term)?'span':'del','',after.includes(term)?term:`− ${term}`);root.append(node,document.createTextNode(', '));}for(const term of after.filter(term=>!before.includes(term)))root.append(el('ins','',`+ ${term}`),document.createTextNode(', '));if(!before.length&&!after.length)root.textContent='Both negatives are explicitly empty.';return root;}
|
||
function simulate(id){const minute=clock;clock+=4;runs.push({id:`r${runs.length+1}`,variant:id,minute,seed:42001+runs.length*3});const variant=getVariant(id);if(variantRuns(id).length===1)variant.created=minute;selected=id;selectedRun=null;render();announce(`Simulated one run of ${variant.name}: 3 images added. The positive prompt is unchanged.`);}
|
||
function renderSelectedNegative(){
|
||
const target=$('selected-negative');target.replaceChildren();
|
||
const ids=comparing?comparison:selected?[selected]:[];
|
||
function row(label,text){const item=el('div','prompt-summary-row'),symbol=el('span','role-symbol negative-symbol','−');symbol.setAttribute('aria-hidden','true');const content=el('div','content');content.append(el('div','eyebrow',label),el('p','negative-text',text));item.append(symbol,content);target.append(item);}
|
||
if(!ids.length){row(comparing?'Negative prompts · comparison':'Negative prompts · all variants',comparing?'Select two red dots to compare their negative prompts.':'Multiple negative variants are included. Select a red dot to see its negative prompt here.');return;}
|
||
ids.forEach((id,index)=>{const variant=getVariant(id);row(`Negative prompt · ${comparing?(index===0?'From':'To')+' · ':''}${variant.name}${selectedRun?` · Run #${selectedRun.slice(1)}`:''}`,variant.text||'No negative prompt (explicitly empty)');});
|
||
if(comparing&&ids.length===1)target.append(el('p','hint','Select another red dot to show the second negative below.'));
|
||
}
|
||
function renderInspector(){
|
||
const target=$('inspector');target.replaceChildren();
|
||
if(comparing){target.append(el('div','eyebrow overview','Compare negatives'),el('h3','',comparison.length===2?'What changed?':'Pick two red dots'),el('p','',comparison.length===2?'Changes are shown by comma-separated term. Blue is added; struck-through red is removed.':'Select two different negative variants in the timeline or filters.'));
|
||
comparison.forEach((id,index)=>{const v=getVariant(id);target.append(el('span','badge',`${index===0?'From':'To'} · ${v.name}`),el('div','prompt-block',v.text||'No negative prompt (explicitly empty)'));});
|
||
if(comparison.length===2)target.append(termDiff(getVariant(comparison[0]).text,getVariant(comparison[1]).text));return;
|
||
}
|
||
if(!selected){target.append(el('div','eyebrow overview','One positive · one gallery'),el('h3','','Explore the branches'),el('p','','Each red branch is a reusable negative. Small dots are repeat runs; each sample run produced three images.'),el('div','prompt-block','Click a red dot → inspect its negative\nClick a small dot → see that run\nCompare negatives → see changed terms'),el('p','','The hollow red dot is a saved idea that has not been run. Saving it does not create another positive prompt.'));return;}
|
||
const variant=getVariant(selected),vruns=variantRuns(selected);target.append(el('div','eyebrow',`− ${vruns.length?'Negative variant':'Saved · not run yet'}`),el('h3','',variant.name),el('div','prompt-block',variant.text||'No negative prompt (explicitly empty)'),el('p','',`${vruns.length} ${vruns.length===1?'run':'runs'} · ${vruns.length*3} images · Positive unchanged`));
|
||
if(vruns.length){target.append(el('h3','','Run history'));const list=el('ul','run-list');vruns.forEach(run=>{const li=el('li'),b=createButton('');b.append(el('span','',`Run #${run.id.slice(1)} · ${time(run.minute)}`),el('span','run-detail','3 images'));b.setAttribute('aria-label',`Show run ${run.id.slice(1)} images for ${variant.name}`);b.setAttribute('aria-pressed',String(selectedRun===run.id));b.addEventListener('click',()=>choose(selected,run.id));b.dataset.focusKey=`history-${run.id}`;li.append(b);list.append(li);});target.append(list);}
|
||
const simulateButton=createButton('Simulate run · 3 images','ca-primary');simulateButton.id='simulate-run';simulateButton.addEventListener('click',()=>simulate(variant.id));target.append(simulateButton,el('p','hint','Prototype action only. No inference is started.'));
|
||
}
|
||
function scene(image){const n=image.seed%9;const skies=[['#15283d','#688ab0'],['#26344b','#b1a4bb'],['#152e40','#75a3ab']][n%3];const shifted=n*6;const svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 300"><defs><linearGradient id="sky" x2="0" y2="1"><stop stop-color="${skies[0]}"/><stop offset="1" stop-color="${skies[1]}"/></linearGradient><linearGradient id="snow" x2=".5" y2="1"><stop stop-color="#b7c6d8"/><stop offset="1" stop-color="#2d4b66"/></linearGradient></defs><rect width="480" height="300" fill="url(#sky)"/><circle cx="${355-shifted}" cy="${48+n*2}" r="15" fill="#d9e4ef" opacity=".7"/><path d="M0 190 90 55 145 130 250 36 356 156 426 70 480 144V300H0" fill="url(#snow)"/><path d="m90 55-28 62 29-12 18 24 36 1Zm160-19-39 84 33-17 18 22 24-18Z" fill="#dae1eb" opacity=".7"/><path d="M0 228 98 182 180 220 290 169 480 233V300H0" fill="#21394a"/><path d="M${181+shifted} 200h110v57H${181+shifted}Z" fill="#182731"/><path d="m${174+shifted} 200 62-38 63 38Z" fill="#4c6576"/><path d="M${193+shifted} 205h84v32h-84Z" fill="#e5ae67"/><path d="M${220+shifted} 204v34m27-34v34" stroke="#273945" stroke-width="4"/><path d="M0 270 Q140 236 300 264 T480 256V300H0" fill="#90a8b7" opacity=".32"/><path d="M0 285 Q140 256 300 280 T480 274V300H0" fill="#adc0cb" opacity=".2"/></svg>`;return`data:image/svg+xml,${encodeURIComponent(svg)}`;}
|
||
function openImage(image){const variant=getVariant(image.run.variant);$('image-title').textContent=`Run #${image.run.id.slice(1)} · Seed ${image.seed}`;const detail=$('image-detail');detail.replaceChildren();const img=el('img','preview-image');img.src=scene(image);img.alt='Illustrative alpine observatory preview';detail.append(img,el('div','eyebrow','+ Positive · unchanged'),el('p','dialog-description',$('positive-text').textContent),el('h3','',`− ${variant.name}`),el('div','prompt-block',variant.text||'No negative prompt (explicitly empty)'),el('p','hint',`Run at ${time(image.run.minute)} · 3 images in this batch · Illustrative sample`));$('image-dialog').showModal();}
|
||
function renderGallery(){
|
||
const all=sampleImages(),shown=visibleImages();$('image-count').textContent=`· ${shown.length}${shown.length!==all.length?` of ${all.length}`:''} images`;
|
||
$('gallery-description').textContent=comparing?'Comparing variants inside the same gallery.':selectedRun?`Run #${selectedRun.slice(1)} · ${getVariant(selected).name} · Same positive prompt`:selected?`${getVariant(selected).name} · Same positive prompt`:'All images from this positive prompt, together.';
|
||
const filters=$('filters');filters.replaceChildren();const allButton=createButton(`All images · ${all.length}`);allButton.dataset.focusKey='filter-all';allButton.setAttribute('aria-pressed',String(!selected&&!comparing));allButton.addEventListener('click',()=>choose(null));filters.append(allButton);
|
||
variants.forEach(variant=>{const b=createButton(`− ${variant.name} · ${variantRuns(variant.id).length*3}`);b.dataset.focusKey=`filter-${variant.id}`;b.setAttribute('aria-pressed',String(comparing?comparison.includes(variant.id):selected===variant.id));b.addEventListener('click',()=>choose(variant.id));filters.append(b);});
|
||
const grid=$('images');grid.replaceChildren();if(!shown.length)grid.append(el('div','empty','Saved for later. Simulate a run to add images to this gallery.'));
|
||
shown.forEach(image=>{const v=getVariant(image.run.variant),b=createButton('','image-card');b.setAttribute('aria-label',`Open ${v.name}, run ${image.run.id.slice(1)}, seed ${image.seed}`);const img=el('img');img.src=scene(image);img.alt='';img.loading='lazy';const info=el('span','image-info');info.append(el('span','image-title',`Observatory · ${image.seed}`));const meta=el('span','image-meta');meta.append(el('span','negative-badge',`− ${v.name}`),el('span','',`Run #${image.run.id.slice(1)}`));info.append(meta);b.append(img,info);b.addEventListener('click',()=>openImage(image));grid.append(b);});
|
||
}
|
||
function render(){const focusKey=document.activeElement?.dataset.focusKey,focusId=document.activeElement?.id;const used=variants.filter(v=>variantRuns(v.id).length).length;const stats=$('stats');stats.replaceChildren();for(const [count,label]of[[runs.length,'tracked runs'],[sampleImages().length,'images'],[used,'used negatives'],[variants.length-used,'saved idea'+(variants.length-used===1?'':'s')]]){const span=el('span');span.append(el('strong','',String(count)),document.createTextNode(` ${label}`));stats.append(span);}const compare=$('compare');compare.setAttribute('aria-pressed',String(comparing));compare.textContent=comparing?'Exit comparison':'Compare negatives';renderSelectedNegative();renderGraph();renderInspector();renderGallery();if(focusKey)document.querySelector(`[data-focus-key="${focusKey}"]`)?.focus({preventScroll:true});else if(focusId)$(focusId)?.focus({preventScroll:true});}
|
||
scope.listen($('compare'),'click',()=>{comparing=!comparing;comparison=comparing&&selected?[selected]:[];selectedRun=null;if(!comparing)selected=null;render();announce(comparing?'Choose two negative variants to compare.':'Showing all variants.');});
|
||
scope.listen($('new-variant'),'click',()=>{$('add-form').reset();$('add-dialog').showModal();});
|
||
for(const id of['close-add','cancel-add'])scope.listen($(id),'click',()=>$('add-dialog').close());scope.listen($('close-image'),'click',()=>$('image-dialog').close());
|
||
scope.listen($('add-form'),'submit',event=>{event.preventDefault();const text=$('variant-text').value.trim(),existing=variants.find(v=>v.text.trim().toLowerCase()===text.toLowerCase());if(existing){$('add-dialog').close();comparing=false;choose(existing.id);announce('This negative already exists. Selected its saved variant.');return;}const id=`n${variants.length+1}`;variants.push({id,name:$('variant-name').value.trim()||`Negative ${variants.length+1}`,text,created:clock});clock+=4;expanded=true;$('add-dialog').close();comparing=false;choose(id);announce('Negative saved as a hollow dot. No run or images added.');});
|
||
scope.listen(window,'pagehide',()=>scope.dispose());render();
|
||
</script>
|
||
</body>
|
||
</html>
|