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..529acef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ Entries are written per release. The log is the source material, not the text. ## [Unreleased] +### Changed — one visual surface, picked from drawings + +- `uxcli studio` and `uxcli dashboard` now write the same page, in a design system of its own + (`src/core/ui.js`, picked by the owner from four drawings, `docs/design/system/`): the project as a + canvas of the screens a person sees, each journey a filmstrip of device frames with the real pictures, + the action and its keystroke-level seconds on the arrow between; what is wrong pinned on the picture, + on the screen it was measured on, in words a person reads ("Call action is 2 scrolls down", not the + selector); "fix first" and a to-do bar ordered fail, finding, waiting on a person, not yet done. + Views: Journeys (the canvas; layers Built, Drawn, Versions with compare), Screens, Runs, People, + Library. The studio's decisions (pick, redraw, note) and live refresh stay; the dashboard keeps its + folder switch. The coverage-matrix page is retired; `uxcli dashboard` still prints the matrix as a card. +- The mockups, map and experience pages take the same tokens. The mockups page painted text at 2.9:1; + it now passes, and `page-pairs` runs the page probes on it as well as on the map. +- The studio's first visit fitted nothing: it saved the view before checking whether one was saved. + +### 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..fe7aec9 100644 --- a/README.md +++ b/README.md @@ -50,20 +50,24 @@ 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 +uxcli studio --serve # http://127.0.0.1:4317 — the project as a canvas, 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 — and leaves a -note on a built frame, written as a `redesign` proposal citing the walk. Two versions can be compared on -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 project as the screens a person sees. Each journey is a filmstrip on a canvas: device frames with the +pictures the last walk took, the action and its seconds on the arrow between them, what is wrong pinned +on the picture of the screen it was measured on and said in a sentence. "Fix first" and a to-do bar say +what to do next and take you to it. Layers switch the frames between what was built, what was drawn, +and named versions side by side. Screens, Runs, People (who it is for, what is still a question) and +Library are a tab away. Served, it is also where a person decides — choose a drawing, ask for a redraw, +or leave a note on a screen that becomes a `redesign` proposal — through the same parsers the files are +read with. One HTML file, free, local, no account; `uxcli studio --shot=FILE` gives an agent the picture. + +## The dashboard + +`uxcli dashboard . ../other-app --serve` (http://127.0.0.1:4319) is the studio's page for several folders +at once, switched from the header, another one added from the page. Without `--serve` it is written to +`.uxcli/dashboard/index.html`; the card it prints is the coverage matrix — every screen against +understood, drawn, picked, reviewed, walked, verdict, estimate and walkthrough, a gap never a pass. ## Install @@ -217,6 +221,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/docs/design/system/A-filmstrip.html b/docs/design/system/A-filmstrip.html new file mode 100644 index 0000000..ced78b5 --- /dev/null +++ b/docs/design/system/A-filmstrip.html @@ -0,0 +1,29 @@ +uxcli · A-filmstrip
CRM môi giới ▾
+
+
call button below
Fix first

The call button is two scrolls down on a phone

Lead detail · 390×844 · C-001, signed by the product owner

+

Handle a new lead

Real-estate agent 390×844 8.7 s for a practised user
+
Lead list1
Lead list
+ +
Lead detail
Call button · 2 scrolls down
Lead detail
+ +
Call started
Call started
+

Sign in

390×844
Sign-in
Sign-in
Rejected
Rejected
+
drawn picked walked fails not yet
+
\ No newline at end of file diff --git a/docs/design/system/B-gallery.html b/docs/design/system/B-gallery.html new file mode 100644 index 0000000..7f9a1bd --- /dev/null +++ b/docs/design/system/B-gallery.html @@ -0,0 +1,17 @@ +uxcli · B-gallery
+

CRM môi giới

Phone Desktop
+
2 failing1 waiting for you3 healthy1 never drawn
+

Handle a new lead

Lead list
Lead listhealthy
Lead detail 2 scrolls
Lead detailC-001
Call started
Call startedredraw
+

Sign in

