From bb8f600a1d6bb82abba07c3a47c99eebb3745f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:09:25 +0000 Subject: [PATCH 1/5] studio: the project as a board, read like a design file; served, where a person decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uxcli studio [dir] [--serve [--port=N]] [--shot=FILE] [--json]` builds one page, .uxcli/studio/index.html, from what is under .uxcli/ and the shipped library: each journey a band, its workflows side by side as lanes, each step a column with the screen as drawn (variants A/B, the pick), as built (the last walk's screenshot, experience findings pinned where the browser found the element, a dashed bar when below the fold) and as each named version left it, steps joined by arrows. Pan by dragging, zoom by scroll or pinch, fit, an outline of every step with a dot for findings and open picks, an inspector for the frame in hand (variants and what the agent says of each, the pick, a redraw asked; metrics, findings with sources, verdicts; a version's note and numbers), and Context (actors, unknowns, insights) and Library (lenses, templates) tabs. The model is inlined, so the page works from disk. --serve runs it on 127.0.0.1, rebuilds and pushes a refresh when a file under .uxcli/ changes, serves nothing outside .uxcli/, and takes the two decisions a person makes there: choose a drawing, written as pick.json with the drawing's hash, or ask for a redraw, written as revise.json — each through the parser the file is read with, and neither overwrites. --shot photographs the fitted board, so an agent working through the CLI can look at the same thing the person does. Skill: look at the board with --shot; send the person to --serve when a pick is theirs. .uxcli/studio/ and .uxcli/experience/ are derived and ignored. Gate: studio-pairs (24) — one column per step, the pick and the drawings on the board; a pick of a missing drawing refused, a valid one written with its hash, a second refused, an empty redraw refused; the server answering page and model, refusing a path outside .uxcli/ and an unparseable pick; in Chrome no page error, drag and zoom move the canvas, a frame fills the inspector, and the four page probes find no fail on the board. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- .claude/skills/uxcli/SKILL.md | 6 ++ .gitignore | 2 + CHANGELOG.md | 9 ++ README.md | 17 ++++ ROADMAP.md | 7 ++ bin/uxcli.js | 16 ++++ skills/uxcli/SKILL.md | 6 ++ src/core/studio-page.js | 175 ++++++++++++++++++++++++++++++++++ src/core/studio.js | 73 ++++++++++++++ src/studio.js | 132 +++++++++++++++++++++++++ src/suites.js | 1 + test/studio-pairs.mjs | 95 ++++++++++++++++++ 12 files changed, 539 insertions(+) create mode 100644 src/core/studio-page.js create mode 100644 src/core/studio.js create mode 100644 src/studio.js create mode 100644 test/studio-pairs.mjs diff --git a/.claude/skills/uxcli/SKILL.md b/.claude/skills/uxcli/SKILL.md index d3e5577..87e67fb 100644 --- a/.claude/skills/uxcli/SKILL.md +++ b/.claude/skills/uxcli/SKILL.md @@ -65,6 +65,12 @@ Use this bounded path for ordinary UI work. Read a reference only where a step n Then read what the person goes through: `npx -y @junixlabs/uxcli experience --page` — steps, clicks, typing, scrolls, waits and a keystroke-level estimate per journey, each finding pinned on the step's screenshot, and what moved since the walk before. Report each finding in its words; they never block. + To see the whole board — every journey's screens as drawn, as built and per version, findings + pinned — run `npx -y @junixlabs/uxcli studio --shot=.uxcli/studio/board.png` and look at the picture. + When a person has to pick a drawing or look at the work, tell them to open + `npx -y @junixlabs/uxcli studio --serve`: they pan and zoom it like a design file, choose a drawing or + ask for a redraw there, and it refreshes as you change files. Their choice lands in `pick.json` or + `revise.json`; read it from there. 6. **Say done only at exit 0**, and say what was measured. Anything else, say what stands in the way. `references/before-done.md` is the full sequence and the list of what exit 0 does not cover. diff --git a/.gitignore b/.gitignore index 165f49a..5018991 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ experiments/*/results/*/transcript.jsonl **/.uxcli/mockups/.shots/ **/.uxcli/mockups/index.html **/.uxcli/map/ +**/.uxcli/studio/ +**/.uxcli/experience/ # experiment results stay on the machine that ran them experiments/*/results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index adc2656..f5bbfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ Entries are written per release. The log is the source material, not the text. ### Added — the designer's habits: a library to cite, templates to start from, the walk measured +- `uxcli studio [dir] [--serve [--port=N]] [--shot=FILE] [--json]`: the project as a board, laid out like + a design file — each journey a band, each workflow a lane, each step a column with the screen as drawn + (variants A/B, the pick), as built (the last walk's screenshot, findings pinned) and as each named + version left it; pan, zoom, fit, an outline, an inspector, and Context and Library tabs. One HTML file + under `.uxcli/studio/`. `--serve` runs it on localhost, refreshes it as files under `.uxcli/` change, + serves nothing outside `.uxcli/`, and takes a person's pick or redraw request through the parser the + file is read with, creating only. `--shot` photographs the board for an agent. Gate: `studio-pairs`, + which also runs the four page probes on the board. + - `uxcli experience [dir|run] [--page] [--json]`: what the person goes through on each journey's last walk, read from the trace — steps, clicks, fields and characters typed, scrolls, the settle time of each step in Nielsen's bands (100 ms, 1 s, 10 s), and a keystroke-level estimate of the time a diff --git a/README.md b/README.md index 00319c1..aa809f4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,22 @@ have, and an instrument to prove the result works for the person using it: uxcli writes no claim about your users. Every sentence on a card was authored by the project, quoted from a cited source, or measured by the browser. +## The studio + +```bash +uxcli studio --serve # http://127.0.0.1:4317 — the project as a board, refreshed as the agent works +``` + +A canvas laid out like a design file, built from what is under `.uxcli/` and nothing else: each journey +a band, each workflow a lane, each step a column; in each column the screen as drawn (the variants, A/B, +the pick), as built (the last walk's screenshot with its findings pinned where the browser found the +element) and as each named version left it. Drag to pan, scroll or pinch to zoom, click a frame for what +the files and the walk say about it. Context (actors, unknowns, insights) and the library (lenses, +templates) are a tab away. Served, it is also where a person decides — choose a drawing, or ask for a +redraw — written as `pick.json` or `revise.json` through the same parser, creating only. The agent +works through the CLI and the skill; `uxcli studio --shot=FILE` gives it the same board as a picture. +Free, local, no account: the page is one HTML file. + ## Install | Agent | Skill | Instrument | @@ -198,6 +214,7 @@ uxcli review / --lens= --write # an empty review beside uxcli review check # every review complete, fresh, and not contradicted by a probe uxcli version [save ] # name a walk so it is kept; experience --journey= --from= --to= compares two uxcli experience [dir] [--page] # what the person goes through: steps, typing, scrolls, waits, an estimate in seconds; findings pinned on screenshots +uxcli studio [dir] [--serve] [--shot=FILE] # the board: every journey's screens drawn, built and per version, pan and zoom like a design file; served, a person picks there uxcli map [dir] # the journey map page: MODEL · RUN · DIFF · IMPACT, step by step, with the evidence uxcli run # measure a journey --env --origin --viewport --json --out uxcli run # measure one screen --prove --state=FILE --src=DIR diff --git a/ROADMAP.md b/ROADMAP.md index ec6f2f3..10a7142 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,6 +72,13 @@ the CRM example a planted extra step, a missing progress signal and a lost value the run packet and the experience report; a schema of their own is next. - Exit: on the CRM example, v1 and v2 side by side, with the metrics that moved. +## The studio + +- Done 2026-10-03: `uxcli studio` — one local board for the project, read like a design file and built + only from `.uxcli/`; served, the place a person picks or asks for a redraw while the agent works + through the CLI. Next: version-to-version view toggled on the board; comments a person leaves on a + frame, written as a redraw request scoped to it; export of a frame as PNG for an issue. + ## 4. Walkthrough and personas - Cognitive walkthrough at each step: the four questions answered with the screenshot, as findings. diff --git a/bin/uxcli.js b/bin/uxcli.js index eb93fc3..959db9d 100755 --- a/bin/uxcli.js +++ b/bin/uxcli.js @@ -44,6 +44,7 @@ const usage = `usage: uxcli gate run every probe's falsification pair; exit 1 unless all hold uxcli lens [show ] the shipped lenses — named designers' viewpoints by kind of UI (marketing, content, data, workspace, shop, transaction) uxcli experience [dir|run] [--page] [--json] what the person goes through on each journey's last walk: steps, clicks, typing, scrolls, waits, a keystroke-level estimate, and what changed since the walk before; findings only, never a fail + uxcli studio [dir] [--serve [--port=N]] [--shot=FILE] [--json] the board: every journey as a canvas — each step drawn (variants, the pick), built (the last walk, findings pinned) and in each named version; pan, zoom, inspect; --serve on localhost refreshes as files change and lets a person pick or ask for a redraw uxcli version [save [--note=…] [--run=DIR] [--proposal=proposals/P-x.json]] name a walk of a journey so it is kept and can be compared: uxcli experience --journey= --from= --to= [--page] uxcli template [show | apply [dir]] where to start for a kind of product (workspace, shop, landing): screens and their lens, journeys to walk, what to research; apply writes the actor questions as unknowns, creating only uxcli review / --lens= --write an empty review beside the drawing; a URL with --name= reviews a screen @@ -148,6 +149,21 @@ try { else console.log(E.experienceCard(list)); if (flags.has('--page')) { const f = E.experiencePage(list, opt('out') || E.pageFile(args[0] && fs.existsSync(path.join(args[0], '.uxcli')) ? args[0] : '.')); console.log(` page ${path.relative(process.cwd(), f)}`); } process.exit(0); + } else if (cmd === 'studio') { + // The board: every journey as a canvas of steps — drawn, built, each named version — with findings pinned. + const S = await import('../src/studio.js'); const vpt = opt('viewport') || '390x844'; + if (flags.has('--serve')) { + const r = await S.serveStudio(args[0] || '.', { port: Number(opt('port') || 4317), viewport: vpt }); + if (!r.model) { console.error('uxcli: ' + (r.problems || []).join('\n ')); process.exit(1); } + console.log(S.studioCard(r.model, path.relative(process.cwd(), r.page))); + console.log(`\n serving ${r.url} (localhost only; refreshes as files under .uxcli/ change; Ctrl+C to stop)`); + } else { + const r = await S.writeStudio(args[0] || '.', { viewport: vpt }); + if (!r.model) { console.error('uxcli: ' + (r.problems || []).join('\n ')); process.exit(1); } + if (opt('shot')) { await S.shootStudio(r.page, opt('shot')); } + console.log(flags.has('--json') ? JSON.stringify(r.model, null, 1) : S.studioCard(r.model, path.relative(process.cwd(), r.page)) + (opt('shot') ? `\n shot ${opt('shot')}` : '')); + process.exit(0); + } } else if (cmd === 'version') { // Named walks of a journey: kept against pruning, compared with experience --from --to. const V = await import('../src/version.js'); const root = path.resolve(opt('src') || '.'); diff --git a/skills/uxcli/SKILL.md b/skills/uxcli/SKILL.md index d3e5577..87e67fb 100644 --- a/skills/uxcli/SKILL.md +++ b/skills/uxcli/SKILL.md @@ -65,6 +65,12 @@ Use this bounded path for ordinary UI work. Read a reference only where a step n Then read what the person goes through: `npx -y @junixlabs/uxcli experience --page` — steps, clicks, typing, scrolls, waits and a keystroke-level estimate per journey, each finding pinned on the step's screenshot, and what moved since the walk before. Report each finding in its words; they never block. + To see the whole board — every journey's screens as drawn, as built and per version, findings + pinned — run `npx -y @junixlabs/uxcli studio --shot=.uxcli/studio/board.png` and look at the picture. + When a person has to pick a drawing or look at the work, tell them to open + `npx -y @junixlabs/uxcli studio --serve`: they pan and zoom it like a design file, choose a drawing or + ask for a redraw there, and it refreshes as you change files. Their choice lands in `pick.json` or + `revise.json`; read it from there. 6. **Say done only at exit 0**, and say what was measured. Anything else, say what stands in the way. `references/before-done.md` is the full sequence and the list of what exit 0 does not cover. diff --git a/src/core/studio-page.js b/src/core/studio-page.js new file mode 100644 index 0000000..0b1b36a --- /dev/null +++ b/src/core/studio-page.js @@ -0,0 +1,175 @@ +// The studio page: the board as a canvas a person pans and zooms, the way a design file is read. The +// model is inlined, so the page works from disk; served (`uxcli studio --serve`), it refetches the model +// when a file under .uxcli/ changes and offers the two things a person decides here — choose a drawing, +// or ask for a redraw — which the server writes as pick.json or revise.json. Pure: a model in, HTML out. + +const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +const CSS = ` +:root{color-scheme:light;--bg:#eceef2;--canvas:#e6e9ee;--dot:#cdd2da;--surface:#ffffff;--well:#f3f5f8;--ink:#14171c;--dim:#545b66;--line:#d6dbe2; +--accent:#1d4ed8;--accent-soft:#e3ebfd;--find:#8a4b00;--find-soft:#fff1dc;--pin:#c2410c;--ok:#0f6b34;--ok-soft:#dcf3e5;--fail:#b3261e;--fail-soft:#fbe6e4; +--sans:-apple-system,"Segoe UI",Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0f1216;--canvas:#13171c;--dot:#262c35;--surface:#1a1f26;--well:#20262e;--ink:#e8ebf0;--dim:#a3abb7;--line:#2c333d; +--accent:#8fb2ff;--accent-soft:#1c2a47;--find:#f2c071;--find-soft:#3a2a10;--pin:#fb923c;--ok:#7fe0a3;--ok-soft:#173a26;--fail:#ff8a7a;--fail-soft:#46211c}} +*{box-sizing:border-box}html,body{height:100%;margin:0}body{background:var(--bg);color:var(--ink);font:14px/1.45 var(--sans);display:grid;grid-template-rows:auto 1fr;overflow:hidden} +button,select,textarea{font:inherit;color:inherit}button{cursor:pointer} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.top{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:8px 14px;background:var(--surface);border-bottom:1px solid var(--line)} +.brand{font-weight:700}.brand small{font-weight:400;color:var(--dim);margin-left:6px} +.tabs,.zoom,.layers{display:flex;gap:4px;align-items:center} +.tabs button,.zoom button,.layers label{border:1px solid var(--line);background:var(--surface);border-radius:6px;padding:4px 10px} +.tabs button[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent);color:var(--ink)} +.layers label{display:flex;gap:6px;align-items:center;cursor:pointer}.zoom output{min-width:48px;text-align:center;font-variant-numeric:tabular-nums} +.counts{color:var(--dim);margin-left:auto}.live{color:var(--ok);font-weight:600} +.main{display:grid;grid-template-columns:240px 1fr 340px;min-height:0} +.outline,.insp{background:var(--surface);overflow:auto;min-height:0}.outline{border-right:1px solid var(--line);padding:10px 0}.insp{border-left:1px solid var(--line);padding:14px 16px;display:grid;gap:12px;align-content:start} +.outline h2{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);margin:12px 14px 6px} +.outline button{display:block;width:100%;text-align:left;border:0;background:none;padding:4px 14px;border-radius:0}.outline button:hover{background:var(--well)} +.outline .wf{color:var(--dim);font-size:12px;padding-left:22px}.outline .st{padding-left:30px}.outline .st b{font-variant-numeric:tabular-nums;color:var(--dim);font-weight:600;margin-right:6px} +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:6px;vertical-align:middle}.dot.find{background:var(--pin)}.dot.open{background:var(--accent)} +.stage{position:relative;overflow:hidden;background-color:var(--canvas);background-image:radial-gradient(var(--dot) 1px,transparent 1px);background-size:22px 22px;cursor:grab;touch-action:none} +.stage.drag{cursor:grabbing}.world{position:absolute;left:0;top:0;transform-origin:0 0} +.wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}.wires path{fill:none;stroke:var(--dim);stroke-width:1.5} +.band{position:absolute;font-weight:700;font-size:20px}.band small{display:block;font-weight:400;font-size:13px;color:var(--dim);max-width:900px} +.lane{position:absolute;font:600 12px var(--mono);color:var(--dim)}.rowlabel{position:absolute;font:600 12px var(--sans);color:var(--dim);text-transform:uppercase;letter-spacing:.06em;width:120px;text-align:right} +.frame{position:absolute;background:var(--surface);border:1px solid var(--line);border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);cursor:pointer} +.frame:hover{border-color:var(--dim)}.frame.sel{outline:3px solid var(--accent);outline-offset:2px} +.frame.find{border-color:var(--pin)}.frame.picked{border-color:var(--ok)}.frame.ghost{background:var(--well);border-style:dashed} +.fh{display:flex;gap:6px;align-items:center;padding:6px 8px;border-bottom:1px solid var(--line);font-size:12px;min-height:30px} +.fh .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}.fh .n{font:600 11px var(--mono);color:var(--dim)} +.chip{font:600 11px var(--sans);padding:1px 6px;border-radius:999px;background:var(--well);color:var(--dim);white-space:nowrap} +.chip.ok{background:var(--ok-soft);color:var(--ok)}.chip.find{background:var(--find-soft);color:var(--find)}.chip.fail{background:var(--fail-soft);color:var(--fail)}.chip.acc{background:var(--accent-soft);color:var(--ink)} +.vtabs{display:flex;gap:2px}.vtabs button{border:1px solid var(--line);background:var(--surface);border-radius:4px;padding:0 6px;font:600 11px var(--mono)}.vtabs button[aria-pressed=true]{background:var(--accent-soft);border-color:var(--accent)} +.fi{position:relative;overflow:hidden;border-radius:0 0 8px 8px;background:var(--well)}.fi img{display:block;width:100%;height:100%;object-fit:cover;object-position:top} +.fi .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--dim);font-size:12px;text-align:center;padding:12px} +.pin{position:absolute;border:2px solid var(--pin);border-radius:4px}.pin b{position:absolute;top:-10px;left:-10px;width:20px;height:20px;border-radius:50%;background:var(--pin);color:#fff;font:700 11px/20px var(--sans);text-align:center} +.pin.below{border-style:dashed;background:rgba(194,65,12,.14)} +.insp h3{margin:0;font-size:16px}.insp .sub{color:var(--dim);margin:0}.insp section{display:grid;gap:6px}.insp h4{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim)} +.insp ul{margin:0;padding-left:18px;display:grid;gap:6px}.insp li small{display:block;color:var(--dim)}.insp code{font:12px var(--mono);overflow-wrap:anywhere} +.insp dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:0}.insp dt{color:var(--dim)}.insp dd{margin:0;font-variant-numeric:tabular-nums} +.act{display:grid;gap:6px}.act button{border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:6px;padding:6px 10px;font-weight:600}.act button.sec{background:var(--surface);color:var(--ink);border-color:var(--line)} +.act textarea{width:100%;min-height:70px;border:1px solid var(--line);border-radius:6px;padding:6px;background:var(--surface)}.msg{font-size:13px}.msg.err{color:var(--fail)}.msg.ok{color:var(--ok)} +.page{overflow:auto;padding:20px 24px;display:none;min-height:0}.page.on{display:block}.page h2{margin:0 0 4px}.page .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;margin-top:14px} +.card{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:14px;display:grid;gap:8px;align-content:start}.card h3{margin:0;font-size:15px}.card p{margin:0}.card ul{margin:0;padding-left:18px} +.q{color:var(--ink)}.muted{color:var(--dim)} +@media (max-width:900px){.main{grid-template-columns:1fr}.outline{display:none}.insp{position:fixed;right:0;bottom:0;left:0;max-height:45vh;border-left:0;border-top:1px solid var(--line)}} +`; + +const JS = `(function(){ +var M=JSON.parse(document.getElementById('studio-data').textContent); +var FW=M.frame.w,FH=M.frame.h,HEAD=30,COL=FW+90,ROWH=FH+HEAD+46,GUT=150; +var stage=document.getElementById('stage'),world=document.getElementById('world'),insp=document.getElementById('insp'); +var view={x:40,y:40,k:0.8},sel=null,show={design:true,built:true,versions:true,findings:true},vtab={}; +var KEY='uxcli-studio:'+(M.project.id||M.project.name); +try{var s=JSON.parse(localStorage.getItem(KEY)||'null');if(s&&s.view)view=s.view;if(s&&s.show)show=s.show;}catch(e){} +function save(){try{localStorage.setItem(KEY,JSON.stringify({view:view,show:show}));}catch(e){}} +function el(t,c,h){var e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;} +function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});} +function apply(){world.style.transform='translate('+view.x+'px,'+view.y+'px) scale('+view.k+')';document.getElementById('zv').value=Math.round(view.k*100)+'%';save();} +function frameImg(shot,pins){var fi=el('div','fi');fi.style.height=FH+'px';if(shot){var im=el('img');im.src=shot;im.alt='';im.loading='lazy';fi.appendChild(im);}else fi.appendChild(el('div','none','no picture yet')); + if(shot&&pins&&show.findings)pins.forEach(function(f,i){if(!f.rect)return;var r=f.rect,vw=(r.viewport&&r.viewport.w)||parseInt(M.viewport),k=FW/vw,p=el('span','pin',''+(i+1)+'');var y=r.y*k; + if(y+8>FH){p.classList.add('below');p.style.top=(FH-26)+'px';p.style.height='22px';p.title='below the fold';}else{p.style.top=y+'px';p.style.height=Math.min(r.h*k,FH-y)+'px';}p.style.left=(r.x*k)+'px';p.style.width=Math.max(8,r.w*k)+'px';fi.appendChild(p);}); + return fi;} +function frame(x,y,kind,s,j,w,extra){var f=el('div','frame');f.style.left=x+'px';f.style.top=y+'px';f.style.width=FW+'px';f.tabIndex=0;f.setAttribute('role','button'); + var id=kind+'|'+j.id+'|'+w.id+'|'+s.id+(extra?'|'+extra:'');f.dataset.id=id;if(sel===id)f.classList.add('sel'); + f.addEventListener('click',function(e){e.stopPropagation();select(id);});f.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();select(id);}});return f;} +function render(){world.innerHTML='';var wires=document.createElementNS('http://www.w3.org/2000/svg','svg');wires.setAttribute('class','wires');var d=''; + var y=0,maxX=0; + M.journeys.forEach(function(j){var b=el('div','band',esc(j.id)+''+esc(j.goal||'')+(j.actor?' · '+esc(j.actor):'')+(j.run?' · last walk: '+esc(j.run.verdict):' · not walked yet')+'');b.style.left='0px';b.style.top=y+'px';world.appendChild(b);y+=74; + // one set of rows per journey; its workflows sit side by side, as alternatives do + var rows=[];if(show.design)rows.push('design');if(show.built)rows.push('built'); + if(show.versions)j.versions.forEach(function(v){if(j.workflows.some(function(w){return w.steps.some(function(s){return s.versions.some(function(q){return q.name===v.name&&q.present;});});}))rows.push('v:'+v.name);}); + rows.forEach(function(row,ri){var lab=el('div','rowlabel',row==='design'?'Design':row==='built'?'Built':esc(row.slice(2)));lab.style.left='0px';lab.style.top=(y+26+ri*ROWH+HEAD+FH/2-8)+'px';world.appendChild(lab);}); + var x0=GUT; + j.workflows.forEach(function(w){var l=el('div','lane',esc(w.id||'workflow')+(w.totals?' · '+w.totals.steps+' step'+(w.totals.steps===1?'':'s')+' · about '+w.totals.klmSeconds+' s':''));l.style.left=x0+'px';l.style.top=y+'px';world.appendChild(l); + rows.forEach(function(row,ri){var ry=y+26+ri*ROWH; + w.steps.forEach(function(s,i){var x=x0+i*COL;maxX=Math.max(maxX,x+FW);var f; + if(row==='design'){var dz=s.design,vs=dz.variants;var cur=vtab[j.id+s.id]||(dz.pick&&dz.pick.pick)||(vs[0]&&vs[0].name);var cv=vs.filter(function(v){return v.name===cur;})[0]||vs[0]; + f=frame(x,ry,'design',s,j,w);if(dz.pick)f.classList.add('picked');if(!vs.length)f.classList.add('ghost'); + var h=el('div','fh',''+s.n+''+esc(dz.state)+''); + if(vs.length>1){var tabs=el('div','vtabs');vs.forEach(function(v,k){var bt=el('button','',String.fromCharCode(65+k));bt.title=v.name;bt.setAttribute('aria-pressed',v===cv);bt.addEventListener('click',function(e){e.stopPropagation();vtab[j.id+s.id]=v.name;var id=f.dataset.id;render();select(id);});tabs.appendChild(bt);});h.appendChild(tabs);} + h.appendChild(el('span','chip '+(dz.pick?'ok':vs.length?'acc':''),dz.pick?'picked':dz.revise&&!dz.revise.answered?'redraw asked':vs.length?'open':'not drawn')); + f.appendChild(h);f.appendChild(frameImg(cv&&cv.shot,null));} + else if(row==='built'){var bl=s.built;f=frame(x,ry,'built',s,j,w);if(bl.findings.length||bl.verdicts.length)f.classList.add('find');if(!bl.shot)f.classList.add('ghost'); + var tag=bl.verdicts.some(function(v){return v.value==='fail';})?'fail':bl.findings.length?''+bl.findings.length+' finding'+(bl.findings.length>1?'s':'')+'':bl.measured?'walked':'not walked'; + f.appendChild(el('div','fh',''+s.n+''+esc(s.action||s.id)+''+(bl.metrics?''+bl.metrics.klmSeconds+' s':'')+tag)); + f.appendChild(frameImg(bl.shot,bl.findings));} + else{var vn=row.slice(2),v=s.versions.filter(function(q){return q.name===vn;})[0]||{};if(!v.present)return;f=frame(x,ry,'version',s,j,w,vn);if(!v.shot)f.classList.add('ghost'); + f.appendChild(el('div','fh',''+s.n+''+esc(vn)+''+(v.klmSeconds!=null?''+v.klmSeconds+' s':'')+(v.findings?''+v.findings+'':''))); + f.appendChild(frameImg(v.shot,null));} + world.appendChild(f); + if(iWrite one under .uxcli/journeys/ — uxcli template show <kind> says where to start.'));apply();} +function find(id){var a=id.split('|');var j=M.journeys.filter(function(x){return x.id===a[1];})[0];if(!j)return null;var w=j.workflows.filter(function(x){return String(x.id)===a[2];})[0];var s=w&&w.steps.filter(function(x){return x.id===a[3];})[0];return s&&{kind:a[0],j:j,w:w,s:s,extra:a[4]};} +function select(id){sel=id;[].forEach.call(world.querySelectorAll('.frame.sel'),function(f){f.classList.remove('sel');});var f=world.querySelector('[data-id="'+(window.CSS&&CSS.escape?CSS.escape(id):id)+'"]');if(f)f.classList.add('sel');inspect();} +function list(items,fn){return items.length?'
    '+items.map(fn).join('')+'
