From 5fcb02651e8aad0dda2c30668f05c335729a38c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:17:29 +0000 Subject: [PATCH 1/3] dashboard: the management view, picked from drawings; template apply honours --src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uxcli dashboard [dir …] [--serve]: read-only management view of one or more projects. Overview is a coverage matrix (every screen the journeys pass through against understood, drawn, picked, reviewed, walked, verdict, estimate, walkthrough); a gap is never a pass and names the command that fills it. Runs, Design and Understanding views. Served, it switches folders, adds one from the page, and serves pictures only from under .uxcli/. - Layout and look picked by the owner from three drawings each (docs/design/dashboard/): the coverage matrix, the "control room" theme. - Gate: dashboard-pairs, which runs the page probes on every view. - template apply --src=DIR wrote into the current directory; it now writes into DIR's project root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- .gitignore | 1 + CHANGELOG.md | 16 +++ README.md | 15 +++ bin/uxcli.js | 15 ++- docs/design/dashboard/README.md | 14 +++ docs/design/dashboard/a-attention.html | 24 ++++ docs/design/dashboard/b-matrix.html | 18 +++ docs/design/dashboard/c-timeline.html | 18 +++ docs/design/dashboard/theme-1-instrument.html | 14 +++ docs/design/dashboard/theme-2-notebook.html | 14 +++ docs/design/dashboard/theme-3-control.html | 14 +++ src/core/dashboard-page.js | 95 ++++++++++++++++ src/core/dashboard.js | 105 ++++++++++++++++++ src/dashboard.js | 105 ++++++++++++++++++ src/suites.js | 1 + test/dashboard-pairs.mjs | 63 +++++++++++ 16 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 docs/design/dashboard/README.md create mode 100644 docs/design/dashboard/a-attention.html create mode 100644 docs/design/dashboard/b-matrix.html create mode 100644 docs/design/dashboard/c-timeline.html create mode 100644 docs/design/dashboard/theme-1-instrument.html create mode 100644 docs/design/dashboard/theme-2-notebook.html create mode 100644 docs/design/dashboard/theme-3-control.html create mode 100644 src/core/dashboard-page.js create mode 100644 src/core/dashboard.js create mode 100644 src/dashboard.js create mode 100644 test/dashboard-pairs.mjs diff --git a/.gitignore b/.gitignore index 5018991..19ea341 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ experiments/*/results/*/transcript.jsonl **/.uxcli/mockups/index.html **/.uxcli/map/ **/.uxcli/studio/ +**/.uxcli/dashboard/ **/.uxcli/experience/ # experiment results stay on the machine that ran them diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7d323..c3887da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ Entries are written per release. The log is the source material, not the text. ## [Unreleased] +### Added — the dashboard + +- `uxcli dashboard [dir …] [--serve [--port=N]] [--json]`: the management view of one or more projects, + read-only, in the look the owner picked from three ("control room"). Overview is a coverage matrix — + every screen the journeys pass through against understood, drawn, picked, reviewed, walked, verdict, + estimate and walkthrough; a gap is never a pass and names the command that fills it; a journey whose + packet keeps no steps reads as ran-with-its-verdict, the start of an unwalked journey as nothing to + measure. Runs, Design and Understanding views. Served, it switches folders, takes another one from the + page, and serves pictures only from under a folder's `.uxcli/`. Gate: `dashboard-pairs`, which also + runs the page probes on every view. + +### Fixed + +- `uxcli template apply --src=DIR` wrote into the current directory; it now writes into DIR's + project root. + ### Added — the designer's habits: a library to cite, templates to start from, the walk measured - Policy `project.design: off | drawn | picked`: a project can hold the work to a design step. A journey diff --git a/README.md b/README.md index b3fd5ee..1130958 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,20 @@ the board, each frame of the later one saying what moved. 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. +## The dashboard + +```bash +uxcli dashboard . ../other-app --serve # http://127.0.0.1:4319 — every folder's .uxcli/, switched from the sidebar +``` + +The management view, read-only. **Overview** is a coverage matrix: each row a screen the journeys pass +through, each column a kind of evidence uxcli keeps — understood, drawn, picked, reviewed, walked, +verdict, estimate, walkthrough. A dashed cell is a gap, never a pass, and names the command that fills +it. **Runs** lists every packet, each opening to its steps, pictures and findings; **Design** shows each +screen's drawings, reviews and pick; **Understanding** the actors, their open questions and the insights +with their confidence and last check. Another folder is added from the page. Decisions stay in the +studio and in the files. Without `--serve`, the pages are written to `.uxcli/dashboard/`. + ## Install | Agent | Skill | Instrument | @@ -217,6 +231,7 @@ uxcli review check # every review complete, fresh, and not co uxcli walkthrough [--as=] | check # the four cognitive-walkthrough questions at every step, answered from the screenshots; held to the walk 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 dashboard [dir …] [--serve] # the management view: coverage matrix, runs, design, understanding; served, several folders 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 diff --git a/bin/uxcli.js b/bin/uxcli.js index d64c121..864f907 100755 --- a/bin/uxcli.js +++ b/bin/uxcli.js @@ -47,6 +47,7 @@ const usage = `usage: uxcli prefer make --a= --b= [--question=…] | serve --judge= [--port=N] | tally [--json] blind pairwise preference: two folders of pictures of the same screens, paired by file name and renamed so nothing names a group; each judge answers on a local page in their own order with sides swapped; tally counts wins and a sign test. A judge is a person, never an agent uxcli walkthrough [--as=] [--for=] [--run=R-…] | check a cognitive walkthrough of the last walk: four questions at every step, answered looking at its screenshot, optionally as one of the project's actors; check refuses an incomplete one and one the walk contradicts; "no" and "unsure" are findings + uxcli dashboard [dir …] [--serve [--port=N]] [--json] the management view, read-only: every screen against every kind of evidence (a gap is never a pass), the runs with their steps and pictures, the design, the understanding; --serve on localhost switches between folders and takes another folder from the page 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 @@ -185,6 +186,18 @@ try { if (r.problems) { console.error('uxcli: ' + r.problems.join('\n ')); process.exit(1); } console.log(W.writtenCard(r, root)); process.exit(0); + } else if (cmd === 'dashboard') { + // The management view of one or more projects. Read-only: decisions stay in the studio and the files. + const DB = await import('../src/dashboard.js'); const dirs = args.length ? args : ['.']; + if (flags.has('--serve')) { + const r = await DB.serveDashboard(dirs, { port: Number(opt('port') || 4319) }); + if (!r.server) { console.error('uxcli: ' + r.problems.join('\n ')); process.exit(1); } + console.log(`uxcli dashboard · ${r.roots.length} folder${r.roots.length === 1 ? '' : 's'}${r.problems.length ? ' · skipped: ' + r.problems.join('; ') : ''}\n open ${r.url} (ctrl-c to stop)`); await new Promise(() => {}); + } + const { dashboardCard } = await import('../src/core/dashboard.js'); + const r = await DB.writeDashboard(dirs[0]); if (!r.matrix) { console.error('uxcli: ' + (r.problems || []).join('\n ')); process.exit(1); } + console.log(flags.has('--json') ? JSON.stringify({ matrix: r.matrix, runs: r.runs, design: r.design, understanding: r.understanding }, null, 1) : dashboardCard(r.matrix, path.relative(process.cwd(), r.page))); + 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'; @@ -219,7 +232,7 @@ try { const T = await import('../src/template.js'); const lib = T.templates(); const pick = id => { const t = lib.list.find(x => x.id === id); if (!t) { console.error(`no template ${id}; have: ${lib.list.map(x => x.id).join(', ')}`); process.exit(1); } return t; }; if (args[0] === 'show' && args[1]) { const t = pick(args[1]); console.log(flags.has('--json') ? JSON.stringify(t, null, 1) : T.templateShowCard(t)); } - else if (args[0] === 'apply' && args[1]) { const t = pick(args[1]); const r = T.applyTemplate(args[2] || '.', t); console.log(flags.has('--json') ? JSON.stringify(r, null, 1) : T.applyCard(r, t)); } + else if (args[0] === 'apply' && args[1]) { const t = pick(args[1]); const at = path.resolve(args[2] || opt('src') || '.'); const r = T.applyTemplate((await import('../src/journey.js')).findRoot(at) || at, t); console.log(flags.has('--json') ? JSON.stringify(r, null, 1) : T.applyCard(r, t)); } else console.log(flags.has('--json') ? JSON.stringify({ templates: lib.list.map(t => ({ id: t.id, name: t.name, when: t.when })), problems: lib.problems }, null, 1) : T.templateListCard(lib)); process.exit(lib.problems.length ? 1 : 0); } else if (cmd === 'review' && args[0] === 'check') { diff --git a/docs/design/dashboard/README.md b/docs/design/dashboard/README.md new file mode 100644 index 0000000..7741c6e --- /dev/null +++ b/docs/design/dashboard/README.md @@ -0,0 +1,14 @@ +# The dashboard — what was drawn and what was picked + +Drawn by an agent (Claude) with the CRM example's real data, picked by the owner on 2026-10-03. + +| question | drawings | picked | +|---|---|---| +| What does the page answer first? | `a-attention` (what waits on a person), `b-matrix` (every screen against every kind of evidence), `c-timeline` (everything that happened) | **b-matrix** | +| Which look? | `theme-1-instrument` (light, dense, developer tool), `theme-2-notebook` (warm paper, serif), `theme-3-control` (dark, numbers first) | **theme-3-control** | +| Which views beside the overview, first? | Decisions, Runs, Design, Understanding | **Runs, Design, Understanding** — Decisions stays in the studio and the files, so the dashboard writes nothing | + +Each drawing was measured with `uxcli run` before it was shown; `theme-2-notebook` failed 1.4.3 once +(#9b8f7e on #f4efe6, 2.76:1) and was corrected before the pick. Built as `uxcli dashboard` +(`src/dashboard.js`, `src/core/dashboard.js`, `src/core/dashboard-page.js`); `test/dashboard-pairs.mjs` +runs the page probes on every view. diff --git a/docs/design/dashboard/a-attention.html b/docs/design/dashboard/a-attention.html new file mode 100644 index 0000000..7f841d6 --- /dev/null +++ b/docs/design/dashboard/a-attention.html @@ -0,0 +1,24 @@ +Attention first
examples/crm ▾read from .uxcli/ · 4 runs · updated 2026-09-25 08:41
+ +
+