Sign-in
Sign-inhealthy
Rejected
Rejectednot held
+
\ No newline at end of file diff --git a/docs/design/system/C-canvas.html b/docs/design/system/C-canvas.html new file mode 100644 index 0000000..0e40d57 --- /dev/null +++ b/docs/design/system/C-canvas.html @@ -0,0 +1,19 @@ +uxcli · C-canvas
+
CRM môi giới ▾ 390 · 144072%
+ +
Lead list
Lead list
+
Lead detail
Lead detail1
+
Call started
Call started
+ +
1Bring the call button up2Redraw call started3Draw login retry
\ No newline at end of file diff --git a/docs/design/system/D-hybrid.html b/docs/design/system/D-hybrid.html new file mode 100644 index 0000000..874c4de --- /dev/null +++ b/docs/design/system/D-hybrid.html @@ -0,0 +1,29 @@ +uxcli · D-hybrid
+
CRM môi giới ▾ Journeys Screens Runs People 390×844 ▾
+
Fix first
Call button is 2 scrolls down on a phone
+
Handle a new lead agent 8.7 s
+ +
Lead list
Lead list
+
Lead detail
Lead detail
call · 2 scrolls
+
Call started
Call started
+
Sign in agent
+
Sign-in
Sign-in
+
Rejected
Rejected
+ +
1Bring the call button up2Redraw call started3Draw login retry
+− 80% + · Fit \ No newline at end of file diff --git a/docs/design/system/README.md b/docs/design/system/README.md new file mode 100644 index 0000000..19d58e5 --- /dev/null +++ b/docs/design/system/README.md @@ -0,0 +1,20 @@ +# uxcli's design system — what was drawn and what was picked + +The owner, 2026-10-03, on the dashboard as first built: "a UI/UX tool that is all text is not right" — +cluttered, hard to read, no next step, not professional — for every uxcli page, one system. + +Drawn by an agent (Claude) with the CRM demo's real screenshots (`img/`, from `uxcli demo` walked at +390×844 and 1440×900), each measured with `uxcli run` before it was shown: + +| drawing | what it is | +|---|---| +| `A-filmstrip.html` | light; each journey a row of phones left to right, the action and its seconds on the arrow, "fix first" on top, state as icons | +| `B-gallery.html` | dark; every screen a card with a health bar, a queue of what to do with thumbnails | +| `C-canvas.html` | a Figma-like canvas: frames joined by flow arrows, pins on the picture, an inspector comparing the build with the picked drawing, a to-do bar | +| `D-hybrid.html` | **picked**: C's canvas, laid out as A's filmstrips, in A's light language | + +Built as `src/core/ui.js` (tokens, icons, shared components) and `src/core/app-page.js` (the surface +`uxcli studio` and `uxcli dashboard` both write). The older pages (mockups, map, experience) take the +same tokens through `LEGACY_ALIASES`. Two drawings failed 1.4.3 once and were corrected before the pick; +building it found two of the system's own tokens below 4.5:1 (`--idle`, `--ok`), corrected, and a +pre-existing 2.9:1 grey on the mockups page, now measured by the gate. diff --git a/docs/design/system/img/called.png b/docs/design/system/img/called.png new file mode 100644 index 0000000..6ddf8ca Binary files /dev/null and b/docs/design/system/img/called.png differ diff --git a/docs/design/system/img/lead-d.png b/docs/design/system/img/lead-d.png new file mode 100644 index 0000000..c933b7c Binary files /dev/null and b/docs/design/system/img/lead-d.png differ diff --git a/docs/design/system/img/lead.png b/docs/design/system/img/lead.png new file mode 100644 index 0000000..3294322 Binary files /dev/null and b/docs/design/system/img/lead.png differ diff --git a/docs/design/system/img/list-d.png b/docs/design/system/img/list-d.png new file mode 100644 index 0000000..bc3a4af Binary files /dev/null and b/docs/design/system/img/list-d.png differ diff --git a/docs/design/system/img/list.png b/docs/design/system/img/list.png new file mode 100644 index 0000000..9bac92c Binary files /dev/null and b/docs/design/system/img/list.png differ diff --git a/docs/design/system/img/login-wrong.png b/docs/design/system/img/login-wrong.png new file mode 100644 index 0000000..12b6788 Binary files /dev/null and b/docs/design/system/img/login-wrong.png differ diff --git a/docs/design/system/img/login.png b/docs/design/system/img/login.png new file mode 100644 index 0000000..cf77670 Binary files /dev/null and b/docs/design/system/img/login.png differ diff --git a/docs/design/system/img/m-callfirst.png b/docs/design/system/img/m-callfirst.png new file mode 100644 index 0000000..8341d4e Binary files /dev/null and b/docs/design/system/img/m-callfirst.png differ diff --git a/docs/design/system/img/m-stacked.png b/docs/design/system/img/m-stacked.png new file mode 100644 index 0000000..611efb0 Binary files /dev/null and b/docs/design/system/img/m-stacked.png differ diff --git a/examples/crm/.uxcli/runs/R-2026-09-24T09-12-03Z-dujexe/run.json b/examples/crm/.uxcli/runs/R-2026-09-24T09-12-03Z-dujexe/run.json index 90518a4..0d4d6b4 100644 --- a/examples/crm/.uxcli/runs/R-2026-09-24T09-12-03Z-dujexe/run.json +++ b/examples/crm/.uxcli/runs/R-2026-09-24T09-12-03Z-dujexe/run.json @@ -58,7 +58,7 @@ "probe": "flow-reachability", "value": "finding", "cause": "method-unproven", - "what": "Gọi khách cần 2 lần cuộn ở 390×844", + "what": "[data-uxcli=call-action] inViewportWithoutScroll: false — needs 2 scrolls at 390x844 at agent.lead_detail", "where": "s2" } ], diff --git a/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json b/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json index b75665f..2932b6d 100644 --- a/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json +++ b/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json @@ -279,7 +279,7 @@ "value": "fail", "caps": [], "cause": "probe-said", - "what": "[data-uxcli=call-action] cần 2 lần cuộn ở 390×844 at agent.lead_detail", + "what": "[data-uxcli=call-action] inViewportWithoutScroll: false — needs 2 scrolls at 390x844 at agent.lead_detail", "where": "s2", "shot": "s2-before.png" }, diff --git a/schemas/experience.schema.json b/schemas/experience.schema.json index bd1072f..a9da45e 100644 --- a/schemas/experience.schema.json +++ b/schemas/experience.schema.json @@ -32,6 +32,7 @@ "step": { "type": "string" }, "workflow": { "type": ["string", "null"] }, "what": { "type": "string", "minLength": 1 }, "source": { "type": "string", "minLength": 1 }, "shot": { "type": ["string", "null"] }, + "state": { "type": ["string", "null"] }, "element": { "type": ["string", "null"] }, "scrolls": { "type": ["number", "null"] }, "rect": { "type": "object", "required": ["x", "y", "w", "h"], "properties": { "x": { "type": "number" }, "y": { "type": "number" }, "w": { "type": "number" }, "h": { "type": "number" }, "viewport": { "type": "object" } } } } } } diff --git a/src/adapters/serve-file.js b/src/adapters/serve-file.js new file mode 100644 index 0000000..c64f94e --- /dev/null +++ b/src/adapters/serve-file.js @@ -0,0 +1,12 @@ +// The one way a uxcli server hands out a file from disk: only a file under `base`, only of a type it +// names, and a malformed or escaping path is a 404 rather than a crash. +import fs from 'node:fs'; import path from 'node:path'; + +export const MIME = { '.html': 'text/html; charset=utf-8', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.css': 'text/css', '.svg': 'image/svg+xml', '.js': 'text/javascript' }; + +export function serveFile(res, base, rel, types = MIME) { + let file; try { file = path.resolve(base, decodeURIComponent(rel)); } catch { file = null; } + const type = file && types[path.extname(file).toLowerCase()]; + if (!type || !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': type, 'cache-control': 'no-store' }); fs.createReadStream(file).pipe(res); +} diff --git a/src/core/app-page.js b/src/core/app-page.js new file mode 100644 index 0000000..7033495 --- /dev/null +++ b/src/core/app-page.js @@ -0,0 +1,280 @@ +// The one surface every uxcli page now is (studio and dashboard alike), in the design system of +// src/core/ui.js: the project as a canvas of the screens a person sees, laid out as filmstrips — each +// journey a row, each screen a device frame with its real picture, the action and its seconds on the +// arrow between, what is wrong pinned on the picture and said once in "fix first" and the to-do bar. +// Beside the canvas: Screens (every screen as a card), Runs, People (who it is for, what is unknown) and +// Library. One HTML file: the views switch in the page, so it works from disk; served, the studio adds +// the decisions a person makes (pick, redraw, note) and the dashboard a folder switch. +// Pure: data in, HTML out. Paths in the data are relative to the folder's .uxcli/ (or, in the studio +// model, to .uxcli/studio/); `asset` is the prefix that turns them into URLs. +import { TOKENS, COMPONENTS, ICONS, icon } from './ui.js'; +import { human, esc } from './wireflow.js'; + +export const VIEWS = [ + { id: 'journeys', label: 'Journeys', icon: 'journeys' }, { id: 'screens', label: 'Screens', icon: 'screens' }, + { id: 'runs', label: 'Runs', icon: 'walked' }, { id: 'people', label: 'People', icon: 'person' }, { id: 'library', label: 'Library', icon: 'library' }, +]; + +// What a person should do next, most urgent first, each pointing at a frame on the canvas. +// The screen a verdict was measured on: the state the run recorded on it, or (older packets) the state its +// words name ("… at agent.workspace_ready"), or else the screen the step reached — a verdict is read after +// the action. +export const verdictState = (v, s) => v?.state || (/ at ([a-z][\w]*\.[\w.]+)\s*$/.exec(String(v?.what || '')) || [])[1] || s.after.state; +export function todos(model, design = []) { + const out = []; const seen = new Set(); + const add = (t, key) => { if (seen.has(key)) return; seen.add(key); out.push(t); }; + for (const j of model.journeys || []) for (const w of j.workflows) for (const s of w.steps) { + const at = `built|${j.id}|${w.id}|${s.id}`; + const fail = (s.built.verdicts || []).find(v => v.value === 'fail'); + const vs = fail ? verdictState(fail, s) : s.before.state; const where = human(vs); + const go = vs === s.after.state && s === w.steps[w.steps.length - 1] ? `${at}|end` : vs === s.after.state ? `built|${j.id}|${w.id}|${w.steps[w.steps.indexOf(s) + 1].id}` : at; + if (fail) add({ kind: 'fail', text: fail.what ? `${where} · ${short(fail.what)}` : `${where} fails ${fail.commitment || ''}`, go, why: [fail.commitment, fail.statement, fail.what].filter(Boolean).join(' — ') }, `f|${s.before.state}|${fail.commitment}`); + else if (s.after.held === false) add({ kind: 'fail', text: `${human(s.after.state)} was not reached`, go: at }, `h|${s.after.state}`); + for (const f of s.built.findings || []) if (f.metric === 'reach' && !(fail && verdictState(fail, s) === s.before.state)) add({ kind: 'find', text: `${human(s.before.state)} · ${short(f.what)}`, go: at, why: f.what }, `r|${s.before.state}|${f.what}`); + } + for (const d of design) { + if (d.revise) add({ kind: 'open', text: `Redraw ${human(d.state)}`, go: d.state, why: d.revise }, `v|${d.state}`); + else if (d.variants?.length && !d.pick) add({ kind: 'open', text: `Pick a drawing for ${human(d.state)}`, go: d.state }, `p|${d.state}`); + } + for (const d of design) if (!d.variants?.length) add({ kind: 'idle', text: `Draw ${human(d.state)}`, go: d.state }, `d|${d.state}`); + for (const j of model.journeys || []) if (!j.run) add({ kind: 'idle', text: `Walk ${human(j.id)}`, go: `journey|${j.id}` }, `w|${j.id}`); + const rank = { fail: 0, find: 1, open: 2, idle: 3 }; + return out.map((t, i) => [t, i]).sort((a, b) => rank[a[0].kind] - rank[b[0].kind] || a[1] - b[1]).map(([t]) => t); +} +// The instrument's words for a finding, said the way a person would: the element by its name, what is +// wrong with it, nothing about selectors or viewports (the frame already shows which screen and size). +// "[data-uxcli=call-action] inViewportWithoutScroll: false — needs 2 scrolls at 390x844 at agent.lead_detail" → "Call action is 2 scrolls down" +// "lead board[data-view=kanban] visible: false at agent.workspace_ready" → "Kanban lead board is not shown" +// "input[name=email] valueUnchanged: false" → "Email field loses what was typed" +export function short(what) { + let s = String(what || '').trim(); + const name = sel => { const m = /\[data-uxcli=([^\]]+)\]|\[name=([^\]]+)\]|\[data-view=([^\]]+)\]|#([\w-]+)/.exec(sel); const pre = sel.replace(/\[[^\]]*\]|#[\w-]+|\b(input|button|a|div|span|select|textarea)\b/g, ' ').replace(/\s+/g, ' ').trim(); + const n = m ? (m[1] || m[2] || m[3] || m[4]).replace(/\{[^}]*\}/g, '').replace(/[-_]+/g, ' ').trim() : ''; const field = /^input|^select|^textarea/.test(sel) ? ' field' : ''; + return ((n && pre && !pre.includes(n) ? `${n} ${pre}` : n || pre || 'an element') + field).replace(/\s+/g, ' ').trim(); }; + let m; + if ((m = /^(.*?)\s+inViewportWithoutScroll: false — needs (\d+) scrolls?/.exec(s))) s = `${name(m[1])} is ${m[2]} scroll${m[2] === '1' ? '' : 's'} down`; + else if ((m = /^(.*?)\s+is (\d+) scrolls? away/.exec(s))) s = `${name(m[1])} is ${m[2]} scroll${m[2] === '1' ? '' : 's'} down`; + else if ((m = /^(.*?)\s+visible: false/.exec(s))) s = `${name(m[1])} is not shown`; + else if ((m = /^(.*?)\s+valueUnchanged: false/.exec(s))) s = `${name(m[1])} loses what was typed`; + else if ((m = /^(.*?)\s+(\w+): (true|false)\b/.exec(s))) s = `${name(m[1])}: ${m[2].replace(/([A-Z])/g, ' $1').toLowerCase()} is ${m[3]}`; + s = s.replace(/ at [a-z]+\.[\w.]+$/, '').replace(/ at \d+x\d+.*$/, ''); + s = s ? s[0].toUpperCase() + s.slice(1) : s; + return s.length > 70 ? s.slice(0, 67) + '…' : s; +} + +const CSS = TOKENS + COMPONENTS + ` +body{height:100vh;overflow:hidden} +.top{position:fixed;left:12px;right:12px;top:12px;z-index:5}.top .sp{flex:1} +.view{position:absolute;inset:0;overflow:auto;padding:76px 24px 96px}.view[hidden]{display:none} +#journeys.view{overflow:hidden;padding:0} +.stage{position:absolute;inset:0;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(--line-strong);stroke-width:2} +.wires text{font:600 11px var(--sans);fill:var(--dim)}.wires text.t{font-weight:700;font-size:12px;fill:var(--ink)}.wires text.d{fill:var(--fail)} +.jh{position:absolute;display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;white-space:nowrap} +.wl{position:absolute;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)} +.frame{position:absolute;cursor:pointer;border-radius:12px;outline:none} +.frame .lb{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;color:var(--soft);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.frame .ics{margin-top:8px} +.frame.sel .device{outline:2px dashed var(--accent);outline-offset:6px}.frame:focus-visible .device{outline:2px solid var(--accent);outline-offset:6px} +.fix{position:fixed;left:12px;top:70px;width:320px;display:grid;grid-template-columns:64px 1fr;gap:12px;padding:12px;z-index:4} +.fix .c{height:84px;border-radius:8px;overflow:hidden;position:relative;border:1px solid var(--line);background:var(--well)}.fix .c img{width:64px}.fix .c span{position:absolute;left:0;right:0;bottom:0;height:6px;background:var(--fail-strong)} +.fix b{display:block;margin:3px 0 6px;font-size:14px} +.insp{position:fixed;right:12px;top:70px;bottom:84px;width:300px;padding:14px;z-index:4;overflow:auto;display:grid;gap:12px;align-content:start} +.insp h3{margin:0;font-size:15px}.insp .row{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--line)} +.insp .row span{display:flex;align-items:center;gap:6px;color:var(--soft)}.insp .row b.fail{color:var(--fail)}.insp .row b.ok{color:var(--ok)} +.insp h4{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--dim)} +.vs{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:8px}.vs figure{margin:0}.vs .th{border-radius:8px;overflow:hidden;border:2px solid var(--line);height:120px;background:var(--well)}.vs .th.p{border-color:var(--ok)}.vs img{width:100%;display:block}.vs figcaption{font-size:11px;margin-top:3px;color:var(--soft);overflow-wrap:anywhere} +.insp ul{margin:0;padding-left:16px;display:grid;gap:6px}.insp li small{display:block;color:var(--dim)} +.act{display:grid;gap:6px}.act textarea{width:100%;min-height:64px;border:1px solid var(--line);border-radius:8px;padding:6px;background:var(--surface)}.msg{font-size:12px}.msg.err{color:var(--fail)}.msg.ok{color:var(--ok)} +.todo .tt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sheet{position:fixed;left:180px;right:324px;bottom:12px;display:flex;gap:8px;padding:8px;z-index:4;overflow:hidden}.sheet .todo{flex:0 1 auto;min-width:0} +.zoom{position:fixed;left:12px;bottom:12px;z-index:4;display:flex;align-items:center;gap:2px;padding:4px}.zoom button{border:0;background:none;border-radius:8px;padding:6px;display:grid;place-items:center}.zoom output{min-width:44px;text-align:center;font-weight:600;font-variant-numeric:tabular-nums} +.layers{display:flex;gap:2px;padding:4px}.layers button{border:0;background:none;border-radius:8px;padding:5px 9px;font-size:12px;font-weight:600;color:var(--soft);display:flex;gap:5px;align-items:center}.layers button[aria-pressed=true]{background:var(--accent-soft);color:var(--accent)} +.layers select{border:1px solid var(--line);border-radius:6px;padding:3px 6px;background:var(--surface);font-size:12px} +.live{color:var(--ok)} +h1.v{font-size:20px;margin:0 0 4px}.lede{margin:0 0 18px;color:var(--dim);max-width:760px} +.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:16px} +.card{padding:12px;display:grid;gap:8px;align-content:start}.card h3{margin:0;font-size:14px;display:flex;align-items:center;gap:6px} +.card .device{height:300px}.card button.open{all:unset;cursor:pointer;display:block;border-radius:18px}.card button.open:focus-visible{outline:2px solid var(--accent);outline-offset:4px} +.wide{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px} +.run summary{list-style:none;cursor:pointer;display:grid;grid-template-columns:56px 1fr auto;gap:12px;align-items:center}.run summary::-webkit-details-marker{display:none} +.run .mini{width:56px;height:76px;border-radius:8px;overflow:hidden;border:1px solid var(--line);background:var(--well)}.run .mini img{width:56px} +.res{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:2px 9px;font-size:12px;font-weight:700}.res.fail{background:var(--fail-soft);color:var(--fail)}.res.finding{background:var(--find-soft);color:var(--find)}.res.pass{background:var(--ok-soft);color:var(--ok)}.res.blocked{background:var(--idle-soft);color:var(--idle)} +.steps{display:flex;gap:14px;flex-wrap:wrap;margin-top:12px}.steps figure{margin:0;width:150px}.steps .device{height:300px}.steps figcaption{font-size:12px;margin-top:6px} +.person{display:grid;grid-template-columns:44px 1fr;gap:12px}.avatar{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent)} +.q{display:flex;gap:6px;align-items:flex-start;padding:6px 8px;border-radius:8px;background:var(--open-soft);margin-top:6px} +.meter{display:inline-flex;gap:3px}.meter i{width:8px;height:8px;border-radius:50%;background:var(--line-strong)}.meter i.on{background:var(--ok)} +@media (max-width:900px){.fix{display:none}.insp{top:auto;left:12px;right:12px;width:auto;max-height:45vh;bottom:130px}.insp:not(.on){display:none}.sheet{left:12px;right:12px;bottom:66px;overflow-x:auto}.sheet .todo{flex:0 0 230px}.top .tabs a span{display:none}}`; + +const JS = `(function(){ +var D=JSON.parse(document.getElementById('studio-data').textContent);var M=D.model;var A=D.asset;var IC=D.icons; +function u(p){return p==null?null:A+String(p).replace(/^\\.\\.\\//,'');} +function ic(n,s,l){return '';} +function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} +function hum(id){var s=String(id==null?'':id).replace(/^.*\\./,'').replace(/^[a-z]-(?=[a-z])/,'').replace(/[_-]+/g,' ').trim();return s?s[0].toUpperCase()+s.slice(1):'';} +function el(t,c,h){var e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;} +var DES={};(D.design||[]).forEach(function(d){DES[d.state]=d;}); +var vw=parseInt(M.viewport)||390,vh=parseInt(String(M.viewport).split('x')[1])||844,PHONE=vw<700; +var FW=PHONE?150:300,FH=Math.round(FW*vh/vw),GAP=130,LBL=22,ICR=34,ROW=LBL+FH+12+ICR+48; +var stage=document.getElementById('stage'),world=document.getElementById('world'),insp=document.getElementById('insp'); +var view={x:360,y:90,k:0.8},sel=null,layer='built',cmp={from:'',to:''}; +var KEY='uxcli-app:'+(M.project.id||M.project.name); +try{var sv=JSON.parse(localStorage.getItem(KEY)||'null');if(sv&&sv.view)view=sv.view;if(sv&&sv.layer)layer=sv.layer;}catch(e){} +var st=null;function save(){clearTimeout(st);st=setTimeout(function(){try{localStorage.setItem(KEY,JSON.stringify({view:view,layer:layer}));}catch(e){}},300);} +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();} +// the screens of a workflow, in the order a person sees them: the screen before each step, then the last one after +function screens(w){var out=[];w.steps.forEach(function(s,i){out.push({state:s.before.state,shot:s.built.shot,step:s,leave:s,prev:i?w.steps[i-1]:null,end:false,w:w});});var l=w.steps[w.steps.length-1];if(l)out.push({state:l.after.state,shot:l.built.after,step:l,leave:null,prev:l,end:true,reached:l.after.held,w:w});return out;} +function vstate(v,s){if(v.state)return v.state;var m=/ at ([a-z][\\w]*\\.[\\w.]+)\\s*$/.exec(String(v.what||''));return m?m[1]:s.after.state;} +function verdictsOn(sc,w){var out=[];w.steps.forEach(function(s){(s.built.verdicts||[]).forEach(function(v){if(vstate(v,s)===sc.state&&(s===sc.leave||(sc.prev===s)))out.push(v);});});return out;} +function health(sc){var vs=verdictsOn(sc,sc.w);if(vs.some(function(v){return v.value==='fail';})||(sc.end&&sc.reached===false))return'fail';var s=sc.leave;if(vs.length||(s&&(s.built.findings||[]).length))return'find';return'';} +function status(sc,walked){var d=DES[sc.state]||{variants:[]};var h=health(sc); + return ''+ic('drawn',13,d.variants.length?'drawn':'not drawn')+''+ + ''+ic('picked',13,d.pick?'picked':'not picked')+''+ + (h?''+ic('alert',13,'needs a fix')+'':''+ic('walked',13,walked?'walked':'not walked')+'');} +function image(sc){if(layer==='design'){var d=DES[sc.state];if(d){var v=d.variants.filter(function(x){return x.name===d.pick;})[0]||d.variants[0];return v&&v.shot;}return null;}return sc.shot;} +function pins(sc){var s=sc.leave;if(!s||layer!=='built')return'';var h='';(s.built.findings||[]).forEach(function(f,i){var r=f.rect;if(!r)return;var k=(FW-12)/((r.viewport&&r.viewport.w)||vw),y=r.y*k;if(y+8>FH-12){h+='
'+ic('down',12)+esc(f.what.replace(/^.*?\\[data-uxcli=([^\\]]+)\\].*?(\\d+) scrolls?.*$/,function(m,n,k){return n.replace(/\\{[^}]*\\}/g,'').replace(/[-_]+/g,' ').trim()+' · '+k+' scroll'+(k==='1'?'':'s')+' down';}))+'
';}else h+=''+(i+1)+'';});return h;} +function frame(x,y,sc,j,w){var id='built|'+j.id+'|'+w.id+'|'+sc.step.id+(sc.end?'|end':'');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');f.dataset.id=id;f.dataset.state=sc.state;if(sel===id)f.classList.add('sel'); + var h=health(sc),img=image(sc);f.setAttribute('aria-label',hum(sc.state)+(h==='fail'?', needs a fix':h==='find'?', has a finding':'')); + f.innerHTML='
'+ic(PHONE?'phone':'desktop',12)+' '+esc(hum(sc.state))+(h?' '+ic('alert',12):'')+'
'+(img?'':'
'+(layer==='design'?'not drawn yet':'not walked yet')+'
')+pins(sc)+'
'+status(sc,!!j.run)+'
'; + 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 svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('class','wires');var g='',y=0,maxX=0; + M.journeys.forEach(function(j){var jh=el('div','jh',esc(hum(j.id))+(j.actor?' '+ic('person',12)+esc(hum(j.actor))+'':'')+(j.workflows[0]&&j.workflows[0].totals?' '+ic('clock',12)+j.workflows[0].totals.klmSeconds+' s':'')+(j.run?'':' '+ic('walked',12)+'not walked'));jh.style.left='0px';jh.style.top=y+'px';jh.dataset.go='journey|'+j.id;world.appendChild(jh);y+=40; + var rows=[{name:'',steps:null}];if(layer==='versions'){j.versions.forEach(function(v){if(cmp.from&&cmp.to&&v.name!==cmp.from&&v.name!==cmp.to)return;rows.push({name:v.name});});} + j.workflows.forEach(function(w){if(!w.steps.length)return;var scs=screens(w);if(j.workflows.length>1){var wl=el('div','wl',esc(w.id||'workflow')+(w.kind==='recovery'?' · recovery':''));wl.style.left='0px';wl.style.top=y+'px';world.appendChild(wl);y+=20;} + scs.forEach(function(sc,i){var x=i*(FW+GAP);maxX=Math.max(maxX,x+FW);world.appendChild(frame(x,y,sc,j,w)); + if(sc.leave){var cy=y+LBL+FH/2,x1=x+FW+10,x2=x+FW+GAP-10,m=sc.leave.built.metrics;g+=''; + var a=String(sc.leave.action||sc.leave.id);if(a.length>22)a=a.slice(0,21)+'…';g+=''+esc(a)+'';if(m)g+=''+m.klmSeconds+' s';}}); + y+=ROW; + if(layer==='versions')j.versions.forEach(function(v){if(cmp.from&&cmp.to&&v.name!==cmp.from&&v.name!==cmp.to)return;var any=false;var wl=el('div','wl',ic('version',12)+' '+esc(v.name));wl.style.left='0px';wl.style.top=y+'px';world.appendChild(wl); + w.steps.forEach(function(s,i){var q=s.versions.filter(function(z){return z.name===v.name;})[0];if(!q||!q.present)return;any=true;var x=(i+1)*(FW+GAP),f=el('div','frame');f.style.left=x+'px';f.style.top=(y+18)+'px';f.style.width=FW+'px';f.innerHTML='
'+esc(hum(s.after.state))+'
'+(q.shot?'':'
no picture kept
')+'
'+ic('clock',12)+(q.klmSeconds!=null?q.klmSeconds+' s':'–')+''+(q.findings?''+ic('alert',12)+q.findings+'':'')+delta(s,v.name)+'
';world.appendChild(f);}); + if(any)y+=ROW+18;else world.removeChild(wl);});}); + y+=30;}); + svg.innerHTML=g;svg.setAttribute('width',maxX+60);svg.setAttribute('height',y);world.insertBefore(svg,world.firstChild);world.style.width=(maxX+60)+'px';world.style.height=y+'px'; + if(!M.journeys.length)world.appendChild(el('div','jh','No journey yet — write one under .uxcli/journeys/'));apply();} +function delta(s,name){if(!(cmp.from&&cmp.to&&name===cmp.to))return'';var a=s.versions.filter(function(z){return z.name===cmp.from;})[0],b=s.versions.filter(function(z){return z.name===name;})[0];if(!a||!a.present)return'new step';if(a.klmSeconds==null||b.klmSeconds==null)return'';var d=Math.round((b.klmSeconds-a.klmSeconds)*10)/10;return''+(d>0?'+':'')+d+' s';} +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&&{j:j,w:w,s:s,end:a[4]==='end'};} +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 inspect(){var x=sel&&find(sel);insp.hidden=false;insp.classList.toggle('on',!!x); + if(!x){var c=M.counts;insp.innerHTML='

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