':'

none

';} +function inspect(){var x=sel&&find(sel);if(!x){insp.innerHTML='

'+esc(M.project.name)+'

Click a frame to see what the files and the last walk say about it. Drag to pan, scroll or pinch to zoom, F to fit.

'+ + '

Board

Journeys
'+M.counts.journeys+'
Steps
'+M.counts.steps+'
Drawn
'+M.counts.drawn+'
Picked
'+M.counts.picked+'
Built
'+M.counts.built+'
Findings
'+M.counts.findings+'
Versions
'+M.counts.versions+'
';return;} + var s=x.s,h='

'+esc(x.j.id)+' · '+esc(x.w.id||'workflow')+' · step '+s.n+'

'+esc(s.action||s.id)+'

'+esc(s.before.state)+' → '+esc(s.after.state)+''+(s.after.held===false?' · not reached':s.after.held?' · reached':'')+'

'; + if(x.kind==='design'){var dz=s.design;h+='

Design · '+esc(dz.state)+'

'+(dz.question?'

'+esc(dz.question)+'

':'')+list(dz.variants,function(v){return '
  • '+esc(v.name)+''+(dz.pick&&dz.pick.pick===v.name?' picked':dz.pick&&dz.pick.parts&&dz.pick.parts[v.name]?' part taken':'')+(v.about?''+esc(v.about)+'':'')+(v.reviews&&v.reviews.length?'review: '+v.reviews.map(function(r){return esc(r.lens)+' '+esc(r.line);}).join('; ')+'':'')+'.uxcli/mockups/'+esc(dz.state)+'/'+esc(v.name)+'.html
  • ';})+'
    '; + if(dz.pick)h+='

    Pick

    '+esc(dz.pick.pick)+' by '+esc(dz.pick.by&&dz.pick.by.ref)+(dz.pick.when?' · '+esc(dz.pick.when):'')+'

    '+(dz.pick.note?'

    '+esc(dz.pick.note)+'

    ':'')+'
    '; + if(dz.revise)h+='

    Redraw asked

    '+esc(dz.revise.note)+'

    '+(dz.revise.answered?'answered: the drawings changed since':'waiting for the agent to redraw')+'

    '; + if(M.serve&&dz.variants.length&&!dz.pick){h+='

    Decide

    '+dz.variants.map(function(v){return '';}).join('')+'

    ';} + else if(!M.serve&&dz.variants.length&&!dz.pick)h+='

    To choose here, open the board with uxcli studio --serve.

    ';} + else if(x.kind==='built'){var b=s.built;h+='

    Built · the last walk

    '+(b.metrics?'
    Estimate
    '+b.metrics.klmSeconds+' s
    Settled
    '+(b.metrics.settleMs==null?'–':b.metrics.settleMs+' ms · '+esc(b.metrics.response))+'
    Clicks
    '+b.metrics.clicks+'
    Typed
    '+b.metrics.chars+' characters
    Scrolls
    '+b.metrics.scrolls+'
    ':'

    '+(b.measured?'walked, no metrics':'not walked yet')+'

    ')+'
    '; + h+='

    Findings

    '+list(b.findings,function(f,i){return '
  • '+(f.rect?''+(i+1)+'. ':'')+''+esc(f.metric)+' '+esc(f.what)+''+esc(f.source)+'
  • ';})+'
    '; + if(b.verdicts.length)h+='

    Verdicts

    '+list(b.verdicts,function(v){return '
  • '+esc(v.value)+' '+esc(v.what)+(v.commitment?''+esc(v.commitment)+(v.statement?' — '+esc(v.statement):'')+'':'')+'
  • ';})+'
    ';} + else{var v=s.versions.filter(function(q){return q.name===x.extra;})[0]||{};var meta=x.j.versions.filter(function(q){return q.name===x.extra;})[0]||{};h+='

    Version '+esc(x.extra)+'

    '+esc(meta.note||'')+'

    Named
    '+esc((meta.at||'').slice(0,16))+' · '+esc(meta.by&&meta.by.ref)+'
    This step
    '+(v.present?v.klmSeconds+' s · '+v.findings+' finding'+(v.findings===1?'':'s'):'not in this version')+'
    Whole walk
    '+(meta.klmSeconds!=null?meta.klmSeconds+' s · ':'')+(meta.findings||0)+' findings
    ';} + insp.innerHTML=h; + [].forEach.call(insp.querySelectorAll('[data-pick]'),function(b){b.addEventListener('click',function(){post('/api/pick',{state:s.design.state,variant:b.dataset.pick});});}); + var rb=document.getElementById('rbtn');if(rb)rb.addEventListener('click',function(){post('/api/revise',{state:s.design.state,note:document.getElementById('rnote').value});});} +function post(url,body){var m=document.getElementById('amsg');fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}).then(function(r){return r.json();}).then(function(r){if(m){m.className='msg '+(r.ok?'ok':'err');m.textContent=r.ok?'Saved '+r.file+'. The agent reads it from there.':(r.problems||['could not save']).join('; ');}}).catch(function(e){if(m){m.className='msg err';m.textContent=String(e);}});} +function fit(){var r=stage.getBoundingClientRect(),W=parseFloat(world.style.width)||1,H=parseFloat(world.style.height)||1;view.k=Math.max(0.1,Math.min(1.5,Math.min((r.width-60)/W,(r.height-60)/H)));view.x=30;view.y=30;apply();} +function zoom(f,cx,cy){var r=stage.getBoundingClientRect();cx=cx==null?r.width/2:cx;cy=cy==null?r.height/2:cy;var k=Math.max(0.1,Math.min(3,view.k*f));view.x=cx-(cx-view.x)*k/view.k;view.y=cy-(cy-view.y)*k/view.k;view.k=k;apply();} +function goTo(id){var f=world.querySelector('[data-id="'+(window.CSS&&CSS.escape?CSS.escape(id):id)+'"]');if(!f)return;var r=stage.getBoundingClientRect();view.k=Math.max(view.k,0.9);view.x=r.width/2-(parseFloat(f.style.left)+FW/2)*view.k;view.y=r.height/3-parseFloat(f.style.top)*view.k;apply();select(id);} +var drag=null;stage.addEventListener('pointerdown',function(e){if(e.target.closest('.frame'))return;drag={x:e.clientX,y:e.clientY,vx:view.x,vy:view.y};stage.classList.add('drag');stage.setPointerCapture(e.pointerId);}); +stage.addEventListener('pointermove',function(e){if(!drag)return;view.x=drag.vx+e.clientX-drag.x;view.y=drag.vy+e.clientY-drag.y;apply();}); +stage.addEventListener('pointerup',function(){drag=null;stage.classList.remove('drag');}); +stage.addEventListener('click',function(e){if(!e.target.closest('.frame')&&!drag){sel=null;[].forEach.call(world.querySelectorAll('.frame.sel'),function(f){f.classList.remove('sel');});inspect();}}); +stage.addEventListener('wheel',function(e){e.preventDefault();var r=stage.getBoundingClientRect();if(e.ctrlKey||e.metaKey)zoom(Math.exp(-e.deltaY*0.01),e.clientX-r.left,e.clientY-r.top);else{view.x-=e.deltaX;view.y-=e.deltaY;apply();}},{passive:false}); +document.addEventListener('keydown',function(e){if(e.target.closest&&e.target.closest('textarea,input,select'))return;if(e.key==='+'||e.key==='=')zoom(1.2);else if(e.key==='-')zoom(1/1.2);else if(e.key==='0'||e.key==='f'||e.key==='F')fit();else if(e.key==='ArrowLeft'){view.x+=60;apply();}else if(e.key==='ArrowRight'){view.x-=60;apply();}else if(e.key==='ArrowUp'){view.y+=60;apply();}else if(e.key==='ArrowDown'){view.y-=60;apply();}}); +document.getElementById('zin').onclick=function(){zoom(1.2);};document.getElementById('zout').onclick=function(){zoom(1/1.2);};document.getElementById('zfit').onclick=fit; +['design','built','versions','findings'].forEach(function(k){var c=document.getElementById('show-'+k);c.checked=show[k];c.onchange=function(){show[k]=c.checked;save();render();};}); +[].forEach.call(document.querySelectorAll('.tabs button'),function(b){b.onclick=function(){[].forEach.call(document.querySelectorAll('.tabs button'),function(x){x.setAttribute('aria-pressed',x===b);});var t=b.dataset.tab;document.getElementById('board').style.display=t==='canvas'?'':'none';[].forEach.call(document.querySelectorAll('.page'),function(p){p.classList.toggle('on',p.id==='page-'+t);});};}); +[].forEach.call(document.querySelectorAll('[data-go]'),function(b){b.onclick=function(){goTo(b.dataset.go);};}); +render();inspect();var first=!(function(){try{return localStorage.getItem(KEY);}catch(e){return null;}})();if(first)fit(); +if(M.serve&&window.EventSource){var es=new EventSource('/events');var live=document.getElementById('live');es.onopen=function(){live.textContent='live';};es.onmessage=function(){fetch('data.json',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){M=d;render();inspect();live.textContent='live · updated '+new Date().toLocaleTimeString();});};es.onerror=function(){live.textContent='disconnected';};} +})();`; + +function outline(m) { + return m.journeys.map(j => `

    ${esc(j.id)}

    ` + j.workflows.map(w => `
    ${esc(w.id || 'workflow')}
    ` + w.steps.map(s => { + const id = `built|${j.id}|${w.id}|${s.id}`; const dz = s.design; + return ``; + }).join('')).join('')).join('') || '

    No journey yet

    '; +} + +function contextPage(m) { + const c = m.context; + const actors = c.actors.map(a => `

    ${esc(a.actor)}

    ${a.note ? `

    ${esc(a.note)}

    ` : ''}${a.jobs.length ? `

    Jobs

      ${a.jobs.map(x => `
    • ${esc(x)}
    • `).join('')}
    ` : ''}${a.pains.length ? `

    Pains

      ${a.pains.map(x => `
    • ${esc(x)}
    • `).join('')}
    ` : ''}

    Still unknown

      ${a.unknowns.map(x => `
    • ${esc(x)}
    • `).join('')}
    `).join(''); + const insights = c.insights.map(i => `

    ${esc(i.id)} ${esc(i.confidence || '?')}

    ${esc(i.claim)}

    ${i.source ? `

    ${esc(i.source)}

    ` : ''}${i.evidence.length ? `
      ${i.evidence.map(e => `
    • ${esc(e)}
    • `).join('')}
    ` : ''}
    `).join(''); + return `

    Context

    Who the product is for and what is known about them, as the files under .uxcli/understanding/ say. Unknowns stay questions until a source answers them.

    ${actors || '

    No actor yet — uxcli template apply <kind> writes the questions to start from.

    '}${insights}
    `; +} + +function libraryPage(m) { + const L = m.library; + return `

    Library

    What an agent reads before it draws: lenses of named designers' viewpoints by kind of UI (uxcli lens show <kind>), and templates by kind of product (uxcli template show <id>).

    +
    ${L.lenses.map(l => `

    ${esc(l.name)} ${l.count} viewpoints

    ${esc(l.when)}

    uxcli lens show ${esc(l.id)}

    `).join('')}
    +
    ${L.templates.map(t => `

    ${esc(t.name)} template

    ${esc(t.when)}

      ${t.screens.map(s => `
    • ${esc(s.name)} · ${esc(s.lens)}
    • `).join('')}

    uxcli template show ${esc(t.id)}

    `).join('')}
    `; +} + +export function studioPage(m) { + const data = JSON.stringify(m).replace(/${esc(m.project.name)} · uxcli studio +
    uxcli studio${esc(m.project.name)} + +
    +
    100%
    +${m.counts.steps} steps · ${m.counts.picked}/${m.counts.drawn} picked · ${m.counts.findings} findings${m.serve ? ' · connecting' : ''}
    +
    +${contextPage(m)}${libraryPage(m)} +`; +} diff --git a/src/core/studio.js b/src/core/studio.js new file mode 100644 index 0000000..a19b6a7 --- /dev/null +++ b/src/core/studio.js @@ -0,0 +1,73 @@ +// The studio model: one board for the whole project, the way a design file lays it out — each journey a +// band, each workflow a lane, each step a column, and in each column the screen as it was drawn (the +// variants and the pick), as it was built (the last walk's screenshot with its findings pinned), and as +// each named version left it. Pure: everything is handed in already read; paths are relative to the +// page. Nothing here judges a screen; it places what the files and the runs already say. +// +// in: { map: mapModel(), screens: [{ id, variants: [{ name, shot, about, reviews }], pick, revise, status }], +// experiences: { [journey]: report }, versions: { [journey]: [{ name, at, note, by, report, shots: { [wf/step]: path } }] }, +// actors, insights, lenses, templates, viewport, serve } +// out: { project, version, generatedAt, viewport, frame: { w, h }, journeys: [...], context, library, counts } + +const vp = s => { const m = /^(\d+)x(\d+)$/.exec(String(s || '')); return m ? { w: +m[1], h: +m[2] } : { w: 390, h: 844 }; }; + +export function studioModel({ map, screens = [], experiences = {}, versions = {}, actors = [], insights = [], lenses = [], templates = [], viewport = null, serve = false }) { + const v = vp(viewport || map?.journeys?.find(j => j.run?.viewport)?.run?.viewport); + const screenBy = Object.fromEntries(screens.map(s => [s.id, s])); + const journeys = (map?.journeys || []).map(j => { + const exp = experiences[j.id] || null; + const vs = versions[j.id] || []; + const workflows = j.workflows.map(w => ({ + id: w.id, kind: w.kind, status: w.status, + totals: exp?.workflows?.find(x => x.id === w.id)?.totals || null, + steps: w.steps.map(s => { + const sc = screenBy[s.after.state] || null; + const metrics = exp?.steps?.find(x => x.id === s.id && (x.workflow === w.id || x.workflow == null)) || null; + const findings = (exp?.findings || []).filter(f => f.step === s.id && (f.workflow === w.id || f.workflow == null)).map(f => ({ metric: f.metric, what: f.what, source: f.source, rect: f.rect || null })); + const verdicts = (s.verdicts || []).filter(x => x.value !== 'pass').map(x => ({ value: x.value, what: x.what || '', commitment: x.commitment || null, statement: x.statement || null })); + return { + n: s.n, id: s.id, action: s.action, url: s.url, + before: { state: s.before.state, held: s.before.held }, after: { state: s.after.state, held: s.after.held }, + design: sc ? { state: sc.id, status: sc.status, pick: sc.pick, revise: sc.revise, question: sc.question || null, variants: sc.variants } : { state: s.after.state, status: 'undrawn', pick: null, revise: null, question: null, variants: [] }, + built: { shot: s.before.shot || s.after.shot || null, after: s.after.shot || null, measured: s.measured, drift: s.drift, metrics: metrics && { klmSeconds: metrics.klmSeconds, settleMs: metrics.settleMs, response: metrics.response, clicks: metrics.clicks, chars: metrics.chars, scrolls: metrics.scrolls }, findings, verdicts }, + versions: vs.map(x => { const m = x.report?.steps?.find(y => y.id === s.id && (y.workflow === w.id || y.workflow == null)); return { name: x.name, shot: x.shots?.[`${w.id}/${s.id}`] || x.shots?.[`/${s.id}`] || null, klmSeconds: m?.klmSeconds ?? null, findings: (x.report?.findings || []).filter(f => f.step === s.id && (f.workflow === w.id || f.workflow == null)).length, present: !!m }; }), + }; + }), + })); + return { id: j.id, goal: j.goal, actor: j.actor, run: j.run, versions: vs.map(x => ({ name: x.name, at: x.at, note: x.note, by: x.by, klmSeconds: x.report?.totals?.klmSeconds ?? null, findings: x.report?.findings?.length ?? 0 })), workflows }; + }); + const steps = journeys.flatMap(j => j.workflows.flatMap(w => w.steps)); + return { + project: map?.project || { name: 'project' }, version: map?.version || '', generatedAt: map?.generatedAt || null, serve, + viewport: `${v.w}x${v.h}`, frame: { w: 220, h: Math.round(220 * v.h / v.w) }, + journeys, + context: { + actors: actors.map(a => ({ actor: a.actor, roles: a.roles || [], jobs: a.jobs || [], pains: a.pains || [], expectations: a.expectations || [], unknowns: a.unknowns || [], note: a.note || null })), + insights: insights.map(i => ({ id: i.id, about: i.about || null, claim: i.claim, confidence: i.confidence || null, evidence: i.evidence || [], source: i.source ? `${i.source.type || ''} ${i.source.ref || ''}`.trim() : null })), + }, + library: { lenses: lenses.map(l => ({ id: l.id, name: l.name, when: l.when, count: l.viewpoints?.length || 0 })), templates: templates.map(t => ({ id: t.id, name: t.name, when: t.when, screens: t.screens.map(s => ({ name: s.name, lens: s.lens })) })) }, + counts: { + journeys: journeys.length, steps: steps.length, + drawn: steps.filter(s => s.design.variants.length).length, picked: steps.filter(s => s.design.pick).length, + built: steps.filter(s => s.built.shot).length, findings: steps.reduce((n, s) => n + s.built.findings.length, 0), + versions: journeys.reduce((n, j) => n + j.versions.length, 0), + }, + }; +} + +export function studioCard(m, page) { + const c = m.counts; + const L = [`uxcli studio · ${m.project.name}`, '', + ` ${c.journeys} journeys · ${c.steps} steps · ${c.drawn} drawn · ${c.picked} picked · ${c.built} built · ${c.findings} findings on the walk · ${c.versions} versions`]; + for (const j of m.journeys) { + L.push(` ${j.id}${j.run ? ` · last walk ${j.run.verdict}` : ' · never walked'}${j.versions.length ? ` · versions ${j.versions.map(v => v.name).join(', ')}` : ''}`); + for (const w of j.workflows) for (const s of w.steps) { + const d = s.design; const open = d.variants.length && !d.pick ? (d.revise && !d.revise.answered ? 'revision asked' : 'waiting for a pick') : null; + L.push(` ${String(s.n).padStart(2)}. ${s.after.state.padEnd(28)} design ${d.variants.length ? `${d.variants.length} drawn${d.pick ? ` · picked ${d.pick.pick}` : ''}` : 'not drawn'}${open ? ` (${open})` : ''} · built ${s.built.shot ? 'yes' : 'no'}${s.built.findings.length ? ` · ${s.built.findings.length} finding${s.built.findings.length === 1 ? '' : 's'}` : ''}`); + } + } + if (page) L.push('', ` page ${page}`); + L.push('', ' uxcli studio --serve the board in a browser, refreshed as files change; a person picks or asks for a redraw there', + ' uxcli studio --shot=FILE a picture of the board, for an agent to look at'); + return L.join('\n'); +} diff --git a/src/studio.js b/src/studio.js new file mode 100644 index 0000000..bfd81df --- /dev/null +++ b/src/studio.js @@ -0,0 +1,132 @@ +// uxcli studio: the board. Reads everything under .uxcli/ (journeys, mockups and picks, the last walks, +// their experience, versions, understanding) plus the shipped library, and writes one page, +// .uxcli/studio/index.html. `--serve` serves it on localhost, refetching when a file changes, and takes +// the two decisions a person makes there — a pick or a redraw request — through the same parsers the +// files are read with, creating only. `--shot` photographs the board for an agent to look at. +import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import { execFileSync } from 'node:child_process'; +import { findRoot, loadProject } from './journey.js'; +import { allRuns, UXCLI } from './adapters/store/runs.js'; +import { discover, mockups as photograph } from './mockups.js'; +import { mapModel } from './core/map.js'; +import { studioModel, studioCard } from './core/studio.js'; +import { studioPage } from './core/studio-page.js'; +import { experience, journeyName } from './core/experience.js'; +import { versions as listVersions } from './version.js'; +import { library } from './lens.js'; +import { templates } from './template.js'; +import { reviewSummary, summaryLine } from './core/model/lens.js'; +import { parsePick, parseRevise, screenStatus } from './core/mockups.js'; + +const VERSION = JSON.parse(fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'package.json'), 'utf8')).version; +const rel = (from, to) => path.relative(from, to).split(path.sep).join('/'); +const readJson = f => JSON.parse(fs.readFileSync(f, 'utf8')); +export const studioDir = root => path.join(root, UXCLI, 'studio'); + +const shotIn = (dir, name) => { if (!name) return null; for (const f of [path.join(dir, 'artifacts', name), path.join(dir, name)]) if (fs.existsSync(f)) return f; return null; }; + +// Everything the board shows, read fresh. `shoot` photographs mockups whose pictures are missing or stale. +export async function buildStudio(from, { viewport = '390x844', shoot = true, serve = false } = {}) { + const root = findRoot(from); if (!root) return { problems: ['no .uxcli/policy/policy.json here or above — uxcli init --apply --origin= first'] }; + const out = studioDir(root); const P = loadProject(root); + const D = discover(root); const shotsDir = path.join(D.base, '.shots'); + const stale = D.screens.some(s => s.variants.some(v => { const png = path.join(shotsDir, s.id, `${v}.png`); return !fs.existsSync(png) || fs.statSync(png).mtimeMs < fs.statSync(path.join(s.dir, `${v}.html`)).mtimeMs; })); + if (shoot && stale) { try { await photograph(root, { viewport }); } catch {} } + const screens = D.screens.map(s => ({ + id: s.id, status: screenStatus(s), question: s.about?.question || null, + pick: s.pick ? { pick: s.pick.pick, parts: s.pick.parts, by: s.pick.by, note: s.pick.note, when: s.pick.when } : null, + revise: s.revise ? { note: s.revise.note, answered: s.revise.answered, by: s.revise.by } : null, + variants: s.variants.map(v => { const png = path.join(shotsDir, s.id, `${v}.png`); return { name: v, shot: fs.existsSync(png) ? rel(out, png) : null, about: s.about?.variants?.[v] || null, reviews: (s.reviews[v] || []).filter(r => r.value).map(r => ({ lens: r.lens, line: summaryLine(reviewSummary(r.value)) })) }; }), + })); + // the newest walk of each journey, for map's model; the experience of it; the walks versions name + const runs = {}; const newest = {}; + for (const x of allRuns(root)) { const id = journeyName(x.run); if (!id || !Array.isArray(x.run.steps)) continue; if (!runs[id]) { runs[id] = { run: x.run, base: rel(out, x.at) }; newest[id] = x; } } + const mocks = Object.fromEntries(D.screens.map(s => [s.id, { pick: s.pick?.pick || null, variants: s.variants, shots: Object.fromEntries(s.variants.filter(v => fs.existsSync(path.join(shotsDir, s.id, `${v}.png`))).map(v => [v, rel(out, path.join(shotsDir, s.id, `${v}.png`))])) }])); + const map = mapModel({ project: P.project, journeys: P.journeys.map(j => j.value).filter(Boolean), commitments: P.commitments.map(c => c.value).filter(Boolean), actors: (P.actors || []).map(a => a.value).filter(Boolean), insights: (P.insights || []).map(i => i.value).filter(Boolean), runs, mockups: mocks, version: VERSION }); + // map links a shot only when the packet lists it in evidence; the studio takes the step's own shots + for (const j of map.journeys) { const x = newest[j.id]; if (!x) continue; for (const w of j.workflows) for (const s of w.steps) { const rs = x.run.steps.find(r => r.id === s.id && (r.workflow === w.id || r.workflow == null)); if (!rs) continue; const b = shotIn(x.at, (rs.shots || []).find(f => f.includes('-before')) || rs.shots?.[0]); const a = shotIn(x.at, (rs.shots || []).find(f => f.includes('-after')) || rs.shots?.[1]); if (b) s.before.shot = rel(out, b); if (a) s.after.shot = rel(out, a); } } + map.generatedAt = new Date().toISOString(); + const experiences = Object.fromEntries(Object.entries(newest).map(([id, x]) => [id, experience(x.run)])); + const versions = {}; + for (const v of listVersions(root).filter(v => v.value)) { + const dir = path.join(root, UXCLI, v.value.run); const f = path.join(dir, 'run.json'); if (!fs.existsSync(f)) continue; + const run = readJson(f); const shots = {}; + for (const s of run.steps || []) { const a = shotIn(dir, (s.shots || []).find(x => x.includes('-after')) || s.shots?.[s.shots.length - 1]); if (a) shots[`${s.workflow || ''}/${s.id}`] = rel(out, a); } + (versions[v.value.journey] ||= []).push({ ...v.value, report: experience(run), shots }); + } + const lib = library(); const tpl = templates(); + const m = studioModel({ map, screens, experiences, versions, actors: (P.actors || []).map(a => a.value).filter(Boolean), insights: (P.insights || []).map(i => i.value).filter(Boolean), lenses: lib.lenses, templates: tpl.list, viewport: Object.values(runs)[0]?.run?.viewport || viewport, serve }); + return { root, out, model: m, problems: P.problems }; +} + +export async function writeStudio(from, opts = {}) { + const r = await buildStudio(from, opts); if (r.problems && !r.model) return r; + fs.mkdirSync(r.out, { recursive: true }); + const page = path.join(r.out, 'index.html'); fs.writeFileSync(page, studioPage(r.model)); + return { ...r, page }; +} + +export { studioCard }; + +// A picture of the board, for an agent that can look: the page opened in Chrome, fitted, photographed. +export async function shootStudio(page, file, { width = 1600, height = 1000 } = {}) { + const { launch } = await import('./browser.js'); + const browser = await launch(); try { + const p = await browser.newPage({ viewport: { width, height } }); + await p.goto('file://' + path.resolve(page)); await p.waitForTimeout(300); + await p.evaluate(() => { const b = document.getElementById('zfit'); if (b) b.click(); }); await p.waitForTimeout(500); + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); await p.screenshot({ path: file }); + } finally { await browser.close(); } + return file; +} + +const by = root => { try { const n = execFileSync('git', ['config', 'user.name'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); if (n) return { type: 'person', ref: n }; } catch {} return { type: 'person', ref: 'studio' }; }; +const MIME = { '.html': 'text/html; charset=utf-8', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.css': 'text/css', '.svg': 'image/svg+xml', '.js': 'text/javascript' }; + +// The two writes a person makes from the board. Each goes through the parser the file is read with, +// and neither overwrites: a screen already picked is changed by deleting its pick.json, on purpose. +export function decide(root, kind, body, who = by(root)) { + const D = discover(root); const s = D.screens.find(x => x.id === body?.state); + if (!s) return { ok: false, problems: [`no screen ${body?.state} under .uxcli/mockups/`] }; + const when = new Date().toISOString().slice(0, 10); + if (kind === 'pick') { + const doc = { schema_version: 1, pick: body.variant, sha256: s.hashes[body.variant], by: who, ...(body.note && { note: String(body.note) }), when }; + const r = parsePick(doc, s.variants, s.hashes); if (!r.value) return { ok: false, problems: r.problems }; + const f = path.join(s.dir, 'pick.json'); if (fs.existsSync(f)) return { ok: false, problems: ['this screen is already picked; delete its pick.json to choose again'] }; + fs.writeFileSync(f, JSON.stringify(doc, null, 2) + '\n'); return { ok: true, file: rel(root, f) }; + } + if (kind === 'revise') { + const doc = { schema_version: 1, note: String(body.note || '').trim(), seen: s.hashes, by: who, when }; + const r = parseRevise(doc, s.variants, s.hashes); if (!r.value) return { ok: false, problems: r.problems }; + if (s.pick) return { ok: false, problems: ['this screen is already picked'] }; + const f = path.join(s.dir, 'revise.json'); if (fs.existsSync(f) && s.revise && !s.revise.answered) return { ok: false, problems: ['a redraw is already asked and not yet answered'] }; + fs.writeFileSync(f, JSON.stringify(doc, null, 2) + '\n'); return { ok: true, file: rel(root, f) }; + } + return { ok: false, problems: [`unknown decision ${kind}`] }; +} + +// localhost only. Serves files under the project's .uxcli/ and nothing else. +export async function serveStudio(from, { port = 4317, viewport = '390x844', host = '127.0.0.1' } = {}) { + const first = await writeStudio(from, { viewport, serve: true }); if (!first.model) return first; + const root = first.root; const base = path.join(root, UXCLI); + const clients = new Set(); let timer = null; + const notify = () => { clearTimeout(timer); timer = setTimeout(async () => { try { await writeStudio(root, { viewport, serve: true, shoot: false }); } catch {} for (const c of clients) c.write('data: change\n\n'); }, 300); }; + try { fs.watch(base, { recursive: true }, (e, f) => { if (f && !String(f).startsWith('studio') && !String(f).includes('.shots')) notify(); }); } catch {} + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + if (req.method === 'GET' && url.pathname === '/') { res.writeHead(302, { location: '/.uxcli/studio/index.html' }); return res.end(); } + if (req.method === 'GET' && url.pathname === '/events') { res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' }); res.write('retry: 2000\n\n'); clients.add(res); req.on('close', () => clients.delete(res)); return; } + if (req.method === 'GET' && url.pathname === '/.uxcli/studio/data.json') { const r = await buildStudio(root, { viewport, serve: true, shoot: false }); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(r.model)); } + if (req.method === 'POST' && (url.pathname === '/api/pick' || url.pathname === '/api/revise')) { + let body = ''; for await (const c of req) { body += c; if (body.length > 100000) break; } + let r; try { r = decide(root, url.pathname.slice(5), JSON.parse(body)); } catch (e) { r = { ok: false, problems: [e.message] }; } + res.writeHead(r.ok ? 200 : 400, { 'content-type': 'application/json' }); res.end(JSON.stringify(r)); if (r.ok) notify(); return; + } + if (req.method !== 'GET') { res.writeHead(405); return res.end(); } + const file = path.resolve(root, '.' + decodeURIComponent(url.pathname)); + if (!file.startsWith(base + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' }); + fs.createReadStream(file).pipe(res); + }); + await new Promise((ok, no) => { server.once('error', no); server.listen(port, host, ok); }); + return { ...first, server, url: `http://${host}:${server.address().port}/` }; +} diff --git a/src/suites.js b/src/suites.js index 65cd96f..33d1ae4 100644 --- a/src/suites.js +++ b/src/suites.js @@ -38,4 +38,5 @@ export const SUITES = [ { tag: 'tmpl', title: 'a template asks, and apply only creates', file: 'template-pairs.mjs', miss: 'refused' }, { tag: 'exp', title: 'what the person goes through, from the trace', file: 'experience-pairs.mjs', miss: 'seen' }, { tag: 'ver', title: 'a version is kept, and two compare', file: 'version-pairs.mjs', miss: 'refused' }, + { tag: 'board', title: 'the studio shows every step, and decides through the files', file: 'studio-pairs.mjs', miss: 'refused' }, ]; diff --git a/test/studio-pairs.mjs b/test/studio-pairs.mjs new file mode 100644 index 0000000..79c9d67 --- /dev/null +++ b/test/studio-pairs.mjs @@ -0,0 +1,95 @@ +// The studio is a board a person reads and, served, the one place they decide from. Its promises, each +// a pair: every step of every journey has a column, drawn, built and per version; a decision made there +// goes through the file's own parser and only ever creates; the server hands out nothing outside +// .uxcli/; and the page is held to the instrument's own page probes, in a real browser, with the +// canvas actually panning, zooming and inspecting. +import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { writeStudio, decide, serveStudio } from '../src/studio.js'; +import { launch } from '../src/browser.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'crm'); + +export const OPERATOR = 'the fixture project as a board: one column per step of every journey, the picked drawing and the walk\'s finding in the model; a pick of a drawing that is not on disk refused, a valid one written with its hash, a second refused, a redraw with no note refused; the server answering the page and its data, refusing a path outside .uxcli/ and a pick it cannot parse; and in Chrome the page with no error, a canvas that moves when dragged and zoomed, a frame that fills the inspector, and the four page probes finding no fail on it'; + +export async function pair() { + const problems = []; let checks = 0; + const must = (what, cond) => { checks++; if (!cond) problems.push(what); }; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-studio-')); + fs.cpSync(FIXTURE, tmp, { recursive: true, filter: s => !/\/\.uxcli\/(index\.json|map|studio|mockups\/\.shots|mockups\/index\.html)$/.test(s) }); + + // the board + const r = await writeStudio(tmp, { viewport: '390x844' }); + const m = r.model; + must(`the board was not written: ${(r.problems || []).join('; ')}`, m && fs.existsSync(r.page)); + if (!m) return { ok: false, checks, problems }; + const declared = r.model.journeys.reduce((n, j) => n + j.workflows.reduce((k, w) => k + w.steps.length, 0), 0); + must('a step of a journey has no column', m.counts.steps === declared && declared > 0); + const lead = m.journeys.find(j => j.id === 'handle-inbound-lead'); + const detail = lead?.workflows.flatMap(w => w.steps).find(s => s.after.state === 'agent.lead_detail'); + must('the picked drawing of agent.lead_detail is not on the board', detail?.design.pick?.pick === 'b-call-first' && detail.design.variants.every(v => v.shot)); + const html = fs.readFileSync(r.page, 'utf8'); + must('the page does not carry its model', html.includes('id="studio-data"') && html.includes('"handle-inbound-lead"')); + + // decisions: through the parser, create only + const open = (await writeStudio(tmp)).model.journeys.flatMap(j => j.workflows.flatMap(w => w.steps)).map(s => s.design).find(d => d.variants.length && !d.pick); + must('the fixture has no screen waiting for a pick', !!open); + if (open) { + const who = { type: 'person', ref: 'test' }; + const bad = decide(tmp, 'pick', { state: open.state, variant: 'not-a-drawing' }, who); + must('a pick of a drawing that is not on disk was written', !bad.ok && bad.problems.some(p => p.includes('not a variant'))); + const ok = decide(tmp, 'pick', { state: open.state, variant: open.variants[0].name }, who); + const doc = ok.ok ? JSON.parse(fs.readFileSync(path.join(tmp, ok.file), 'utf8')) : null; + must(`a valid pick was not written with its hash: ${(ok.problems || []).join('; ')}`, ok.ok && /^[0-9a-f]{64}$/.test(doc?.sha256 || '') && doc.by.ref === 'test'); + const again = decide(tmp, 'pick', { state: open.state, variant: open.variants[0].name }, who); + must('a second pick overwrote the first', !again.ok && again.problems.some(p => p.includes('already picked'))); + } + const silent = decide(tmp, 'revise', { state: 'agent.workspace_ready', note: ' ' }, { type: 'person', ref: 'test' }); + must('a redraw with no note was written', !silent.ok); + + // the server and the browser + let srv = null; let browser = null; + try { + srv = await serveStudio(tmp, { port: 0 }); + const base = srv.url; + const get = async p => { const res = await fetch(base + p); return { status: res.status, text: await res.text() }; }; + must('the server did not answer the page', (await get('.uxcli/studio/index.html')).status === 200); + const data = await get('.uxcli/studio/data.json'); + must('the server did not answer the model', data.status === 200 && JSON.parse(data.text).serve === true); + must('the server handed out a file outside .uxcli/', (await get('..%2Fserver.mjs')).status === 404 && (await get('server.mjs')).status === 404); + const junk = await fetch(base + 'api/pick', { method: 'POST', body: 'not json' }); + must('the server accepted a pick it could not parse', junk.status === 400); + + browser = await launch(); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + const errors = []; page.on('pageerror', e => errors.push(e.message)); + await page.goto(base); await page.waitForTimeout(600); + must(`the page threw: ${errors.join('; ')}`, !errors.length); + must('the canvas carries no frame', await page.locator('.frame').count() >= m.counts.steps * 2); + const t = () => page.evaluate(() => document.getElementById('world').style.transform); + const t0 = await t(); + const st = await page.locator('#stage').boundingBox(); + await page.mouse.move(st.x + 8, st.y + 8); await page.mouse.down(); await page.mouse.move(st.x + 160, st.y + 120, { steps: 5 }); await page.mouse.up(); + const t1 = await t(); + must('dragging the canvas did not move it', t1 !== t0); + await page.click('#zin'); must('zooming in did not scale the canvas', (await t()) !== t1); + await page.click('.outline button[data-go^="built|handle-inbound-lead"]'); await page.waitForTimeout(200); + must('a frame did not fill the inspector', /Built · the last walk/.test(await page.textContent('#insp'))); + await page.click('#zfit'); + // the instrument's own page probes, on the board + const { runPage } = await import('../src/page.js'); + const out = path.join(tmp, 'probe'); fs.mkdirSync(out, { recursive: true }); + const res = await runPage(base + '.uxcli/studio/index.html', { browser, outDir: out }); + for (const p of res.probes || []) must(`studio: probe ${p.probe} says ${p.verdict}: ${p.why || ''}`, p.verdict !== 'fail'); + must('the page probes did not run on the studio', (res.probes || []).length >= 4); + } catch (e) { problems.push(`studio in the browser: ${e.message}`); } + finally { if (browser) await browser.close(); if (srv?.server) await new Promise(ok => srv.server.close(ok)); } + fs.rmSync(tmp, { recursive: true, force: true }); + return { ok: !problems.length, checks, problems }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const r = await pair(); + console.log(r.ok ? `PASS studio · ${r.checks} checks` : 'FAIL ' + r.problems.join('\n ')); + process.exit(r.ok ? 0 : 1); +} From f14edfc89a6474712b20651c2d3fe56821d8e2c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:21:56 +0000 Subject: [PATCH 2/5] roadmap: navigation and feedback pools, two templates, trace schemas, walkthroughs, studio compare and notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library. Two topic pools researched from the source repositories of USWDS, GOV.UK, Primer, Carbon, Polaris and the W3C, every quote checked verbatim: navigation and information architecture (10) and motion and feedback (10); the lenses carry 187 viewpoints. Templates `content` (docs, help centres) and `mobile-app`. `template apply` records the template in .uxcli/template.json, and `context show` names it, the lens for each kind of screen, and the lens each screen of the journey was reviewed against. Evidence. schemas/trace.schema.json (a step of a walk; a typed value stored instead of its hash does not fit) and experience.schema.json (the report), held by the experience pairs. Walkthrough. `uxcli walkthrough [--as=] [--for=…] | check`: the four questions of Wharton, Rieman, Lewis & Polson (1994) at every step of a walk, answered from its screenshot, as an actor the project wrote. check refuses a step left out, an answer without why, a no without where, a persona nobody wrote, an agent answering for nobody, and a yes the walk contradicts (noticing a control measured below the fold; progress where the step did not arrive). "No" and "unsure" are findings, shown on the studio's inspector. Schema and references/walkthrough.md. Studio. Two named versions compare on the board; a note on a built frame becomes a `redesign` proposal citing the walk; a frame's picture opens on its own. Gate: walkthrough-pairs (12), experience-pairs (46), studio-pairs (27), template-pairs (28), lens-pairs (47). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- .claude/skills/uxcli/SKILL.md | 1 + .claude/skills/uxcli/lenses/content.md | 159 +++--- .claude/skills/uxcli/lenses/data.md | 231 +++++---- .claude/skills/uxcli/lenses/marketing.md | 181 +++---- .claude/skills/uxcli/lenses/shop.md | 262 ++++++---- .claude/skills/uxcli/lenses/transaction.md | 306 ++++++------ .claude/skills/uxcli/lenses/workspace.md | 317 +++++++----- .../skills/uxcli/references/walkthrough.md | 35 ++ .claude/skills/uxcli/templates/content.md | 83 +++ .claude/skills/uxcli/templates/mobile-app.md | 84 ++++ CHANGELOG.md | 18 +- PRODUCT.md | 2 +- README.md | 15 +- ROADMAP.md | 24 +- VISION.md | 4 +- bin/uxcli.js | 23 +- docs/lenses/README.md | 2 + docs/lenses/build-lenses.mjs | 2 +- docs/lenses/feedback.md | 107 ++++ docs/lenses/navigation.md | 219 ++++++++ examples/crm/.uxcli/template.json | 5 + schemas/experience.schema.json | 43 ++ schemas/trace.schema.json | 50 ++ schemas/walkthrough.schema.json | 22 + skills/uxcli/SKILL.md | 1 + skills/uxcli/lenses/content.json | 41 +- skills/uxcli/lenses/content.md | 159 +++--- skills/uxcli/lenses/data.json | 63 ++- skills/uxcli/lenses/data.md | 231 +++++---- skills/uxcli/lenses/marketing.json | 49 +- skills/uxcli/lenses/marketing.md | 181 +++---- skills/uxcli/lenses/shop.json | 72 ++- skills/uxcli/lenses/shop.md | 262 ++++++---- skills/uxcli/lenses/transaction.json | 56 ++- skills/uxcli/lenses/transaction.md | 306 ++++++------ skills/uxcli/lenses/viewpoints/feedback.json | 471 ++++++++++++++++++ .../uxcli/lenses/viewpoints/navigation.json | 458 +++++++++++++++++ skills/uxcli/lenses/workspace.json | 79 +-- skills/uxcli/lenses/workspace.md | 317 +++++++----- skills/uxcli/references/walkthrough.md | 35 ++ skills/uxcli/templates/content.json | 84 ++++ skills/uxcli/templates/content.md | 83 +++ skills/uxcli/templates/mobile-app.json | 85 ++++ skills/uxcli/templates/mobile-app.md | 84 ++++ src/core/model/walkthrough.js | 73 +++ src/core/studio-page.js | 20 +- src/core/studio.js | 3 +- src/studio.js | 22 +- src/suites.js | 1 + src/template.js | 11 + src/walkthrough.js | 66 +++ test/experience-pairs.mjs | 12 + test/studio-pairs.mjs | 6 + test/template-pairs.mjs | 2 +- test/walkthrough-pairs.mjs | 52 ++ 55 files changed, 4150 insertions(+), 1430 deletions(-) create mode 100644 .claude/skills/uxcli/references/walkthrough.md create mode 100644 .claude/skills/uxcli/templates/content.md create mode 100644 .claude/skills/uxcli/templates/mobile-app.md create mode 100644 docs/lenses/feedback.md create mode 100644 docs/lenses/navigation.md create mode 100644 examples/crm/.uxcli/template.json create mode 100644 schemas/experience.schema.json create mode 100644 schemas/trace.schema.json create mode 100644 schemas/walkthrough.schema.json create mode 100644 skills/uxcli/lenses/viewpoints/feedback.json create mode 100644 skills/uxcli/lenses/viewpoints/navigation.json create mode 100644 skills/uxcli/references/walkthrough.md create mode 100644 skills/uxcli/templates/content.json create mode 100644 skills/uxcli/templates/content.md create mode 100644 skills/uxcli/templates/mobile-app.json create mode 100644 skills/uxcli/templates/mobile-app.md create mode 100644 src/core/model/walkthrough.js create mode 100644 src/walkthrough.js create mode 100644 test/walkthrough-pairs.mjs diff --git a/.claude/skills/uxcli/SKILL.md b/.claude/skills/uxcli/SKILL.md index 87e67fb..d64e5c4 100644 --- a/.claude/skills/uxcli/SKILL.md +++ b/.claude/skills/uxcli/SKILL.md @@ -107,6 +107,7 @@ Before you design or change a screen, you read `uxcli context show` for the jour | `references/journey.md` | the card says no journey, or a flow has changed shape: states as signals, hooks, workflows | | `references/mockups.md` | a screen the journey names has no mockup or no pick: variants, hooks, `pick.json` | | `references/understand.md` | the card says no understanding, or an insight the screen leans on is missing or a hypothesis | +| `references/walkthrough.md` | a journey was walked and you need to know whether a person could follow it: the four questions at every step, as one of the project's actors | | `references/versions.md` | a walk showed something worth changing: naming versions, a redesign proposal with evidence, comparing two versions | | `references/research.md` | you do not know the product's field from sources yet: where to look, what to come back with, how to write it down | | `templates/.md` | the product is a kind uxcli has a template for — `workspace`, `shop`, `landing`: screens and their lens, journeys to walk, what to research | diff --git a/.claude/skills/uxcli/lenses/content.md b/.claude/skills/uxcli/lenses/content.md index f93a15b..1519b62 100644 --- a/.claude/skills/uxcli/lenses/content.md +++ b/.claude/skills/uxcli/lenses/content.md @@ -2,7 +2,7 @@ Docs, articles, blogs and help centres: long text read and navigated by section. -39 viewpoints from named designers. Answer every one for the screen you are looking at: `holds` with where, `breaks` with where and what, `n/a` with why. The sequence, the review file and `uxcli review check` are in `../references/lenses.md`. The rules are the designers', not uxcli's and not yours. +42 viewpoints from named designers. Answer every one for the screen you are looking at: `holds` with where, `breaks` with where and what, `n/a` with why. The sequence, the review file and `uxcli review check` are in `../references/lenses.md`. The rules are the designers', not uxcli's and not yours. ## 1. `usability.signifiers-make-clickable-look-clickable` @@ -51,17 +51,7 @@ Write in the words the actor already uses; no internal jargon, error codes or un - **Look at:** Flag bare error codes (e.g. ERR-422, Error 0x…), all-caps acronyms not defined on the page, and Latin abbreviations; whether a term is jargon for this actor needs the journey's actor. - **Unless:** Expert-only tools where the domain term is the users' language — match the user, do not simplify per se -## 5. `usability.no-false-affordances` - -Nothing that is not interactive may look interactive: no underlined or blue static text, no button-shaped badges, no pointer cursor on inert elements. - -- **Source:** Hoa Loranger, NN/g, *Beyond Blue Links: Making Clickable Elements Recognizable (2015)* — https://www.nngroup.com/articles/clickable-elements/ -- **In their words:** "Avoid making non-clickable items (like headings) resemble buttons." -- **Do:** Reserve link colour and underline for links; Reserve button shape and fill for buttons -- **Don't:** Give static items hyperlink colours; Underline non-interactive text; Make headings or badges resemble buttons -- **Look at:** Elements with no href, handler, role or tabindex that have cursor: pointer, underline plus link colour, or a button-like box (border-radius, filled background, short centred text). - -## 6. `usability.mindless-clicks-not-fewer-clicks` +## 5. `usability.mindless-clicks-not-fewer-clicks` Do not count clicks; make each one an unambiguous choice with clear link text that says where it goes. @@ -73,7 +63,29 @@ Do not count clicks; make each one an unambiguous choice with clear link text th - **Unless:** Fewer clicks matter more when the same path is drilled repeatedly or pages take long to load (Krug) - **Also stated as:** writing.links-describe-their-destination (Mailchimp). -## 7. `usability.follow-conventions` +## 6. `usability.banner-blindness-dont-style-content-like-ads` + +Keep essential content and the primary action in the main column, styled like content, never in a right rail, top strip or animated coloured box. + +- **Source:** Kara Pernice, NN/g, *Banner Blindness Revisited (2018)* — https://www.nngroup.com/articles/banner-blindness-old-and-new-findings/ (study) +- **In their words:** "Users have learned to ignore content that resembles ads, is close to ads, or appears in locations traditionally dedicated to ads." +- **Do:** Put essential content in the main column; Style key notices like content +- **Don't:** Put the primary CTA in the right rail or a top banner strip; Give a key notice animation, coloured background or fancy formatting; Place essential content next to real ads +- **Look at:** Is the journey's primary action or a required notice positioned in the right rail (x > 70% of desktop viewport) or in a full-width top strip with background fill and animation? +- **Unless:** On mobile, large inline ads do get fixated — the effect is weaker for inline placement +- **Also stated as:** feedback.moving-content-can-be-paused (W3C Accessibility Guidelines Working Group). + +## 7. `usability.no-false-affordances` + +Nothing that is not interactive may look interactive: no underlined or blue static text, no button-shaped badges, no pointer cursor on inert elements. + +- **Source:** Hoa Loranger, NN/g, *Beyond Blue Links: Making Clickable Elements Recognizable (2015)* — https://www.nngroup.com/articles/clickable-elements/ +- **In their words:** "Avoid making non-clickable items (like headings) resemble buttons." +- **Do:** Reserve link colour and underline for links; Reserve button shape and fill for buttons +- **Don't:** Give static items hyperlink colours; Underline non-interactive text; Make headings or badges resemble buttons +- **Look at:** Elements with no href, handler, role or tabindex that have cursor: pointer, underline plus link colour, or a button-like box (border-radius, filled background, short centred text). + +## 8. `usability.follow-conventions` Work the way the sites and platforms users already know; a convention beats a locally optimised novelty. @@ -85,17 +97,6 @@ Work the way the sites and platforms users already know; a convention beats a lo - **Unless:** Desktop apps follow their own OS: Windows OK-first, Apple OK-last - **Also stated as:** modern.boring-and-familiar-beats-novel (Scott Berkun). -## 8. `usability.banner-blindness-dont-style-content-like-ads` - -Keep essential content and the primary action in the main column, styled like content, never in a right rail, top strip or animated coloured box. - -- **Source:** Kara Pernice, NN/g, *Banner Blindness Revisited (2018)* — https://www.nngroup.com/articles/banner-blindness-old-and-new-findings/ (study) -- **In their words:** "Users have learned to ignore content that resembles ads, is close to ads, or appears in locations traditionally dedicated to ads." -- **Do:** Put essential content in the main column; Style key notices like content -- **Don't:** Put the primary CTA in the right rail or a top banner strip; Give a key notice animation, coloured background or fancy formatting; Place essential content next to real ads -- **Look at:** Is the journey's primary action or a required notice positioned in the right rail (x > 70% of desktop viewport) or in a full-width top strip with background fill and animation? -- **Unless:** On mobile, large inline ads do get fixated — the effect is weaker for inline placement - ## 9. `usability.aesthetic-usability-effect-bias` Polish makes a screen look more usable than it is; discount your aesthetic impression and run the measurable checks first. @@ -152,7 +153,19 @@ Draw every margin, padding and gap from one scale, and make space between groups - **Unless:** Optical adjustments on icons and hanging punctuation sit a pixel or two off-scale - **Also stated as:** canon.rams-thorough-to-the-last-detail (Dieter Rams); modern.radii-are-few-and-concentric (Vercel Labs); modern.quality-is-a-choice-spec-is-the-floor (Karri Saarinen); modern.spacing-comes-from-a-scale (Stan Kirilov). -## 14. `craft.separation-order-space-then-lines-then-boxes` +## 14. `craft.wcag-contrast-and-dont-rely-on-colour-alone` + +Meet 4.5:1 for body text and 3:1 for headlines, prefer soft backgrounds with dark text, and never convey status by colour alone. + +- **Source:** Erik D. Kennedy, *100 Things a UX/UI Designer Should Know* — https://www.learnui.design/blog/100-things-ux-ui-designer-know.html +- **In their words:** "The WCAG recommended contrast ratio for body text — 4.5:1 to meet AA standards" +- **Do:** Style coloured badges as a soft background with dark text; Pair every colour state with an icon or label +- **Don't:** Set white text on yellow, green or red fills that fail 4.5:1; Convey status by colour only +- **Look at:** Standard contrast ratio of computed text colour against effective background at 4.5:1 or 3:1 by size; status elements must carry a non-colour signal such as text or an icon. +- **Unless:** Disabled controls; Logos; Incidental text, per WCAG itself +- **Also stated as:** modern.contrast-and-not-colour-alone (Apple); color.never-the-only-signal (W3C Accessibility Guidelines Working Group); navigation.you-are-here (U.S. Web Design System (USWDS)). + +## 15. `craft.separation-order-space-then-lines-then-boxes` Use the lightest separator that works: more space first, then a keyline or background band, and a box only for the object that is acted on. @@ -162,7 +175,7 @@ Use the lightest separator that works: more space first, then a keyline or backg - **Look at:** Between sibling groups, record which separator is used: gap at least 2x the inner gap (space), hr or border-bottom (line), bordered or shadowed wrapper (box); report the inner/outer gap ratio. - **Unless:** Dense data such as tables where zebra stripes or keylines are used; Interactive cards that are the unit of action -## 15. `craft.body-16px-line-height-1-5` +## 16. `craft.body-16px-line-height-1-5` Body copy at 16px or more with 1.5 line height; inputs at 16px or more on mobile. @@ -174,18 +187,6 @@ Body copy at 16px or more with 1.5 line height; inputs at 16px or more on mobile - **Unless:** Interaction-heavy desktop pages may go to 14px; Captions sit 2px under body by design - **Also stated as:** canon.butterick-body-size-15-25px (Matthew Butterick); modern.readable-type-sizes-and-weights (Rauno Freiberg). -## 16. `craft.wcag-contrast-and-dont-rely-on-colour-alone` - -Meet 4.5:1 for body text and 3:1 for headlines, prefer soft backgrounds with dark text, and never convey status by colour alone. - -- **Source:** Erik D. Kennedy, *100 Things a UX/UI Designer Should Know* — https://www.learnui.design/blog/100-things-ux-ui-designer-know.html -- **In their words:** "The WCAG recommended contrast ratio for body text — 4.5:1 to meet AA standards" -- **Do:** Style coloured badges as a soft background with dark text; Pair every colour state with an icon or label -- **Don't:** Set white text on yellow, green or red fills that fail 4.5:1; Convey status by colour only -- **Look at:** Standard contrast ratio of computed text colour against effective background at 4.5:1 or 3:1 by size; status elements must carry a non-colour signal such as text or an icon. -- **Unless:** Disabled controls; Logos; Incidental text, per WCAG itself -- **Also stated as:** modern.contrast-and-not-colour-alone (Apple); color.never-the-only-signal (W3C Accessibility Guidelines Working Group). - ## 17. `craft.line-length-50-75-characters` Cap paragraph measure at 50–75 characters per line and do not let it fall under about 30. @@ -286,7 +287,7 @@ Icons carry labels, menus are named for what they hold, and no primary feature h - **Don't:** More, ellipsis or Tools catch-alls holding primary features; Icon-only toolbars without labels; Tooltips on disabled buttons - **Look at:** Icon-only buttons with no text and no aria-label fail; menus triggered by More, …, Tools or Options holding over 5 items or a primary-action label warn; disabled buttons with title or aria-describedby fail. - **Unless:** Universally recognised icons in tight toolbars, though even the hamburger is weaker than designers think -- **Also stated as:** usability.dont-make-me-think (Steve Krug). +- **Also stated as:** navigation.primary-nav-visible-on-wide-screens (GitHub Primer); usability.dont-make-me-think (Steve Krug); navigation.top-level-is-sections-not-a-site-map (GOV.UK Design System). ## 26. `modern.interactions-feel-immediate-under-200ms` @@ -298,19 +299,9 @@ Interaction transitions run 200ms or less with an ease-out curve so the interfac - **Don't:** Slow transitions on hover, focus, open and close; Bounce or elastic easing on everyday controls; ease-in on an entering element - **Look at:** Computed transition-duration and animation-duration on elements that change on :hover, :focus, [aria-expanded] or [data-state]; flag any over 300ms, warn over 200ms; ease-in on an entering element fails. - **Unless:** Toasts may run slower with a plain ease on purpose for tone; Large page or scene transitions and decorative loops sit outside the interaction budget; Exit animations can be a bit more relaxed +- **Also stated as:** modern.motion-values-proportional-to-trigger (Rauno Freiberg); feedback.motion-duration-scales-with-size (IBM Carbon Design System). -## 27. `modern.motion-values-proportional-to-trigger` - -Scale and fade motion starts near its resting size, in proportion to the trigger, never from zero or a heavy squash. - -- **Source:** Rauno Freiberg, *Web Interface Guidelines* — https://interfaces.rauno.me/ -- **In their words:** "Don't animate dialog scale in from 0 → 1, fade opacity and scale from ~0.8. Don't scale buttons on press from 1 → 0.8, but ~0.96, ~0.9, or so." -- **Do:** Enter dialogs and popovers from scale 0.8–0.97 with opacity; Press buttons to about scale 0.96–0.97 -- **Don't:** Scale-from-zero pops on dialogs; Press states that squash a button to 0.8 or below -- **Look at:** Parse @keyframes and WAAPI keyframes on dialogs, popovers and buttons and read the starting scale(); :active transforms below about 0.9 fail. -- **Unless:** Elements that genuinely originate from a point, such as a FAB expanding into a sheet, can grow from small - -## 28. `modern.animate-only-transform-and-opacity` +## 27. `modern.animate-only-transform-and-opacity` Animate only transform and opacity, listing properties explicitly; never transition all or animate layout properties. @@ -321,7 +312,7 @@ Animate only transform and opacity, listing properties explicitly; never transit - **Look at:** Scan stylesheets and computed transition-property for all and for layout properties; scan @keyframes for width, height, top and left. - **Unless:** Accordion height animation via grid-template-rows or interpolate-size is layout by nature and accepted when it is the only honest way -## 29. `modern.focus-is-visible-and-unobscured` +## 28. `modern.focus-is-visible-and-unobscured` Every focusable element shows a visible focus ring on :focus-visible, and no sticky or fixed element ever covers it. @@ -331,7 +322,7 @@ Every focusable element shows a visible focus ring on :focus-visible, and no sti - **Don't:** outline: none or 0 with no replacement; Focus rings hidden under sticky headers - **Look at:** Tab through every focusable element; diff the focused and unfocused rect or read computed outline and box-shadow under :focus-visible; check the focused rect is not intersected by position fixed or sticky elements above it. -## 30. `modern.numbers-and-text-do-not-shift-layout` +## 29. `modern.numbers-and-text-do-not-shift-layout` Numbers in columns and timers use tabular figures, images carry dimensions, and nothing changes weight on hover. @@ -342,7 +333,7 @@ Numbers in columns and timers use tabular figures, images carry dimensions, and - **Look at:** Numeric td cells whose computed font-variant-numeric lacks tabular-nums; img without width/height or aspect-ratio; sibling rect drift while a counter fixture runs. - **Unless:** Prose numbers -## 31. `modern.defaults-are-decisions-you-inherited` +## 30. `modern.defaults-are-decisions-you-inherited` A library or AI default is someone else's decision; commit to one written aesthetic direction instead of inheriting it. @@ -353,7 +344,7 @@ A library or AI default is someone else's decision; commit to one written aesthe - **Look at:** Score the constellation: purple-to-blue/cyan gradients warn, Inter or Roboto with no display face notes, 3+ identical icon+h3+p cards note, backdrop-filter plus glow notes, gradient text on numerals warns, overshoot cubic-bezier on hover warns. - **Unless:** A purple brand is allowed to be purple; the tell is the constellation and the absence of a decision, not any one colour -## 32. `color.from-tokens-not-hex` +## 31. `color.from-tokens-not-hex` Every colour on the page comes from the design system's named tokens or palette functions, never from hex values copied into components. @@ -364,7 +355,7 @@ Every colour on the page comes from the design system's named tokens or palette - **Look at:** Collect every computed color, background-color, border-*-color, outline-color and fill/stroke of painted elements, and every value of CSS custom properties declared on :root (and on any theme selector). Count distinct painted colours that match no custom-property value (exact RGBA after resolution), and count pairs of painted colours closer than ΔE 2 that are not identical. - **Unless:** Images, illustrations and embedded third-party widgets; Browser defaults on unstyled native controls; GOV.UK: palette colours (not functional ones) are allowed for illustrations and custom components -## 33. `color.one-action-colour-apart-from-status` +## 32. `color.one-action-colour-apart-from-status` Links and primary actions share one interactive colour family, used consistently, and that colour is not the colour of errors, warnings or success. @@ -375,7 +366,7 @@ Links and primary actions share one interactive colour family, used consistently - **Look at:** Take the computed text colour of every a[href] in running text and the background of every primary (first, filled, or type=submit) button. Count distinct hues among them (30° bins), and count links or primary buttons whose colour is within ΔE 10 of the page's error, warning or success colour. Also count non-interactive text elements painted in the link colour. - **Unless:** Primer (GitHub) deliberately uses the success role for primary buttons; a system that states such a mapping consistently is following its own rule; Destructive primary actions (Delete account) take the danger colour on purpose; Navigation menus, where position signals the link -## 34. `color.focus-ring-contrasts-with-its-surroundings` +## 33. `color.focus-ring-contrasts-with-its-surroundings` The focus indicator contrasts at least 3:1 with whatever it is drawn against: the page background for an outer ring, the component's own colours for an inner one. @@ -386,7 +377,7 @@ The focus indicator contrasts at least 3:1 with whatever it is drawn against: th - **Look at:** Tab to each focusable control; diff the focused and unfocused screenshots of the control's box padded by a few pixels; for the changed pixels, take their colour and the colour of the unchanged pixels adjacent to them (page background outside, component fill inside). Count controls where no changed region reaches 3:1 against its adjacent colour. page.focus-visible checks only that some pixel changes; this checks that the change can be seen. - **Unless:** Unmodified browser default focus styles; WCAG does not compare focused and unfocused states with each other; a background-only change is out of scope for 1.4.11 but fails Use of Color -## 35. `color.few-families-in-proportion` +## 34. `color.few-families-in-proportion` Use a few colour families in a deliberate proportion (neutral base dominant, then primary, secondary and a small accent), not an even spread of many hues. @@ -397,7 +388,7 @@ Use a few colour families in a deliberate proportion (neutral base dominant, the - **Look at:** Screenshot the page and bucket every non-neutral pixel (HSL saturation above about 15%) by hue into 30° bins, ignoring images and status colours. How many hue families take more than 2% of the coloured area, and does the largest one carry most of it while the smallest (accent) stays near a tenth? - **Unless:** USWDS: the proportions are for non-base colours; neutral text will usually dominate; Illustration, photography and data visualisation, which need their own palettes; Brands whose identity is multi-hue -## 36. `writing.sentence-case-ui-text` +## 35. `writing.sentence-case-ui-text` Labels, buttons, headings, tabs and table headers are written in sentence case; capitals only for the first word, proper nouns and acronyms — never title case or all caps for emphasis. @@ -408,7 +399,7 @@ Labels, buttons, headings, tabs and table headers are written in sentence case; - **Look at:** For each button, label, th, tab and h1–h6 with three or more words, take the rendered text (after text-transform) and count those where two or more non-initial words that are not acronyms or in the page's proper-noun list start with a capital (title case), or where the whole text of four or more words is upper case. Count must be 0. - **Unless:** Proper nouns, product names and acronyms keep their capitals; Mailchimp's own guide uses title case for page titles, menu names and global navigation — a house style, decide one and keep it; Short tracked overline labels in caps are a typographic choice; the four-word floor leaves them alone -## 37. `writing.dates-numbers-units-for-the-reader` +## 36. `writing.dates-numbers-units-for-the-reader` Dates spell out the month, numbers are numerals with thousands separators, and units sit a space after their number — formatted in the reader's locale, never as an ambiguous all-numeric date or a raw machine value. @@ -419,7 +410,7 @@ Dates spell out the month, numbers are numerals with thousands separators, and u - **Look at:** Scan visible text nodes. Count matches of all-numeric dates (\b\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}\b), raw ISO timestamps (\d{4}-\d{2}-\d{2}T\d{2}:), integers of 5+ digits with no separator outside codes/IDs, and numbers glued to a unit (\d(kg|lb|cm|mm|km|mi|ml|oz)\b). Count must be 0. - **Unless:** Polaris notes these are American English base rules and dates, numbers and measurements should be localized automatically — the target is the reader's locale, not US format; Identifiers, codes, SKUs and years are not quantities and take no separator; Dense data tables may use compact numeric dates if the format is unambiguous for the locale and stated in the column header -## 38. `data-display.no-tables-for-layout` +## 37. `data-display.no-tables-for-layout` A table is for comparing data in rows and columns, never for arranging content on the page; layout belongs to the grid. @@ -431,7 +422,7 @@ A table is for comparing data in rows and columns, never for arranging content o - **Unless:** HTML email, where tables are still the only reliable layout tool - **Also stated as:** modern.cards-are-a-choice-not-a-default (Stan Kirilov). -## 39. `data-display.header-cells-are-th-with-scope` +## 38. `data-display.header-cells-are-th-with-scope` A data table marks its header cells as th and its data cells as td; when headers run both across and down, scope (or id/headers in complex tables) ties each cell to its headers. @@ -441,3 +432,47 @@ A data table marks its header cells as th and its data cells as td; when headers - **Don't:** Header rows built from td styled bold; Div grids that look like tables with no role=table/row/columnheader/cell semantics; One table holding several topics separated by extra rows of th - **Look at:** Count table (and role=table/grid) elements that have more than one row of data and no th / role=columnheader; count tables with both a header row and a header column whose th lack scope; count tables with no caption, aria-label or aria-labelledby. - **Unless:** A simple table with one header row needs only th, not scope; scope matters once there are header rows and header columns; Layout tables, which should not exist (see data-display.no-tables-for-layout) + +## 39. `navigation.breadcrumbs-only-for-real-hierarchy` + +Breadcrumbs belong on pages more than two levels deep in a hierarchy; never on a flat site, never to show steps in a linear flow, and never in place of the primary navigation. + +- **Source:** GOV.UK Design System, *Breadcrumbs component* — https://design-system.service.gov.uk/components/breadcrumbs/ +- **In their words:** "Do not use the breadcrumbs component on websites with a flat structure, or to show progress through a linear journey or transaction." +- **Do:** A trail that starts with the word 'Home' and follows the site's hierarchy, not the user's click history; Breadcrumbs placed at the top of the page, before
    , so the skip link skips them; Every ancestor a link; the current page last and marked aria-current, or left off; Breadcrumb wording identical to the page titles it points at +- **Don't:** Breadcrumbs that list the steps of a sign-up or checkout; 'Home > About' on a site whose every page is linked from the top nav; Breadcrumbs as the only way to move between sections; Breadcrumbs and a Back link on the same page +- **Look at:** If the page has a breadcrumb (a nav whose accessible name contains 'breadcrumb', or an ordered list of links styled as one, before main): does it hold at least two ancestor links (Home > Section > page), does it sit before main, is every item except the current page a link, is there also a primary nav on the page, and is there no Back link and no step indicator on the same page? +- **Unless:** 'At least two ancestor links' is uxcli's reading of Carbon's 'more than two levels'; no source gives a link count; GOV.UK ends the trail with the parent section; Primer and USWDS end it with the current page marked aria-current; either passes; USWDS: omit breadcrumbs on the home page and optionally on section landing pages, and where a side navigation already shows the hierarchy; Carbon recognises path-based breadcrumbs (the steps the user took) as a type; GOV.UK and USWDS do not, and this viewpoint follows them + +## 40. `navigation.skip-link-is-the-first-tab-stop` + +The first Tab press on every page lands on a visible 'Skip to main content' link that moves focus past the header and navigation into main. + +- **Source:** GOV.UK Design System, *Skip link component* — https://design-system.service.gov.uk/components/skip-link/ +- **In their words:** "Including the skip link component gives users the option to bypass the top-level navigation links and jump to the main content on a page." +- **Do:** The skip link immediately after (or after a cookie banner); Visually hidden until it receives keyboard focus, then clearly shown; A target id on
    (or its first heading) that can take focus; Breadcrumbs and back links placed before
    , so the skip link skips them too +- **Don't:** A page whose first Tab stop is the logo or the first of a dozen nav links; A skip link that stays invisible when focused; A skip link whose href points at an id that does not exist; A skip link wrapped in