Waiting for a person · 5

+ + + + +
whatwhere
fail C-001 call action not in view at 390×844 — handle-inbound-lead s2runs/R-…zs8fns
Pick a drawing — agent.call_started asked for a redrawmockups/agent.call_started
3 proposals waiting: instrumentation P-0004 · retirement P-0006 · profile P-0007proposals/
3 unknowns about real_estate_agent with no sourceunderstanding/actors
+
+
handle-inbound-lead

fail last walk 25 Sep · 2 steps

8.4 s estimate · v1 → v2 +3.5 s, one step more

1 finding: call control 2 scrolls down

+
authenticate

fail last walk 25 Sep · 1 step

— no experience walk yet

+

Recent walks

whenjourneyverdictexit
25 Sep 08:41handle-inbound-leadfail2
25 Sep 08:39authenticatefail2
25 Sep 08:30handle-inbound-leadblocked1
24 Sep 09:12handle-inbound-leadfinding2
+
\ No newline at end of file diff --git a/docs/design/dashboard/b-matrix.html b/docs/design/dashboard/b-matrix.html new file mode 100644 index 0000000..73d1629 --- /dev/null +++ b/docs/design/dashboard/b-matrix.html @@ -0,0 +1,18 @@ +Coverage matrix
examples/crm ▾read from .uxcli/ · 4 runs · updated 2026-09-25 08:41

examples/crm · coverage

Each row a screen the journeys name; each column a kind of evidence uxcli keeps. A blank cell is a gap, not a pass.

+
+ + + + + +
journey · screenunderstooddrawnpickedreviewedwalkedverdictestimatewalkthrough
authenticate · anon.login_page✓ 1 actor2a-card—25 Sepfail——
authenticate · anon.login_rejected✓0 — draw——————
handle-inbound-lead · agent.workspace_ready✓2a-list—25 Sepheld3.3 s—
handle-inbound-lead · agent.lead_detail✓ 3 insights2b-call-first—25 SepC-001 fail5.1 s—
handle-inbound-lead · agent.call_started✓1redraw asked—25 Sepheld——
+
commitments
2 active · 1 retiring
proposals
3 open
versions
v1 → v2 +3.5 s
unknowns
3 without a source
\ No newline at end of file diff --git a/docs/design/dashboard/c-timeline.html b/docs/design/dashboard/c-timeline.html new file mode 100644 index 0000000..1055701 --- /dev/null +++ b/docs/design/dashboard/c-timeline.html @@ -0,0 +1,18 @@ +Timeline
examples/crm ▾read from .uxcli/ · 4 runs · updated 2026-09-25 08:41
+
+
    +
  1. walk · 25 Sep 08:41

    fail handle-inbound-lead at 390×844 — C-001: the call action is 2 scrolls down

  2. +
  3. version · 25 Sep 09:00

    v2 of handle-inbound-lead named — estimate 8.4 s, +3.5 s on v1

  4. +
  5. walk · 25 Sep 08:39

    fail authenticate

  6. +
  7. design · 24 Sep

    agent.lead_detail picked b-call-first by the owner · agent.call_started: redraw asked

  8. +
  9. decision · 23 Sep

    P-0003 approved → C-001 active