Click a screen. Drag to move, ctrl+scroll or + − to zoom, F to fit.

'+ + '
'+ic('screens',14)+'screens walked'+c.built+'
'+ic('drawn',14)+'drawn · picked'+c.drawn+' · '+c.picked+'
'+ic('alert',14)+'findings on the walks'+c.findings+'
'+ic('version',14)+'versions'+c.versions+'
';return;} + var s=x.s,state=x.end?s.after.state:s.before.state,d=DES[state]||{variants:[]},b=s.built,h='
'+esc(hum(x.j.id))+' · '+(x.end?'end':'step '+s.n)+'

'+esc(hum(state))+'

'; + var scx={state:state,leave:x.end?null:s,prev:x.end?s:(x.w.steps[x.w.steps.indexOf(s)-1]||null),w:x.w};verdictsOn(scx,x.w).forEach(function(v){h+='
'+ic('alert',14)+esc(v.commitment||v.value)+''+esc(v.value)+'
'+(v.what?'

'+esc(v.what)+(v.statement?' — '+esc(v.statement):'')+'

':'');}); + if(!x.end){h+='
'+ic('walked',14)+'next: '+esc(String(s.action||s.id))+''+(b.metrics?b.metrics.klmSeconds+' s':'–')+'
';} + else h+='
'+ic('walked',14)+'reached'+(s.after.held===false?'no':s.after.held?'yes':'not measured')+'
'; + h+='
'+ic('picked',14)+'picked'+esc(d.pick||(d.revise?'redraw asked':d.variants.length?'waiting':'not drawn'))+'
'; + if(d.variants.length)h+='

Drawings

'+d.variants.map(function(v){return '
'+(v.shot?''+esc(v.name)+'':'')+'
'+esc(v.name)+(v.name===d.pick?' · picked':'')+'
';}).join('')+'
'; + if(d.pick&&!x.end&&(b.verdicts||[]).some(function(v){return v.value==='fail';}))h+='

The walk of the build fails here; compare it with the picked drawing.

'; + if(!x.end){h+='

Built · the last walk

'+(b.metrics?'
'+ic('clock',14)+'settled'+(b.metrics.settleMs==null?'–':b.metrics.settleMs+' ms')+'
'+ic('down',14)+'scrolls'+b.metrics.scrolls+'
':'

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

')+'
'; + if((b.findings||[]).length)h+='

Findings

    '+b.findings.map(function(f,i){return '
  • '+(f.rect?''+(i+1)+'. ':'')+esc(f.what)+''+esc(f.source||'')+'
  • ';}).join('')+'
'; + if((s.walkthrough||[]).length)h+='

Walkthrough

    '+s.walkthrough.map(function(f){return '
  • '+esc(f.question)+': '+esc(f.answer)+''+esc(f.what.split(' — ').slice(1).join(' — '))+'
  • ';}).join('')+'
'; + if(b.shot)h+=''+ic('external',13)+'Open the picture';} + if(D.serve&&d.variants.length&&!d.pick)h+='