+
\ No newline at end of file diff --git a/docs/design/dashboard/theme-1-instrument.html b/docs/design/dashboard/theme-1-instrument.html new file mode 100644 index 0000000..ab8fe3c --- /dev/null +++ b/docs/design/dashboard/theme-1-instrument.html @@ -0,0 +1,14 @@ +uxcli · theme-1-instrument +
\ No newline at end of file diff --git a/docs/design/dashboard/theme-2-notebook.html b/docs/design/dashboard/theme-2-notebook.html new file mode 100644 index 0000000..4bbca30 --- /dev/null +++ b/docs/design/dashboard/theme-2-notebook.html @@ -0,0 +1,14 @@ +uxcli · theme-2-notebook +
+
Notebook · overview

CRM môi giới — what we know

Each row a screen the journeys pass through; each column a kind of evidence. A dashed cell is a gap, never a pass.

+
34
empty cells
1
failing
4
waiting on a person
2
commitments active
+
journey · screenunderstooddrawnpickedreviewedwalkedverdictestimatewalkthrough
authenticate
anon.login_page
—————start——
agent.workspace_ready—2a-list—————
anon.login_rejected————————
anon.login_failed_retryable————————
handle-inbound-lead
agent.workspace_ready
1 insight · real_estate_agent———startstart——
agent.lead_detail1 insight · real_estate_agent2b-call-first—2026-09-25no fail3.3 s—
agent.call_started1 insight · real_estate_agent1 — draw anotherredraw asked—2026-09-25C-001 fail5.1 s—
\ No newline at end of file diff --git a/docs/design/dashboard/theme-3-control.html b/docs/design/dashboard/theme-3-control.html new file mode 100644 index 0000000..4651688 --- /dev/null +++ b/docs/design/dashboard/theme-3-control.html @@ -0,0 +1,14 @@ +uxcli · theme-3-control +
+
overview · examples/crm

CRM môi giới · coverage

Each row a screen the journeys pass through; each column a kind of evidence. A dashed cell is a gap, never a pass.

+
34
empty cells
1
failing
4
waiting on a person
2
commitments active
+
journey · screenunderstooddrawnpickedreviewedwalkedverdictestimatewalkthrough
authenticate
anon.login_page
—————start——
agent.workspace_ready—2a-list—————
anon.login_rejected————————
anon.login_failed_retryable————————
handle-inbound-lead
agent.workspace_ready
1 insight · real_estate_agent———startstart——
agent.lead_detail1 insight · real_estate_agent2b-call-first—2026-09-25no fail3.3 s—
agent.call_started1 insight · real_estate_agent1 — draw anotherredraw asked—2026-09-25C-001 fail5.1 s—
\ No newline at end of file diff --git a/src/core/dashboard-page.js b/src/core/dashboard-page.js new file mode 100644 index 0000000..4c3bdff --- /dev/null +++ b/src/core/dashboard-page.js @@ -0,0 +1,95 @@ +// The dashboard's pages, in the look the owner picked on 2026-10-03 ("control room": dark, numbers +// first, status as blocks of colour). One shell — the folder switcher and four views — and a page per +// view: the coverage matrix (picked as the opening view the same day), the runs, the design, the +// understanding. Pure: the data is gathered by src/dashboard.js; `link(view, query)` and `asset(path)` +// say where a view and a picture live, so the same page serves a written folder and a served one. +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +export const VIEWS = [ + { id: 'overview', label: 'Overview', what: 'every screen against every kind of evidence' }, + { id: 'runs', label: 'Runs', what: 'each walk and page run, its steps and what they measured' }, + { id: 'design', label: 'Design', what: 'each screen\'s drawings, the pick and the reviews' }, + { id: 'understanding', label: 'Understanding', what: 'who the product is for, what is known, what is not' }, +]; + +const CSS = `:root{color-scheme:dark;--bg:#0e1116;--panel:#151a21;--raise:#1f2733;--line:#262d38;--line2:#3a4352;--ink:#e6e9ef;--soft:#c4cad6;--dim:#98a2b3;--accent:#7ee2b8;--ok:#7ee2b8;--ok-bg:#12372a;--fail:#ffb3ad;--fail-bg:#4a1717;--find:#ffd27a;--find-bg:#3f3110;--open:#a9c1ff;--open-bg:#1d2a4d;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +*{box-sizing:border-box}body{margin:0;font:13px/1.45 -apple-system,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)} +a{color:var(--open)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.app{display:grid;grid-template-columns:224px minmax(0,1fr);min-height:100vh;background:linear-gradient(to right,var(--panel) 224px,var(--bg) 224px)} +.side{background:var(--panel);border-right:1px solid var(--line);padding:14px 10px;position:sticky;top:0;height:100vh;overflow:auto} +.logo{font:700 14px var(--mono);color:var(--accent);padding:4px 8px 14px} +.fl{display:block;font-size:11px;color:var(--dim);padding:0 8px 6px}.fl select,.add input{display:block;width:100%;margin-top:4px;font:inherit;padding:6px 8px;border:1px solid var(--line2);border-radius:6px;background:var(--bg);color:var(--ink)} +.add{padding:0 8px 14px}.add button{margin-top:6px} +nav a{display:flex;justify-content:space-between;gap:8px;padding:7px 8px;border-radius:6px;color:var(--soft);text-decoration:none}nav a span{font:11px var(--mono);color:var(--dim)}nav a[aria-current=page]{background:var(--raise);color:#fff;box-shadow:inset 2px 0 0 var(--accent)} +main{padding:20px 24px;min-width:0}.eyebrow{font:11px var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}h1{font-size:19px;margin:2px 0 4px}h2{font-size:15px;margin:22px 0 10px}.lede{margin:0;color:var(--dim);max-width:780px} +button,.btn{font:600 13px inherit;font-family:inherit;border-radius:6px;padding:6px 12px;border:1px solid var(--line2);background:var(--panel);color:var(--ink);text-decoration:none;cursor:pointer} +.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin:16px 0}.stat{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px}.sv{font:700 24px var(--mono)}.sk{color:var(--dim);font-size:12px} +.tbl{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:auto}table{border-collapse:collapse;width:100%} +th,td{text-align:left;vertical-align:top;padding:7px 10px;border-top:1px solid var(--line)}thead th{border-top:0;font:11px var(--mono);color:var(--dim);text-transform:uppercase;white-space:nowrap}tbody th{font-weight:400;white-space:nowrap}.sub{color:var(--dim)} +.c{display:inline-block;min-width:48px;font:600 12px var(--mono);border-radius:4px;padding:2px 6px;text-align:center;white-space:nowrap} +.c.ok,.c.pass{background:var(--ok-bg);color:var(--ok)}.c.fail{background:var(--fail-bg);color:var(--fail)}.c.finding{background:var(--find-bg);color:var(--find)}.c.open,.c.blocked{background:var(--open-bg);color:var(--open)}.c.gap{border:1px dashed var(--line2);color:var(--dim)}.c.na{color:var(--dim)} +.why{display:block;color:var(--dim);font-size:12px;margin-top:3px;max-width:280px;white-space:normal} +.legend{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0 0;font-size:12px;color:var(--dim);align-items:center} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px} +.card h3{font-size:14px;margin:0 0 6px}.card ul{margin:6px 0 0;padding-left:18px}.card li{margin:2px 0} +.shots{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}.shot{margin:0;width:180px}.shot a{display:block;border-radius:6px}.shot img{display:block;width:100%;border:1px solid var(--line2);border-radius:6px;background:#fff}.shot figcaption{font-size:12px;color:var(--dim);margin-top:4px} +.none{overflow-wrap:anywhere;display:grid;place-items:center;width:180px;height:110px;border:1px dashed var(--line2);border-radius:6px;color:var(--dim);font-size:12px;text-align:center;padding:8px} +.notice{background:var(--find-bg);color:var(--find);border-radius:8px;padding:8px 12px;margin-bottom:12px} +@media (max-width:760px){.app{grid-template-columns:1fr;background:var(--bg)}.side{position:static;height:auto}main{padding:16px}}`; + +const chip = (state, text) => `${esc(text)}`; +const when = s => s ? esc(String(s).slice(0, 16).replace('T', ' ')) : '—'; + +function overview({ data, link }) { + const m = data.matrix; const t = m.totals; + const waiting = m.rows.filter(r => Object.values(r.cells).some(c => c.state === 'open')).length; + return `
overview · ${esc(m.folder)}

${esc(m.project?.name || m.folder)} · coverage

+

Each row a screen the journeys pass through; each column a kind of evidence uxcli keeps. A dashed cell is a gap, never a pass; point at it for the command that fills it.

+
${t.gaps}
empty cells
${t.fails}
failing screens
${waiting + t.proposals}
waiting on a person · ${t.proposals} proposals
${t.commitments.active}
commitments active${t.commitments.retiring ? ` · ${t.commitments.retiring} retiring` : ''}
${t.unknowns}
unknowns · understanding
+
${m.columns.map(c => ``).join('')} +${m.rows.map((r, i) => `${m.columns.map(c => { const x = r.cells[c.id]; return x.state === 'gap' ? `${chip('gap', '—')}` : ``; }).join('')}`).join('') || ``}
${m.rows.length} screens · ${m.columns.length} kinds of evidence
journey · screen${esc(c.label)}
${i === 0 || m.rows[i - 1].journey !== r.journey ? `${esc(r.journey)}
` : ''}${esc(r.state)}
${chip(x.state, x.text)}${x.fill && x.state !== 'ok' ? `${esc(x.fill)}` : ''}
No journey yet: write one under .uxcli/journeys/ — uxcli init says how.
+
${chip('ok', 'held')} done ${chip('open', 'waiting')} on a person ${chip('finding', 'finding')} reported ${chip('fail', 'fail')} measured ${chip('gap', '—')} gap ${chip('na', '—')} nothing to measure
+${t.versions.length ? `

Versions

${t.versions.map(v => `

${esc(v.journey)}

${esc(v.names.join(' → '))}${v.delta != null ? ` · ${v.delta > 0 ? '+' : ''}${v.delta} s estimate` : ''}
`).join('')}
` : ''}`; +} + +function runs({ data, link, asset, run }) { + const pick = run && data.runs.find(r => r.id === run); + if (pick) return `
runs · ${esc(pick.id)}

${esc(pick.target)} ${chip(pick.worst, pick.worst)}

+

${pick.kind === 'journey' ? 'A walk of the journey' : 'A page run'} · ${when(pick.when)}${pick.viewport ? ` · ${esc(pick.viewport)}` : ''}${pick.environment ? ` · ${esc(pick.environment)}` : ''} · exit ${esc(pick.exit)}${pick.klm != null ? ` · about ${pick.klm} s for a practised user` : ''}${pick.blocked ? ` · blocked: ${esc(pick.blocked)}` : ''}

+${pick.verdicts.length ? `

What it measured

${pick.verdicts.map(v => ``).join('')}
verdictcommitmentstepwhat
${chip(v.value, v.value)}${esc(v.commitment || '—')}${esc(v.step || '—')}${esc(v.what)}
` : ''} +${pick.probes.length ? `

Probes

${pick.probes.map(p => ``).join('')}
probeverdictwhy
${esc(p.probe)}${chip(p.verdict, p.verdict)}${esc(p.why)}
` : ''} +${pick.steps.length ? `

Steps

${pick.steps.map(s => `

${esc(s.workflow ? s.workflow + ' / ' : '')}${esc(s.id)} · ${esc(s.action)}

reaches ${esc(s.state || '—')} · ${s.held === true ? chip('ok', 'held') : s.held === false ? chip('fail', 'not held') : chip('na', 'not measured')}${s.klm != null ? ` · ${s.klm} s` : ''} +${s.findings.length ? `
    ${s.findings.map(f => `
  • ${chip('finding', 'finding')} ${esc(f)}
  • `).join('')}
` : ''} +
${[['before', s.before], ['after', s.after]].map(([k, p]) => p ? `
${esc(s.id)} ${k} the action
${k}
` : `
no ${k} picture kept
(pruned, or the run was copied without artifacts/)
`).join('')}
`).join('')}
` : ''}`; + return `
runs · ${esc(data.matrix.folder)}

Runs

Every packet under .uxcli/runs/, newest first. A run is what Chrome measured once; open one for its steps and pictures.

+
${data.runs.map(r => ``).join('') || ''}
whentargetkindviewportresultexitestimate
${when(r.when)}${esc(r.target)}${r.kind}${esc(r.viewport || '—')}${chip(r.worst, r.worst)}${esc(r.exit)}${r.klm != null ? r.klm + ' s' : '—'}
No run yet: uxcli run <url> or uxcli run .uxcli/journeys/<id>.json
`; +} + +function design({ data, asset }) { + return `
design · ${esc(data.matrix.folder)}

Design

Each screen the journeys name: the drawings, what each does differently, the reviews against a lens, and the pick. Picks and redraws are made in the studio (uxcli studio --serve), not here.