Decide

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

'; + if(D.serve&&!x.end)h+='

Propose a change

'; + insp.innerHTML=h; + [].forEach.call(insp.querySelectorAll('[data-pick]'),function(bt){bt.addEventListener('click',function(){post('/api/pick',{state:state,variant:bt.dataset.pick},'amsg');});}); + var nb=document.getElementById('nbtn');if(nb)nb.addEventListener('click',function(){post('/api/note',{journey:x.j.id,workflow:x.w.id,step:s.id,note:document.getElementById('nnote').value},'amsg2');}); + var rb=document.getElementById('rbtn');if(rb)rb.addEventListener('click',function(){post('/api/revise',{state:state,note:document.getElementById('rnote').value},'amsg');});} +function post(url,body,mid){var m=document.getElementById(mid);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,left=r.width>900?350:20,right=r.width>900?330:20;view.k=Math.max(0.55,Math.min(1.1,(r.width-left-right)/W));view.x=left;view.y=80;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){showView('journeys');var f=null;if(id.indexOf('|')<0)f=world.querySelector('[data-state="'+(window.CSS&&CSS.escape?CSS.escape(id):id)+'"]');else if(id.indexOf('journey|')===0)f=world.querySelector('[data-go="'+(window.CSS&&CSS.escape?CSS.escape(id):id)+'"]');else 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.8);view.x=r.width/2-(parseFloat(f.style.left)+FW/2)*view.k;view.y=110-parseFloat(f.style.top)*view.k;apply();if(f.dataset.id)select(f.dataset.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('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(document.getElementById('journeys').hidden)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();}); +document.getElementById('zin').onclick=function(){zoom(1.2);};document.getElementById('zout').onclick=function(){zoom(1/1.2);};document.getElementById('zfit').onclick=fit; +[].forEach.call(document.querySelectorAll('[data-layer]'),function(b){b.setAttribute('aria-pressed',b.dataset.layer===layer);b.onclick=function(){layer=b.dataset.layer;[].forEach.call(document.querySelectorAll('[data-layer]'),function(x){x.setAttribute('aria-pressed',x===b);});document.getElementById('cmp').hidden=layer!=='versions';save();render();};}); +(function(){var names=[];M.journeys.forEach(function(j){j.versions.forEach(function(v){if(names.indexOf(v.name)<0)names.push(v.name);});});var a=document.getElementById('cmp-from'),b=document.getElementById('cmp-to'); + if(!names.length){var vb=document.querySelector('[data-layer=versions]');if(vb)vb.hidden=true;} + [a,b].forEach(function(sl){names.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sl.appendChild(o);});sl.onchange=function(){cmp={from:a.value,to:b.value};render();};});document.getElementById('cmp').hidden=layer!=='versions';})(); +[].forEach.call(document.querySelectorAll('[data-go]'),function(b){if(b.closest('#world'))return;b.addEventListener('click',function(){goTo(b.dataset.go);});}); +function showView(v){if(!document.getElementById(v)||!document.getElementById(v).classList.contains('view'))v='journeys';[].forEach.call(document.querySelectorAll('.view'),function(s){s.hidden=s.id!==v;});[].forEach.call(document.querySelectorAll('.tabs a'),function(a){if(a.getAttribute('href')==='#'+v)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current');});var on=v==='journeys';['fix','insp','sheet','zoomer','layers'].forEach(function(id){var e=document.getElementById(id);if(e)e.style.display=on?'':'none';});if(location.hash!=='#'+v)history.replaceState(null,'','#'+v);} +[].forEach.call(document.querySelectorAll('.tabs a'),function(a){a.addEventListener('click',function(e){e.preventDefault();showView(a.getAttribute('href').slice(1));});}); +var first=!(function(){try{return localStorage.getItem(KEY);}catch(e){return null;}})();render();inspect();if(first)fit(); +function fromHash(){showView((location.hash||'#journeys').slice(1).replace(/[^a-z]/g,'')||'journeys');}fromHash();window.addEventListener('hashchange',fromHash); +if(D.live&&window.EventSource){var es=new EventSource('/events');var live=document.getElementById('live');es.onopen=function(){live.textContent='live';};// a change on disk reloads the page (every view is written by the server), unless the person is typing: + // then it waits behind a button so nothing they wrote is lost + es.onmessage=function(){var busy=[].some.call(document.querySelectorAll('textarea'),function(t){return t.value||t===document.activeElement;});if(!busy)return location.reload();live.innerHTML='';live.firstChild.onclick=function(){location.reload();};};es.onerror=function(){live.textContent='disconnected';};} +})();`; + +const shotUrl = (asset, p) => p == null ? null : asset + String(p).replace(/^\.\.\//, ''); +const device = (src, cls = '', alt = '', extra = '') => `
${src ? `${esc(alt)}` : `
${esc(alt || 'no picture')}
`}${extra}
`; + +function screensView(data, asset) { + // every screen once: its newest picture, the drawings, and how it stands + const pic = {}; const hl = {}; + for (const j of data.model.journeys) for (const w of j.workflows) w.steps.forEach((s, i) => { + if (s.built.shot && !pic[s.before.state]) pic[s.before.state] = s.built.shot; + if (i === w.steps.length - 1 && s.built.after && !pic[s.after.state]) pic[s.after.state] = s.built.after; + for (const v of s.built.verdicts || []) { const at = verdictState(v, s); if (v.value === 'fail') hl[at] = 'fail'; else if (hl[at] !== 'fail') hl[at] = 'find'; } + if (s.after.held === false) hl[s.after.state] = 'fail'; + if ((s.built.findings || []).length && hl[s.before.state] !== 'fail') hl[s.before.state] = 'find'; + }); + const cards = data.design.map(d => { + const v = d.variants.find(x => x.name === d.pick) || d.variants[0]; const src = shotUrl(asset, pic[d.state] || v?.shot); + const state = hl[d.state] || ''; + const s = (on, n, t) => `${icon(n, 13, t)}`; + return `
+

${esc(human(d.state))}${state ? ` ${icon('alert', 14, 'needs a fix')}` : ''}

${s(d.variants.length ? 'y' : '', 'drawn', d.variants.length ? `${d.variants.length} drawn` : 'not drawn')}${s(d.pick ? 'y' : d.variants.length ? 'w' : '', 'picked', d.pick ? `picked ${d.pick}` : d.variants.length ? 'waiting for a pick' : 'no pick')}${s(state === 'fail' ? 'n' : state === 'find' ? 'w' : pic[d.state] ? 'y' : '', state ? 'alert' : 'walked', state === 'fail' ? 'fails' : state === 'find' ? 'finding' : pic[d.state] ? 'walked' : 'not walked')}
`; + }).join(''); + return `

Screens

Every screen the journeys pass through, with its newest picture. A red frame needs a fix; open one to see it on the canvas.

${cards || '

No screen named by a journey yet.

'}
`; +} + +function runsView(data, asset) { + const rows = data.runs.map(r => { + const first = r.steps.find(s => s.before || s.after); const src = first ? shotUrl(asset, first.before || first.after) : null; + return `
${src ? `` : ''}
+
${esc(r.kind === 'journey' ? human(r.target) : r.target)}
${icon(r.viewport && parseInt(r.viewport) < 700 ? 'phone' : 'desktop', 12)} ${esc(r.viewport || '1280×800')} · ${esc(String(r.when || '').slice(0, 16).replace('T', ' '))}${r.klm != null ? ` · ${icon('clock', 12)} ${r.klm} s` : ''}
+${icon(r.worst === 'pass' ? 'picked' : r.worst === 'blocked' ? 'minus' : 'alert', 12)}${esc(r.worst)}
+${r.blocked ? `

Blocked: ${esc(r.blocked)}

` : ''} +${r.verdicts.length ? `
    ${r.verdicts.map(v => `
  • ${esc(v.value)} ${esc(v.commitment || '')} ${esc(v.what)}
  • `).join('')}
` : ''} +${r.probes.length ? `
    ${r.probes.map(p => `
  • ${esc(p.verdict)} ${esc(String(p.probe || '').replace(/^page\./, ''))} — ${esc(p.why)}
  • `).join('')}
` : ''} +${r.steps.length ? `
${r.steps.map(s => `
${device(shotUrl(asset, s.before), s.held === false ? 'fail' : s.findings.length ? 'find' : '', s.before ? '' : 'no picture kept')}
${esc(human(s.state))}
${esc(s.action)}${s.klm != null ? ` · ${s.klm} s` : ''}${s.findings.map(f => `
${icon('alert', 11)}${esc(short(f))}`).join('')}
`).join('')}
` : ''}
`; + }).join(''); + return `

Runs

Every time Chrome walked a journey or checked a page, newest first. Open one for its steps as the browser saw them.

${rows || '

No run yet: uxcli run <url>, or uxcli run .uxcli/journeys/<id>.json

'}`; +} + +function peopleView(data) { + const u = data.understanding; const conf = { hypothesis: 0, low: 1, medium: 2, high: 3 }; + const meter = c => `${[1, 2, 3].map(i => ``).join('')}`; + return `

People

Who the product is for, what is known about them and how sure, and what is still a question.

+
${u.actors.map(a => `
${icon('person', 22)}

${esc(human(a.actor))}

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

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

` : ''} +${(a.jobs || []).slice(0, 3).map(x => `

${icon('picked', 12)} ${esc(x)}

`).join('')}${(a.pains || []).slice(0, 2).map(x => `

${icon('alert', 12)} ${esc(x)}

`).join('')} +${(a.unknowns || []).map(x => `
${icon('question', 14)}${esc(x)}
`).join('')}
`).join('') || '

No one written yet: uxcli template apply <kind>.

'}
+

What is known

${u.insights.map(i => `

${meter(i.confidence)} ${esc(i.confidence)}

${esc(i.claim)}

${esc(i.source ? `${i.source.type} · ${i.source.ref}` : '')}${i.lastCheck ? ` · checked ${esc(String(i.lastCheck.at).slice(0, 10))}${i.lastCheck.fired ? ', and it changed' : ''}` : ''}

`).join('') || '

Nothing known yet.

'}
`; +} + +function libraryView(data) { + const L = data.model.library; + return `

Library

What the agent reads before it draws: named designers' rules by kind of screen, and where to start for a kind of product.

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

${icon('eye', 16)} ${esc(l.name)}

${esc(l.when)}

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

`).join('')}
+

Templates

${L.templates.map(t => `

${icon('screens', 16)} ${esc(t.name)}

${esc(t.when)}

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

`).join('')}
`; +} + +// What the page's script reads, embedded in the page and served as the studio's data.json. +export const pageData = (data, { asset = '../', serve = false, live = false } = {}) => ({ model: data.model, design: data.design, asset, serve, live, icons: ICONS }); + +// data: { model, design, runs, understanding } (src/dashboard.js gather). opts: asset — prefix for paths +// under .uxcli/; serve — the studio's decisions; live — refresh on change; folders/current — the switch. +export function appPage(data, { asset = '../', serve = false, live = false, folders = null, current = 0, notice = null } = {}) { + const m = data.model; const T = todos(m, data.design); + const top = T[0]; const topShot = top && (() => { const x = top.go.split('|'); const j = m.journeys.find(q => q.id === x[1]); const s = j?.workflows.find(w => String(w.id) === x[2])?.steps.find(q => q.id === x[3]); const p = x[4] === 'end' ? s?.built.after : s?.built.shot; return p ? shotUrl(asset, p) : null; })(); + const embed = JSON.stringify(pageData(data, { asset, serve, live })).replace(/${icon('folder', 15)}` : `${icon('folder', 15)} ${esc(m.project.name)}`; + return `${esc(m.project.name)} · uxcli +
${folder} +
+${icon(parseInt(m.viewport) < 700 ? 'phone' : 'desktop', 15)} ${esc(m.viewport)}${live ? ' · connecting' : ''}
+${notice ? `

${esc(notice)}

` : ''} +
+${top ? `
${topShot ? `` : ''}${top.kind === 'fail' ? '' : ''}
${icon(top.kind === 'fail' || top.kind === 'find' ? 'alert' : top.kind === 'open' ? 'picked' : 'drawn', 12)} ${top.kind === 'fail' ? 'Fix first' : top.kind === 'find' ? 'Look at first' : top.kind === 'open' ? 'Waiting for you' : 'Next'}
${esc(top.text)}
` : ''} + +${T.length ? `` : ''} +
100%
+ + + + +`; +} diff --git a/src/core/dashboard.js b/src/core/dashboard.js new file mode 100644 index 0000000..180d891 --- /dev/null +++ b/src/core/dashboard.js @@ -0,0 +1,101 @@ +// 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 +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 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/core/experience.js b/src/core/experience.js index 263a780..149f040 100644 --- a/src/core/experience.js +++ b/src/core/experience.js @@ -77,7 +77,7 @@ export function experience(run) { source: SRC.response, shot: shotAfter }); for (const x of ui) if ((x.scrollsNeeded || 0) > 0) findings.push({ metric: 'reach', step: st.id, workflow: st.workflow || null, what: `${x.target} is ${x.scrollsNeeded} scroll${x.scrollsNeeded === 1 ? '' : 's'} away at ${x.viewport || run.viewport || 'this viewport'} when this step needs it`, - source: SRC.reach, shot: shotBefore, ...(x.rect && { rect: x.rect }) }); + source: SRC.reach, shot: shotBefore, element: x.target, scrolls: x.scrollsNeeded, state: st.before?.state || null, ...(x.rect && { rect: x.rect }) }); for (const x of typed) { const first = typedAt.get(x.typed.value); if (first && first.step !== st.id && first.workflow === (st.workflow || null)) findings.push({ metric: 'asked-twice', step: st.id, workflow: st.workflow || null, diff --git a/src/core/map.js b/src/core/map.js index 5de8876..3f56fca 100644 --- a/src/core/map.js +++ b/src/core/map.js @@ -8,6 +8,7 @@ // an insight with its source, or what the browser measured. "Drift" means exactly one of two things: // a state the run said did not hold, or a verdict of fail cited at that step. import { esc } from './wireflow.js'; +import { LEGACY_ALIASES } from './ui.js'; export const DRIFT_WORD = { fail: 'FAIL', finding: 'finding', pass: 'pass', 'not-committed': 'not committed', unmeasurable: 'unmeasurable', 'not-applicable': 'n/a' }; @@ -188,7 +189,7 @@ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;paddin .fx b{display:block;font:600 13px var(--sans);overflow-wrap:anywhere}.fx small{font:11px var(--mono);color:var(--dim)} .fx .thumb{width:110px;--ta:16/10} .empty{color:var(--dim)} - +${LEGACY_ALIASES}
diff --git a/src/core/mockups-page.js b/src/core/mockups-page.js index bc9aadd..11035b7 100644 --- a/src/core/mockups-page.js +++ b/src/core/mockups-page.js @@ -9,6 +9,7 @@ // screen: { id, state, n, crumb, title, action, question, status, first?, vw, vh, revise, tech, problems, refs, // variants: [{ name, shot, screens, hooks, pins, view, pickId, status, summary, part, review }] } import { esc, human, sentence, flowRow, hooksHtml, pinsHtml, WIREFLOW_CSS } from './wireflow.js'; +import { LEGACY_ALIASES } from './ui.js'; export const STATUS = { picked: 'Decided', revise: 'Revision asked', open: 'Open', undrawn: 'Not drawn' }; const letter = k => String.fromCharCode(65 + k); @@ -254,7 +255,7 @@ export function mockupsPage(m) { ${esc(m.name)} mockups - +
diff --git a/src/core/run/measure.js b/src/core/run/measure.js index 2750c7d..5e3bdb6 100644 --- a/src/core/run/measure.js +++ b/src/core/run/measure.js @@ -2,7 +2,8 @@ // steps with their observations attached (`obs.before`, `obs.after` — never written to the packet). // // in: commitment, measurement, its index, steps [{ id, workflow, before:{state}, after:{state}, timing, shots, obs }], ctx for holds() -// out: { commitmentId, index, outcome: held | not-held | condition-not-met | unmeasurable, where, what?, shot?, cause? } +// out: { commitmentId, index, outcome: held | not-held | condition-not-met | unmeasurable, where, state, what?, element?, scrolls?, shot?, cause? } +// state: the screen the measurement was read on; element and scrolls: what a page says in words of its own import { holds } from '../observe/holds.js'; const CAP = 10000; @@ -26,7 +27,7 @@ export function outcomeOf(c, m, index, steps, ctx = {}) { if (!hit) return { ...base, outcome: 'unmeasurable', where: c.scope?.step, cause: `${m.target} was not reached in this run` }; const { step, side } = hit; const obs = step.obs?.[side]; - const out = { ...base, where: step.id, shot: shotOf(step, side) }; + const out = { ...base, where: step.id, ...(step[side]?.state && { state: step[side].state }), shot: shotOf(step, side) }; const p = m.predicate; if (!p || typeof p !== 'object') return { ...out, outcome: 'unmeasurable', cause: 'predicate is prose; write it as a signal object' }; if (!obs) return { ...out, outcome: 'unmeasurable', cause: `no observation at ${step.id}.${side}` }; @@ -44,5 +45,5 @@ export function outcomeOf(c, m, index, steps, ctx = {}) { if (r.held === null) return { ...out, outcome: 'unmeasurable', cause: r.why.join('; ') }; const d = sig.selector ? obs.dom?.[sig.selector] : null; const scroll = d && sig.inViewportWithoutScroll && d.scrollsNeeded ? ` — needs ${d.scrollsNeeded} scroll${d.scrollsNeeded === 1 ? '' : 's'}${ctx.viewport ? ` at ${ctx.viewport}` : ''}` : ''; - return { ...out, outcome: 'not-held', what: `${r.why.join('; ')}${scroll} at ${m.target}` }; + return { ...out, outcome: 'not-held', what: `${r.why.join('; ')}${scroll} at ${m.target}`, ...(sig.selector && { element: sig.selector }), ...(scroll && { scrolls: d.scrollsNeeded }) }; } diff --git a/src/core/run/verdicts.js b/src/core/run/verdicts.js index 5c2c81e..6066fec 100644 --- a/src/core/run/verdicts.js +++ b/src/core/run/verdicts.js @@ -37,7 +37,7 @@ const callerCaps = (caps, c, where) => caps.filter(k => (!k.commitment || k.comm function one(c, m, index, outcome, run, caps, anchorHashes) { const where = outcome?.where || c.scope?.step; const base = { commitment: c.id, measurement: index, caps: [] }; - const pass = (v, cause, extra = {}) => ({ ...base, value: v, cause, ...extra, ...(where && { where }), ...(outcome?.what && { what: outcome.what }), ...(outcome?.shot && { shot: outcome.shot }) }); + const pass = (v, cause, extra = {}) => ({ ...base, value: v, cause, ...extra, ...(where && { where }), ...(outcome?.what && { what: outcome.what }), ...(outcome?.state && { state: outcome.state }), ...(outcome?.element && { element: outcome.element }), ...(outcome?.scrolls && { scrolls: outcome.scrolls }), ...(outcome?.shot && { shot: outcome.shot }) }); if (c.anchor && anchorState({ entry: { derivedFrom: c.anchor }, actual: anchorHashes[c.anchor.run] ?? null }) === 'differs') return pass('not-committed', 'anchor-differs'); if (!outcome) return pass('unmeasurable', 'no measurement supplied'); @@ -57,7 +57,7 @@ export const stepVerdicts = run => (run.steps || []).filter(s => s.after && s.af const low = LOW.has(s.after.strength); return { journey: journeyIdOf(run), ...(s.workflow && { workflow: s.workflow }), step: s.id, - value: low ? 'finding' : 'fail', caps: low ? [{ by: 'observability', from: 'fail' }] : [], cause: 'state-not-held', + value: low ? 'finding' : 'fail', caps: low ? [{ by: 'observability', from: 'fail' }] : [], cause: 'state-not-held', ...(s.after.state && { state: s.after.state }), ...(s.after.what && { what: s.after.what }), ...(s.shots?.length && { shot: s.shots[s.shots.length - 1] }), }; }); diff --git a/src/core/studio-page.js b/src/core/studio-page.js deleted file mode 100644 index 4c5208f..0000000 --- a/src/core/studio-page.js +++ /dev/null @@ -1,185 +0,0 @@ -// 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}.layers select{border:1px solid var(--line);border-radius:6px;padding:3px 6px;background:var(--surface)}#cmp[hidden]{display:none}.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={},cmp={from:'',to:''}; -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(cmp.from&&cmp.to&&v.name!==cmp.from&&v.name!==cmp.to)return;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'); - var delta='';if(cmp.from&&cmp.to&&vn===cmp.to){var a0=s.versions.filter(function(q){return q.name===cmp.from;})[0];if(a0&&a0.present&&v.klmSeconds!=null){var dk=Math.round((v.klmSeconds-a0.klmSeconds)*10)/10,df=v.findings-a0.findings;delta=''+(dk>0?'+':'')+dk+' s · '+(df>0?'+':'')+df+'';}else if(!a0||!a0.present)delta='new step';} - f.appendChild(el('div','fh',''+s.n+''+esc(vn)+''+(v.klmSeconds!=null?''+v.klmSeconds+' s':'')+(v.findings?''+v.findings+'':'')+delta)); - 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.shot)h+='
    Open the picture
    '; - if(M.serve)h+='

    Propose a change

    '; - if(s.walkthrough&&s.walkthrough.length)h+='

    Walkthrough

    '+list(s.walkthrough,function(f){return '
  • '+esc(f.question)+': '+esc(f.answer)+' '+esc(f.what.split(' — ').slice(1).join(' — '))+(f.as?'as '+esc(f.as)+'':'')+'
  • ';})+'
    '; - 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 nb=document.getElementById('nbtn');if(nb)nb.addEventListener('click',function(){post('/api/note',{journey:x.j.id,workflow:x.w.id,step:s.id,note:document.getElementById('nnote').value});}); - 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();};}); -(function(){var names=[];M.journeys.forEach(function(j){j.versions.forEach(function(v){if(names.indexOf(v.name)<0)names.push(v.name);});});var a=document.getElementById('cmp-from'),b=document.getElementById('cmp-to');if(!a)return; - [a,b].forEach(function(sl){names.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sl.appendChild(o);});sl.onchange=function(){cmp={from:a.value,to:b.value};render();};});document.getElementById('cmp').hidden=names.length<2;})(); -[].forEach.call(document.querySelectorAll('.tabs button'),function(b){b.onclick=function(){(function(){var names=[];M.journeys.forEach(function(j){j.versions.forEach(function(v){if(names.indexOf(v.name)<0)names.push(v.name);});});var a=document.getElementById('cmp-from'),b=document.getElementById('cmp-to');if(!a)return; - [a,b].forEach(function(sl){names.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sl.appendChild(o);});sl.onchange=function(){cmp={from:a.value,to:b.value};render();};});document.getElementById('cmp').hidden=names.length<2;})(); -[].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 index ea0a5c5..3b6ae49 100644 --- a/src/core/studio.js +++ b/src/core/studio.js @@ -23,8 +23,8 @@ export function studioModel({ map, screens = [], experiences = {}, versions = {} 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 })); + 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, element: f.element || null, scrolls: f.scrolls ?? 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, state: x.state || null, element: x.element || null, scrolls: x.scrolls ?? 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 }, diff --git a/src/core/ui.js b/src/core/ui.js new file mode 100644 index 0000000..3d0c649 --- /dev/null +++ b/src/core/ui.js @@ -0,0 +1,85 @@ +// uxcli's design system, one for every page it writes: the tokens, the icons and the shared component +// styles. Picked by the owner on 2026-10-03 from three drawings and a merge of two of them (docs/design/ +// system/): the project is a canvas of the screens a person sees, read as filmstrips — pictures first, +// state as icons and colour on the picture, words only where a picture cannot say it. Pure. + +// Every colour a page paints is one of these; light, with a dark set under prefers-color-scheme (unless +// the page says data-theme="light") or when it says data-theme="dark". +const DARK = `color-scheme:dark; +--bg:#0f1115;--canvas:#0f1115;--dot:#262a33;--surface:#171a20;--well:#1d2128;--line:#262b34;--line-strong:#3a404c; +--ink:#e9ebf0;--soft:#c4c8d2;--dim:#a2a8b5;--device:#3a404c; +--accent:#a9b0ff;--accent-soft:#23264a;--fail:#ffb0a8;--fail-strong:#f0645a;--fail-soft:#3d1a17;--find:#ffd27a;--find-strong:#e8a33a;--find-soft:#3a2b0f; +--ok:#7ee2b8;--ok-soft:#123a2a;--open:#a9b0ff;--open-soft:#23264a;--idle:#a2a8b5;--idle-soft:#262b34;--shadow:none;--lift:0 8px 24px rgba(0,0,0,.5)`; +export const TOKENS = `:root{color-scheme:light; +--bg:#f3f4f7;--canvas:#f3f4f7;--dot:#d3d6de;--surface:#fff;--well:#f5f6f8;--line:#e6e8ee;--line-strong:#c9ccd6; +--ink:#16181d;--soft:#4b5160;--dim:#5b6170;--device:#16181d; +--accent:#3b3bd0;--accent-soft:#eef0ff;--brand-a:#5b5bf6;--brand-b:#22c3a6; +--fail:#b42318;--fail-strong:#d92d20;--fail-soft:#fde3e1;--find:#8a5300;--find-strong:#c4700a;--find-soft:#fff3d6; +--ok:#0b7350;--ok-soft:#e3f6ee;--open:#3b3bd0;--open-soft:#eef0ff;--idle:#525866;--idle-soft:#e9ebf0; +--shadow:0 1px 3px rgba(22,24,29,.06);--lift:0 8px 24px rgba(22,24,29,.12); +--sans:Inter,-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${DARK}}} +:root[data-theme="dark"]{${DARK}}`; + +// 24px stroke icons; the same shape means the same thing on every page. +export const ICONS = { + drawn: '', + picked: '', + walked: '', + alert: '', + eye: '', + clock: '', + person: '', + down: '', + screens: '', + journeys: '', + library: '', + phone: '', + desktop: '', + folder: '', + question: '', + plus: '', minus: '', fit: '', + version: '', + note: '', + external: '', +}; +export const icon = (name, size = 16, label = null) => ` ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))}"` : ' aria-hidden="true"'}>${ICONS[name] || ''}`; + +// Components shared by every page: the shell, floating panels, the device frame, status icons, pins. +export const COMPONENTS = `*{box-sizing:border-box}html,body{margin:0}body{background:var(--bg);color:var(--ink);font:13px/1.45 var(--sans)} +a{color:var(--accent)}button,select,textarea,input{font:inherit;color:inherit}button{cursor:pointer}:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.ic{flex:none;vertical-align:-3px} +.float{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)} +.bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.box{display:flex;align-items:center;gap:8px;padding:7px 12px;font-weight:600} +.logo i{width:20px;height:20px;border-radius:6px;background:linear-gradient(135deg,var(--brand-a),var(--brand-b));display:inline-block} +.tabs{display:flex;gap:2px;padding:4px}.tabs a,.tabs button{display:flex;align-items:center;gap:6px;padding:5px 10px;border:0;border-radius:8px;background:none;color:var(--soft);text-decoration:none;font-size:12px;font-weight:600} +.tabs [aria-current=page],.tabs [aria-pressed=true]{background:var(--accent-soft);color:var(--accent)} +.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:8px;background:var(--surface);padding:6px 11px;font-weight:600;font-size:12px;text-decoration:none;color:var(--ink)} +.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} +@media (prefers-color-scheme:dark){.btn.primary{color:#0f1115}} +.chip{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:2px 9px;font-size:12px;font-weight:600;background:var(--surface);border:1px solid var(--line);color:var(--soft)} +.s{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:7px;background:var(--idle-soft);color:var(--idle)} +.s.y{background:var(--ok-soft);color:var(--ok)}.s.n{background:var(--fail-soft);color:var(--fail)}.s.w{background:var(--open-soft);color:var(--open)} +.ics{display:flex;gap:5px} +.device{position:relative;border-radius:18px;overflow:hidden;border:6px solid var(--device);background:#fff;box-shadow:var(--lift)} +.device.desk{border-radius:10px;border-width:4px} +.device img{display:block;width:100%} +.device.fail{border-color:var(--fail-strong);box-shadow:0 0 0 4px var(--fail-soft),var(--lift)}.device.find{border-color:var(--find-strong);box-shadow:0 0 0 4px var(--find-soft),var(--lift)} +.device .none{display:grid;place-items:center;height:100%;color:var(--dim);text-align:center;padding:12px;font-size:12px;background:var(--well);overflow-wrap:anywhere} +.fold{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:5px;padding:6px;background:var(--fail-strong);color:#fff;font-weight:700;font-size:11px} +.fold.find{background:var(--find-strong);color:#1a1200} +.pin{position:absolute;display:grid;place-items:center;width:22px;height:22px;border-radius:50% 50% 50% 0;transform:translate(-4px,-22px) rotate(-45deg);background:var(--fail-strong);color:#fff;font-weight:800;font-size:11px;box-shadow:0 2px 6px rgba(0,0,0,.25)}.pin b{transform:rotate(45deg)} +.pin.find{background:var(--find-strong);color:#1a1200} +.k{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--dim)}.k.fail{color:var(--fail)} +.todo{display:flex;align-items:center;gap:8px;border:0;border-radius:10px;padding:7px 11px;background:var(--well);font-weight:600;text-align:left;color:var(--ink)}.todo .n{display:grid;place-items:center;width:22px;height:22px;border-radius:50%;color:#fff;font-size:11px;flex:none} +.todo .n.fail{background:var(--fail-strong)}.todo .n.find{background:var(--find-strong);color:#1a1200}.todo .n.open{background:var(--accent)}.todo .n.idle{background:var(--idle)} +@media (prefers-color-scheme:dark){.todo .n.open{color:#0f1115}} +.muted{color:var(--dim)}`; + +// For the pages that predate this system (mockups, map, experience): their own variable names, given +// this system's values. Appended last in their CSS, so the light look is this one everywhere; their dark +// overrides stay as they were. +export const LEGACY_ALIASES = TOKENS + `:root{--finding:var(--find);--finding-soft:var(--find-soft);--pass:var(--ok);--pass-soft:var(--ok-soft);--faint:var(--dim);--line-soft:var(--line);--pin:var(--fail-strong);--stage:var(--canvas);--fail-ink:var(--fail);--pass-ink:var(--ok)} + +body{font-family:var(--sans)}`; diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..e79b51b --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,110 @@ +// 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 } from './journey.js'; +import { UXCLI } from './adapters/store/runs.js'; +import { buildStudio } from './studio.js'; +import { projectTemplate } from './template.js'; +import { serveFile } from './adapters/serve-file.js'; +import { worst } from './core/verdict/rank.js'; +import { experience, journeyName } from './core/experience.js'; +import { dashboardModel } from './core/dashboard.js'; +import { appPage } from './core/app-page.js'; +import { reviewSummary, summaryLine } from './core/model/lens.js'; +import { screenStatus } from './core/mockups.js'; + +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, { viewport, serve = false, shoot = false } = {}) { + 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 S = await buildStudio(root, { shoot, serve, ...(viewport && { viewport }) }); const P = S.project; + const actors = P.actors.map(a => a.value).filter(Boolean); const insights = P.insights.map(i => i.value).filter(Boolean); + + const runs = S.packets.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) }; + }) : []; + // the run's headline, said in the four words the page colours: anything short of fail that a person should read is a finding + const w = worst([...verdicts.map(v => v.value), ...probes.map(p => p.verdict)]); + const headline = r.status === 'blocked' ? 'blocked' : w === 'fail' ? 'fail' : w === 'pass' || w === 'not-applicable' ? 'pass' : 'finding'; + 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: headline, 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: P.proposals, actors, lastRuns, folder: path.relative(process.cwd(), root) || '.' }); + const D = S.discovered; 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, model: S.model, matrix, runs, design, understanding: { actors, insights, template: projectTemplate(root)?.id || null }, problems: P.problems }; +} + +// Written once, for one folder: the same page the studio writes, at .uxcli/dashboard/index.html. +export async function writeDashboard(from) { + const g = await gather(from); if (!g.model) return g; + const out = dashboardDir(g.root); fs.mkdirSync(out, { recursive: true }); + const page = path.join(out, 'index.html'); fs.writeFileSync(page, appPage(g, { asset: '../' })); + return { ...g, page }; +} + +const PICTURES = { '.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) || '.'; + // one reading per folder, kept until a file under its .uxcli/ changes (pictures being written do not count) + const cache = new Map(); const watched = new Set(); + const read = root => { + if (!watched.has(root)) { watched.add(root); try { fs.watch(path.join(root, UXCLI), { recursive: true }, (e, f) => { if (!String(f || '').includes('.shots')) cache.delete(root); }); } catch {} } + if (!cache.has(root)) cache.set(root, gather(root).catch(e => { cache.delete(root); return { problems: [e.message] }; })); + return cache.get(root); + }; + 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]]; if (!root) { res.writeHead(404); return res.end('not found'); } + return serveFile(res, path.join(root, UXCLI), m[2], PICTURES); + } + 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 g = await read(roots[i]); + const missing = url.searchParams.get('missing'); + const page = g.model ? appPage(g, { asset: `/f/${i}/`, folders: roots.map(label), current: i, notice: missing != null ? `${missing || 'that path'} holds no uxcli project (no .uxcli/policy/policy.json there or above)` : null }) + : `uxcli

    ${(g.problems || []).join('
    ')}

    `; + 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/experience.js b/src/experience.js index 9d74ad9..1e2397c 100644 --- a/src/experience.js +++ b/src/experience.js @@ -5,6 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { experience, compareExperience, journeyName, KLM, LIMITS } from './core/experience.js'; import { allRuns, artifactsDir, UXCLI } from './adapters/store/runs.js'; +import { LEGACY_ALIASES } from './core/ui.js'; const readJson = f => JSON.parse(fs.readFileSync(f, 'utf8')); const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); @@ -103,7 +104,7 @@ dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:4px .finds{margin:0;padding-left:20px;display:grid;gap:8px}.finds li{background:var(--find-soft);border-radius:6px;padding:6px 8px}.finds small{display:block;color:var(--dim);font-size:12px;margin-top:2px} .metric{font:600 12px ui-monospace,Menlo,monospace;color:var(--find)}.clean{margin:0;color:var(--ok)} footer{color:var(--dim);font-size:13px} -

    Experience

    What the person goes through on each journey, from the trace of its last walk in Chrome. Every finding is method-unproven: reported with its source, never a fail and never a pass.

    ${sections || '

    No journey run with steps on disk.

    '}
    Estimate: keystroke-level model (Card, Moran & Newell) — K ${KLM.K} s, P ${KLM.P} s, BB ${2 * KLM.B} s, H ${KLM.H} s, M ${KLM.M} s, each scroll one P, plus the measured settle time. Response bands after Nielsen (1993): ${LIMITS.instant} ms, ${LIMITS.flow} ms, ${LIMITS.attention} ms.
    +${LEGACY_ALIASES}

    Experience

    What the person goes through on each journey, from the trace of its last walk in Chrome. Every finding is method-unproven: reported with its source, never a fail and never a pass.

    ${sections || '

    No journey run with steps on disk.

    '}
    Estimate: keystroke-level model (Card, Moran & Newell) — K ${KLM.K} s, P ${KLM.P} s, BB ${2 * KLM.B} s, H ${KLM.H} s, M ${KLM.M} s, each scroll one P, plus the measured settle time. Response bands after Nielsen (1993): ${LIMITS.instant} ms, ${LIMITS.flow} ms, ${LIMITS.attention} ms.
    +
    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—