+${data.design.map(s => `

${esc(s.state)} ${s.pick ? chip('ok', `picked ${s.pick}`) : s.revise ? chip('open', 'redraw asked') : s.variants.length ? chip('open', 'waiting for a pick') : chip('gap', 'not drawn')}

+${s.question ? `

${esc(s.question)}

` : ''}${s.revise ? `

redraw asked: ${esc(s.revise)}

` : ''}${s.pickedBy ? `

picked by ${esc(s.pickedBy)}

` : ''} +
${s.variants.map(v => `
${v.shot ? `${esc(s.state)} variant ${esc(v.name)}` : '
not photographed yet
uxcli mockups
'}
${esc(v.name)}${v.name === s.pick ? ' · picked' : ''}${v.about ? `
${esc(v.about)}` : ''}${v.reviews.map(r => `
${esc(r.lens)}: ${esc(r.line)}`).join('')}${v.reviews.length ? '' : '
no lens review'}
`).join('') || `
no drawing: .uxcli/mockups/${esc(s.state)}/<variant>.html
`}
`).join('') || '

No screen named by a journey yet.

'}`; +} + +function understanding({ data }) { + const u = data.understanding; + return `
understanding · ${esc(data.matrix.folder)}

Understanding

Who the product is for and what is known about them, as .uxcli/understanding/ says. An unknown stays a question until a source answers it; an insight is only as confident as its evidence and its last check.${u.template ? ` Started from the ${esc(u.template)} template.` : ''}

+

Actors

${u.actors.map(a => `

${esc(a.actor)}

${(a.roles || []).length ? `

${esc(a.roles.join(' · '))}

` : ''} +${[['jobs', a.jobs], ['pains', a.pains], ['expectations', a.expectations]].filter(([, xs]) => (xs || []).length).map(([k, xs]) => `

${k}

    ${xs.map(x => `
  • ${esc(x)}
  • `).join('')}
`).join('')} +

unknowns · ${(a.unknowns || []).length}

    ${(a.unknowns || []).map(x => `
  • ${chip('open', '?')} ${esc(x)}
  • `).join('')}
`).join('') || '

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

'}
+

Insights

${u.insights.map(i => ``).join('') || ''}
idclaimconfidencesourcewould change iflast check
${esc(i.id)}${esc(i.claim)}${esc((i.evidence || []).join(' · '))}${chip(i.confidence === 'high' ? 'ok' : i.confidence === 'hypothesis' ? 'gap' : 'finding', i.confidence)}${esc(i.source ? `${i.source.type} ${i.source.ref}` : '—')}${esc(i.wouldChangeIf?.text || '—')}${i.lastCheck ? `${when(i.lastCheck.at)}${i.lastCheck.fired ? ' · fired' : ' · held'}` : 'never'}
No insight yet.
`; +} + +export function dashboardPage({ data, folders, current = 0, view = 'overview', run = null, serve = false, link, asset, notice = null }) { + const ok = data && data.matrix; + const counts = ok ? { overview: `${data.matrix.totals.gaps} gaps`, runs: String(data.runs.length), design: `${data.design.length} screens`, understanding: `${data.understanding.actors.reduce((n, a) => n + (a.unknowns || []).length, 0)} unknowns` } : {}; + const side = ``; + const body = !ok ? `

Nothing to show

${(data?.problems || []).map(p => `

${esc(p)}

`).join('')}` + : view === 'runs' ? runs({ data, link, asset, run }) : view === 'design' ? design({ data, asset }) : view === 'understanding' ? understanding({ data }) : overview({ data, link }); + const probs = ok && data.problems?.length ? `

Declarations with problems

${data.problems.map(p => `

${esc(p)}

`).join('')}
` : ''; + return `uxcli · ${esc(VIEWS.find(v => v.id === view)?.label || '')} · ${esc(folders[current] || '')}
${side}
${notice ? `

${esc(notice)}

` : ''}${body}${probs}
`; +} diff --git a/src/core/dashboard.js b/src/core/dashboard.js new file mode 100644 index 0000000..31a12c2 --- /dev/null +++ b/src/core/dashboard.js @@ -0,0 +1,105 @@ +// The dashboard: one project as a coverage matrix. Each row is a screen a journey passes through; each +// column is a kind of evidence uxcli keeps about it. A cell that is empty is a gap and says which command +// fills it — never a pass. Built from the studio's model (which has already read every file) plus the +// declarations the studio does not carry: commitments, proposals, the actors' unknowns. +// Pure: everything is handed in already read. +// +// in: studio: studioModel(), journeys: [journey declarations], commitments, proposals, actors, folder +// out: { folder, project, generatedAt, columns, rows: [{ journey, state, cells: { : cell } }], totals } +// lastRuns: { [journey]: { when, worst } } — the newest packet of a journey, walked or not +// cell: { state: 'ok' | 'gap' | 'fail' | 'finding' | 'open' | 'na', text, fill? } — na: nothing to measure here +import { compareExperience } from './experience.js'; + +export const COLUMNS = [ + { id: 'understood', label: 'understood', means: 'the journey traces an insight and its actor is written' }, + { id: 'drawn', label: 'drawn', means: 'variants drawn under .uxcli/mockups//' }, + { id: 'picked', label: 'picked', means: 'a person picked one, or asked for a redraw' }, + { id: 'reviewed', label: 'reviewed', means: 'a complete lens review of a variant' }, + { id: 'walked', label: 'walked', means: 'the journey was walked in Chrome and this screen was reached' }, + { id: 'verdict', label: 'verdict', means: 'what the walk measured on the step that reaches this screen' }, + { id: 'estimate', label: 'estimate', means: 'keystroke-level seconds to reach this screen from the one before' }, + { id: 'walkthrough', label: 'walkthrough', means: 'the four questions answered at the step that reaches it' }, +]; + +const gap = (text, fill) => ({ state: 'gap', text, fill }); +const ok = text => ({ state: 'ok', text }); + +export function dashboardModel({ studio, journeys = [], commitments = [], proposals = [], actors = [], lastRuns = {}, folder = '.' }) { + const decl = Object.fromEntries(journeys.map(j => [j.id, j])); + const actorOf = Object.fromEntries(actors.map(a => [a.actor, a])); + const rows = []; + for (const j of studio.journeys || []) { + const d = decl[j.id] || {}; const trace = (d.trace || []).length; const actor = actorOf[j.actor]; + // the screens of the journey in the order a person meets them, each with the step that reaches it + const seen = new Map(); + for (const w of j.workflows) for (const s of w.steps) { + if (!seen.has(s.before.state)) seen.set(s.before.state, { step: null, workflow: w.id }); + const cur = seen.get(s.after.state); if (!cur || !cur.step) seen.set(s.after.state, { step: s, workflow: w.id }); + } + for (const [state, { step }] of seen) { + const des = step?.design || (j.workflows.flatMap(w => w.steps).find(s => s.design?.state === state)?.design) || null; + const variants = des?.variants || []; + const lenses = [...new Set(variants.flatMap(v => (v.reviews || []).map(r => r.lens)))]; + const cells = {}; + cells.understood = actor && trace ? ok(`${trace} insight${trace === 1 ? '' : 's'} · ${j.actor}`) : actor ? gap(`${j.actor}, no insight traced`, 'add the insight this journey rests on to its trace[]') : gap('no actor written', 'uxcli template apply , then answer the unknowns'); + cells.drawn = variants.length ? (variants.length >= 2 ? ok(`${variants.length}`) : { state: 'open', text: '1 — draw another', fill: 'two or three variants make a choice' }) : gap('—', `draw .uxcli/mockups/${state}/.html, then uxcli mockups`); + cells.picked = des?.pick ? ok(des.pick.pick) : des?.revise && !des.revise.answered ? { state: 'open', text: 'redraw asked', fill: 'answer the revise.json note with a new drawing' } : variants.length ? { state: 'open', text: 'waiting', fill: 'a person picks: uxcli studio --serve' } : gap('—'); + cells.reviewed = lenses.length ? ok(lenses.join(', ')) : variants.length ? gap('—', `uxcli review ${state}/ --lens= --write`) : gap('—'); + const walked = !!j.run && j.run.status !== 'blocked'; + // a journey whose packets keep no step trace (an older format, or a run that stopped before its + // first step): it ran, and what it found is the packet's, not any one screen's + const bare = !j.run && lastRuns[j.id]; + if (bare && step) { + cells.walked = { state: 'open', text: `ran ${String(bare.when || '').slice(0, 10)}`, fill: 'the packet keeps no steps — open it under Runs' }; + cells.verdict = bare.worst === 'pass' ? ok('no fail') : { state: bare.worst === 'blocked' ? 'open' : bare.worst, text: `${bare.worst} (journey)`, fill: 'on the journey, not placed on a step' }; + cells.estimate = gap('—', `uxcli run .uxcli/journeys/${j.id}.json`); cells.walkthrough = gap('—'); + rows.push({ journey: j.id, goal: j.goal, state, cells }); continue; + } + if (!step) { + // the screen a journey starts from: reached as a prerequisite, not measured + cells.walked = walked ? ok('start') : gap('—', `uxcli run .uxcli/journeys/${j.id}.json`); + const na = { state: 'na', text: '—', fill: null }; cells.verdict = { ...na, text: 'start' }; cells.estimate = na; cells.walkthrough = na; + } else { + const b = step.built || {}; + cells.walked = !j.run ? gap('—', `uxcli run .uxcli/journeys/${j.id}.json`) : j.run.status === 'blocked' ? { state: 'open', text: 'blocked', fill: 'read the run\'s blocked reason: identity, fixture or reach' } : b.metrics ? ok(String(j.run.ranAt || '').slice(0, 10)) : gap('not reached', 'the walk stopped before this step'); + const bad = (b.verdicts || []).find(v => v.value === 'fail') || (b.verdicts || []).find(v => v.value === 'finding') || null; + cells.verdict = bad ? { state: bad.value, text: `${bad.commitment ? bad.commitment + ' ' : ''}${bad.value}`, fill: bad.what || null } + : step.after?.held === false ? { state: 'fail', text: 'state not held' } + : (b.findings || []).length ? { state: 'finding', text: `${b.findings.length} experience finding${b.findings.length === 1 ? '' : 's'}`, fill: b.findings.map(f => f.what).join('; ') } + : b.metrics ? ok(step.after?.held ? 'held' : 'no fail') : gap('—'); + cells.estimate = b.metrics?.klmSeconds != null ? ok(`${b.metrics.klmSeconds} s`) : gap('—', 'uxcli experience after a walk'); + const wt = step.walkthrough || []; + cells.walkthrough = wt.length ? { state: 'finding', text: `${wt.length} no/unsure`, fill: wt.map(x => `${x.question}: ${x.what}`).join('; ') } : walked && b.metrics ? gap('—', `uxcli walkthrough ${j.id} --write`) : gap('—'); + } + rows.push({ journey: j.id, goal: j.goal, state, cells }); + } + } + // what the matrix rows do not hold + const versions = (studio.journeys || []).map(j => { + const vs = j.versions || []; if (vs.length < 2) return vs.length ? { journey: j.id, names: vs.map(v => v.name), delta: null } : null; + const [a, b] = vs.slice(-2); return { journey: j.id, names: [a.name, b.name], delta: a.klmSeconds != null && b.klmSeconds != null ? Math.round((b.klmSeconds - a.klmSeconds) * 10) / 10 : null }; + }).filter(Boolean); + const totals = { + screens: rows.length, + gaps: rows.reduce((n, r) => n + Object.values(r.cells).filter(c => c.state === 'gap').length, 0), + fails: rows.filter(r => r.cells.verdict.state === 'fail').length, + commitments: { active: commitments.filter(c => c.status === 'ACTIVE').length, retiring: commitments.filter(c => c.status === 'RETIREMENT_PROPOSED').length }, + proposals: proposals.filter(p => p.status === 'proposed').length, + unknowns: actors.reduce((n, a) => n + (a.unknowns || []).length, 0), + versions, + }; + return { folder, project: studio.project, generatedAt: studio.generatedAt, columns: COLUMNS, rows, totals }; +} + +export { compareExperience }; + +export function dashboardCard(m, page) { + const t = m.totals; const L = [`uxcli dashboard · ${m.folder}`, '', + ` ${t.screens} screens · ${t.gaps} empty cells · ${t.fails} failing · ${t.commitments.active} commitments active · ${t.proposals} proposals open · ${t.unknowns} unknowns`]; + const w = Math.max(...m.rows.map(r => r.state.length), 10); + for (const r of m.rows) L.push(` ${r.state.padEnd(w)} ${m.columns.map(c => { const x = r.cells[c.id]; return x.state === 'gap' ? '·' : x.state === 'fail' ? 'F' : x.state === 'finding' ? 'f' : x.state === 'open' ? 'o' : x.state === 'na' ? '-' : '✓'; }).join(' ')}`); + L.push(` ${''.padEnd(w)} ${m.columns.map(c => c.label[0]).join(' ')} (✓ held · · gap · o waiting on a person · F fail · f finding · - nothing to measure)`); + if (page) L.push('', ` page ${page}`); + L.push('', ' uxcli dashboard [ …] --serve every folder in one page, switched from the header; add a folder from the page'); + return L.join('\n'); +} diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..f643850 --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,105 @@ +// uxcli dashboard: the management view of one or more projects, read-only. For each folder it reads +// what is under .uxcli/ — through the studio's reader, which already gathers journeys, drawings, walks, +// experience, versions and walkthroughs — and the files the studio does not carry (commitments, +// proposals, the actors' unknowns, the insights' checks). Four views: the coverage matrix, the runs, +// the design, the understanding. Written once (.uxcli/dashboard/) or served for several folders. +// Nothing here writes a declaration: decisions stay in the studio and in the files. +import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; +import { findRoot, loadProject } from './journey.js'; +import { allRuns, UXCLI } from './adapters/store/runs.js'; +import { buildStudio } from './studio.js'; +import { discover } from './mockups.js'; +import { experience, journeyName } from './core/experience.js'; +import { dashboardModel } from './core/dashboard.js'; +import { dashboardPage, VIEWS } from './core/dashboard-page.js'; +import { reviewSummary, summaryLine } from './core/model/lens.js'; +import { screenStatus } from './core/mockups.js'; + +const readJson = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } }; +const rel = (root, f) => path.relative(path.join(root, UXCLI), f).split(path.sep).join('/'); +export const dashboardDir = root => path.join(root, UXCLI, 'dashboard'); + +const shotOf = (root, dir, name) => { if (!name) return null; for (const f of [path.join(dir, 'artifacts', name), path.join(dir, name)]) if (fs.existsSync(f)) return rel(root, f); return null; }; + +// Everything the four views show for one folder. Paths are relative to the folder's .uxcli/. +export async function gather(from) { + const root = findRoot(path.resolve(from)); if (!root) return { problems: [`${from}: no .uxcli/policy/policy.json here or above — uxcli init --apply there first`] }; + const P = loadProject(root); + const S = await buildStudio(root, { shoot: false }); + const pd = path.join(root, UXCLI, 'proposals'); + const proposals = fs.existsSync(pd) ? fs.readdirSync(pd).filter(f => f.endsWith('.json')).map(f => readJson(path.join(pd, f))).filter(Boolean) : []; + const actors = P.actors.map(a => a.value).filter(Boolean); const insights = P.insights.map(i => i.value).filter(Boolean); + + const runs = allRuns(root).map(x => { + const r = x.run; const journey = journeyName(r); const isWalk = Array.isArray(r.steps); + const ex = isWalk ? (() => { try { return experience(r); } catch { return null; } })() : null; + const verdicts = (r.verdicts || []).filter(v => v.value !== 'pass').map(v => ({ value: v.value, commitment: v.commitment || null, step: v.where || v.step || null, what: v.what || v.cause || '' })); + const probes = (r.probes || []).map(p => ({ probe: p.probe, verdict: p.verdict, why: p.why || p.cite?.what || '' })); + const steps = isWalk ? r.steps.filter(s => s.kind !== 'fixture').map(s => { + const m = ex?.steps?.find(y => y.id === s.id && (y.workflow ?? null) === (s.workflow ?? null)); + return { id: s.id, workflow: s.workflow || null, action: s.action || '', held: s.after?.held ?? null, state: s.after?.state || null, + before: shotOf(root, x.at, (s.shots || []).find(f => f.includes('-before'))), after: shotOf(root, x.at, (s.shots || []).find(f => f.includes('-after'))), + klm: m?.klmSeconds ?? null, findings: (ex?.findings || []).filter(f => f.step === s.id).map(f => f.what) }; + }) : []; + const worst = r.status === 'blocked' ? 'blocked' : verdicts.some(v => v.value === 'fail') || probes.some(p => p.verdict === 'fail') ? 'fail' : verdicts.length || probes.some(p => p.verdict === 'finding') ? 'finding' : 'pass'; + return { id: path.basename(x.at), when: r.ranAt || r.at || null, target: journey || r.url || r.target || '?', kind: isWalk ? 'journey' : 'page', viewport: r.viewport || null, environment: r.environment || null, + status: r.status || null, exit: r.exit ?? null, worst, blocked: r.blocked?.reason || null, verdicts, probes, steps, klm: ex?.workflows?.[0]?.totals?.klmSeconds ?? null }; + }); + + const lastRuns = {}; for (const r of runs) if (!lastRuns[r.target]) lastRuns[r.target] = { when: r.when, worst: r.worst }; + const matrix = dashboardModel({ studio: S.model, journeys: P.journeys.map(j => j.value).filter(Boolean), commitments: P.commitments.map(c => c.value).filter(Boolean), proposals, actors, lastRuns, folder: path.relative(process.cwd(), root) || '.' }); + const D = discover(root); const shots = path.join(D.base, '.shots'); + const design = D.screens.map(s => ({ + state: s.id, question: s.about?.question || null, status: screenStatus(s), pick: s.pick?.pick || null, pickedBy: s.pick?.by?.ref || null, + revise: s.revise && !s.revise.answered ? s.revise.note : null, journeys: s.journeys || [], + variants: s.variants.map(v => ({ name: v, about: s.about?.variants?.[v] || null, shot: fs.existsSync(path.join(shots, s.id, `${v}.png`)) ? rel(root, path.join(shots, s.id, `${v}.png`)) : null, + reviews: (s.reviews[v] || []).map(r => ({ lens: r.lens, line: r.value ? summaryLine(reviewSummary(r.value)) : `refused: ${r.problems[0] || ''}` })) })), + })); + + return { root, matrix, runs, design, understanding: { actors, insights, template: readJson(path.join(root, UXCLI, 'template.json'))?.template || null }, problems: P.problems }; +} + +// Written once, for one folder: one HTML file per view under .uxcli/dashboard/, linking each other. +export async function writeDashboard(from) { + const g = await gather(from); if (!g.matrix) return g; + const out = dashboardDir(g.root); fs.mkdirSync(out, { recursive: true }); + for (const v of VIEWS) fs.writeFileSync(path.join(out, `${v.id}.html`), dashboardPage({ data: g, folders: [g.matrix.folder], view: v.id, link: (view, q = '') => `${view}.html${q}`, asset: p => `../${p}` })); + fs.writeFileSync(path.join(out, 'index.html'), dashboardPage({ data: g, folders: [g.matrix.folder], view: 'overview', link: (view, q = '') => `${view}.html${q}`, asset: p => `../${p}` })); + return { ...g, page: path.join(out, 'index.html') }; +} + +const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }; + +// Served, for several folders: ?p= &v= (&run= on the runs view). Pictures come from +// /f// and nothing outside it. A folder is added by posting its path; it +// must hold a uxcli project. localhost only. +export async function serveDashboard(folders, { port = 4319, host = '127.0.0.1' } = {}) { + const roots = []; const problems = []; + for (const f of folders) { const r = findRoot(path.resolve(f)); if (r && !roots.includes(r)) roots.push(r); else if (!r) problems.push(`${f}: no uxcli project`); } + if (!roots.length) return { problems: problems.length ? problems : ['name at least one folder that holds a uxcli project'] }; + const label = r => path.relative(process.cwd(), r) || '.'; + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + if (req.method === 'POST' && url.pathname === '/api/folders') { + let body = ''; for await (const c of req) { body += c; if (body.length > 4000) break; } + const want = new URLSearchParams(body).get('path') || ''; const r = want ? findRoot(path.resolve(want)) : null; + if (r && !roots.includes(r)) roots.push(r); + res.writeHead(303, { location: r ? `/?p=${roots.indexOf(r)}` : `/?p=0&missing=${encodeURIComponent(want)}` }); return res.end(); + } + const m = /^\/f\/(\d+)\/(.+)$/.exec(url.pathname); + if (req.method === 'GET' && m) { + const root = roots[+m[1]]; const base = root && path.join(root, UXCLI); const file = base && path.resolve(base, decodeURIComponent(m[2])); + if (!file || !file.startsWith(base + path.sep) || !MIME[path.extname(file).toLowerCase()] || !fs.existsSync(file)) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()], 'cache-control': 'no-store' }); return fs.createReadStream(file).pipe(res); + } + if (req.method !== 'GET' || url.pathname !== '/') { res.writeHead(404); return res.end('not found'); } + const i = Math.min(roots.length - 1, Math.max(0, Number(url.searchParams.get('p')) || 0)); const view = VIEWS.some(v => v.id === url.searchParams.get('v')) ? url.searchParams.get('v') : 'overview'; + let g; try { g = await gather(roots[i]); } catch (e) { g = { problems: [e.message] }; } + const missing = url.searchParams.get('missing'); + const page = dashboardPage({ data: g, folders: roots.map(label), current: i, view, run: url.searchParams.get('run'), serve: true, + link: (v, q = '') => `/?p=${i}&v=${v}${q ? '&' + q.replace(/^\?/, '') : ''}`, asset: p => `/f/${i}/${p}`, notice: missing != null ? `${missing || 'that path'} holds no uxcli project (no .uxcli/policy/policy.json there or above)` : null }); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(page); + }); + await new Promise((ok, no) => { server.once('error', no); server.listen(port, host, ok); }); + return { roots, server, url: `http://${host}:${server.address().port}/`, problems }; +} diff --git a/src/suites.js b/src/suites.js index ce58d10..48633f8 100644 --- a/src/suites.js +++ b/src/suites.js @@ -41,5 +41,6 @@ export const SUITES = [ { tag: 'walk', title: 'four questions at every step, held to the walk', file: 'walkthrough-pairs.mjs', miss: 'refused' }, { tag: 'design', title: 'the design step holds the work when the project asks', file: 'design-pairs.mjs', miss: 'refused' }, { tag: 'prefer', title: 'a blind pair names no group, and a judge is a person', file: 'prefer-pairs.mjs', miss: 'refused' }, + { tag: 'dash', title: 'the dashboard says what is missing as plainly as what is there', file: 'dashboard-pairs.mjs', miss: 'seen' }, { tag: 'board', title: 'the studio shows every step, and decides through the files', file: 'studio-pairs.mjs', miss: 'refused' }, ]; diff --git a/test/dashboard-pairs.mjs b/test/dashboard-pairs.mjs new file mode 100644 index 0000000..34b02df --- /dev/null +++ b/test/dashboard-pairs.mjs @@ -0,0 +1,63 @@ +// The dashboard says what is missing as plainly as what is there. The pair: on the example, the screen +// whose walk failed C-001 reads fail, the picked screen names its pick, a journey whose packet keeps no +// steps reads as ran-with-its-verdict rather than as never walked, and the start of a journey that was +// never walked is "nothing to measure", not held; a project with no runs and no drawings is all gaps and +// no pass. Served, it switches folders, refuses a folder with no project, serves pictures only from +// under a folder's .uxcli/, and its four views find no fail with the page probes. +import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gather, writeDashboard, serveDashboard } from '../src/dashboard.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const EXAMPLE = path.join(ROOT, 'examples', 'crm'); +export const OPERATOR = 'on the CRM example and on a copy with no runs and no drawings: fail, pick, a run with no steps kept and an unwalked start each read as what they are, the bare copy is all gaps; served over both, the folder switch, a refused folder, pictures only from .uxcli/, and the four page probes on every view'; + +export async function pair() { + const problems = []; let checks = 0; + const must = (what, cond) => { checks++; if (!cond) problems.push(what); }; + const g = await gather(EXAMPLE); + const row = st => g.matrix.rows.find(r => r.journey === 'handle-inbound-lead' && r.state === st); + must('the call screen whose walk failed C-001 does not read fail', row('agent.call_started')?.cells.verdict.state === 'fail' && /C-001/.test(row('agent.call_started').cells.verdict.text)); + must('the picked lead screen does not name its pick', row('agent.lead_detail')?.cells.picked.text === 'b-call-first'); + must('a redraw asked is not waiting on a person', row('agent.call_started')?.cells.picked.state === 'open'); + const auth = g.matrix.rows.filter(r => r.journey === 'authenticate' && r.cells.walked.state !== 'na'); + must('a journey whose packet keeps no steps reads as never walked', auth.some(r => /^ran /.test(r.cells.walked.text) && r.cells.verdict.state === 'fail')); + must('no review on the example, yet a reviewed cell is not a gap', g.matrix.rows.every(r => r.cells.reviewed.state === 'gap')); + must('the runs view lost a packet', g.runs.length === 4 && g.runs.some(r => r.worst === 'blocked')); + + // a bare copy: the same declarations, no runs, no drawings — every evidence cell a gap, nothing held + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-dash-')); + fs.cpSync(EXAMPLE, tmp, { recursive: true, filter: s => !/[\\/]\.uxcli[\\/](runs|mockups|versions|index\.json)/.test(s) }); + const b = await gather(tmp); + const ev = ['drawn', 'picked', 'reviewed', 'walked', 'verdict', 'estimate', 'walkthrough']; + must('a project with nothing measured shows a pass somewhere', b.matrix.rows.every(r => ev.every(c => ['gap', 'na'].includes(r.cells[c].state)))); + must('the start of a journey never walked reads as held', b.matrix.rows.filter(r => r.cells.verdict.state === 'na').every(r => r.cells.walked.state === 'gap')); + must('the bare copy counts no fail', b.matrix.totals.fails === 0 && b.matrix.totals.gaps > b.matrix.rows.length * 5); + + const w = await writeDashboard(tmp); + must('the written dashboard is missing a view', ['index', 'overview', 'runs', 'design', 'understanding'].every(v => fs.existsSync(path.join(tmp, '.uxcli', 'dashboard', `${v}.html`)))); + + const s = await serveDashboard([EXAMPLE, tmp], { port: 0 }); + const { runPage } = await import('../src/page.js'); const { launch } = await import('../src/browser.js'); + const browser = await launch(); + try { + const home = await (await fetch(s.url)).text(); + must('the folder switch does not list both folders', (home.match(/
+
Overview

CRM môi giới · coverage

Each row a screen the journeys pass through; each column a kind of evidence. A dashed cell is a gap, never a pass.

+
34
empty cells
1
failing
4
waiting on a person
2
commitments active
+
journey · screenunderstooddrawnpickedreviewedwalkedverdictestimatewalkthrough
authenticate
anon.login_page
—————start——
agent.workspace_ready—2a-list—————
anon.login_rejected————————
anon.login_failed_retryable————————
handle-inbound-lead
agent.workspace_ready
1 insight · real_estate_agent———startstart——
agent.lead_detail1 insight · real_estate_agent2b-call-first—2026-09-25no fail3.3 s—
agent.call_started1 insight · real_estate_agent1 — draw anotherredraw asked—2026-09-25C-001 fail5.1 s—