From ec2625c1ad932b0e9adf629a984e7296435428de Mon Sep 17 00:00:00 2001 From: Daniel Netzer Date: Tue, 27 Oct 2026 14:37:50 +0200 Subject: [PATCH 1/2] refactor(ui): split app.js/style.css into per-view ES modules and stylesheets, explicit served allowlist (no behaviour change) (#262) --- lab/internal/ui/analytics_test.go | 2 +- lab/internal/ui/evidence_test.go | 2 +- lab/internal/ui/gates_test.go | 6 +- lab/internal/ui/inbox_test.go | 54 +- lab/internal/ui/molecule_view_test.go | 22 +- lab/internal/ui/retire_test.go | 2 +- lab/internal/ui/served_test.go | 380 +++ lab/internal/ui/server.go | 36 +- lab/internal/ui/server_test.go | 54 +- lab/internal/ui/shell_test.go | 36 +- lab/internal/ui/ui_polish_test.go | 33 +- lab/internal/ui/web/app.js | 2434 ++----------------- lab/internal/ui/web/css/base.css | 212 ++ lab/internal/ui/web/css/formula.css | 20 + lab/internal/ui/web/css/inbox.css | 70 + lab/internal/ui/web/css/insights.css | 38 + lab/internal/ui/web/css/issues.css | 10 + lab/internal/ui/web/css/loop.css | 61 + lab/internal/ui/web/css/molecule.css | 222 ++ lab/internal/ui/web/css/tokens.css | 45 + lab/internal/ui/web/css/work.css | 92 + lab/internal/ui/web/index.html | 12 +- lab/internal/ui/web/js/core-gates.js | 117 + lab/internal/ui/web/js/core.js | 284 +++ lab/internal/ui/web/js/formula.js | 29 + lab/internal/ui/web/js/inbox.js | 189 ++ lab/internal/ui/web/js/insights.js | 85 + lab/internal/ui/web/js/issues.js | 10 + lab/internal/ui/web/js/loop.js | 296 +++ lab/internal/ui/web/js/molecule-decision.js | 464 ++++ lab/internal/ui/web/js/molecule-plan.js | 95 + lab/internal/ui/web/js/molecule-timeline.js | 235 ++ lab/internal/ui/web/js/molecule.js | 247 ++ lab/internal/ui/web/js/work.js | 180 ++ 34 files changed, 3724 insertions(+), 2350 deletions(-) create mode 100644 lab/internal/ui/served_test.go create mode 100644 lab/internal/ui/web/css/base.css create mode 100644 lab/internal/ui/web/css/formula.css create mode 100644 lab/internal/ui/web/css/inbox.css create mode 100644 lab/internal/ui/web/css/insights.css create mode 100644 lab/internal/ui/web/css/issues.css create mode 100644 lab/internal/ui/web/css/loop.css create mode 100644 lab/internal/ui/web/css/molecule.css create mode 100644 lab/internal/ui/web/css/tokens.css create mode 100644 lab/internal/ui/web/css/work.css create mode 100644 lab/internal/ui/web/js/core-gates.js create mode 100644 lab/internal/ui/web/js/core.js create mode 100644 lab/internal/ui/web/js/formula.js create mode 100644 lab/internal/ui/web/js/inbox.js create mode 100644 lab/internal/ui/web/js/insights.js create mode 100644 lab/internal/ui/web/js/issues.js create mode 100644 lab/internal/ui/web/js/loop.js create mode 100644 lab/internal/ui/web/js/molecule-decision.js create mode 100644 lab/internal/ui/web/js/molecule-plan.js create mode 100644 lab/internal/ui/web/js/molecule-timeline.js create mode 100644 lab/internal/ui/web/js/molecule.js create mode 100644 lab/internal/ui/web/js/work.js diff --git a/lab/internal/ui/analytics_test.go b/lab/internal/ui/analytics_test.go index 58dc131..4454def 100644 --- a/lab/internal/ui/analytics_test.go +++ b/lab/internal/ui/analytics_test.go @@ -344,7 +344,7 @@ func TestAnalyticsIsGuardedReadOnlyAndSampleOnlyInDemo(t *testing.T) { // and the Unavailable cost-per-merged-PR card. func TestAnalyticsOverviewRendersTextOnly(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "api('/api/analytics'", "token !== state.analyticsToken", diff --git a/lab/internal/ui/evidence_test.go b/lab/internal/ui/evidence_test.go index 04c77e4..cb91d41 100644 --- a/lab/internal/ui/evidence_test.go +++ b/lab/internal/ui/evidence_test.go @@ -319,7 +319,7 @@ func TestUIAtomPanelSeparatesPlannedFromObserved(t *testing.T) { if d.Atoms[0].Provenance != nil { t.Fatalf("intake provenance inferred: %+v", d.Atoms[0].Provenance) } - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "'PLANNED CAPABILITIES · DECLARED, NOT OBSERVED'", "'ACTIONS TAKEN · OBSERVED'", diff --git a/lab/internal/ui/gates_test.go b/lab/internal/ui/gates_test.go index 95b60f1..d47198c 100644 --- a/lab/internal/ui/gates_test.go +++ b/lab/internal/ui/gates_test.go @@ -668,7 +668,7 @@ func TestUIGateJobRegistryIsBoundedAndStops(t *testing.T) { // submissions, reads canonical state back instead of retrying, and builds // everything from text nodes. func TestUIGateServedJSSubmitsOnceAndReadsBack(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "method: 'POST'", "'Content-Type': 'application/json'", @@ -693,13 +693,13 @@ func TestUIGateServedJSSubmitsOnceAndReadsBack(t *testing.T) { } } // An ambiguous response is read back and never resubmitted. - submit := jsFunction(t, js, "submitGate") + submit := moduleFunction(t, js, "submitGate") for _, want := range []string{"if (!res || res.status >= 500) {", "readBack(gate);", "return null;"} { if !strings.Contains(submit, want) { t.Errorf("submitGate lacks %q", want) } } - if strings.Contains(submit, "gatePost(gate.id, action, payload);\n res = await gatePost") { + if strings.Count(submit, "await gatePost(") != 1 { t.Error("submitGate posts twice") } for _, gone := range []string{"approvals are not available in this UI", "/merge", "/deploy"} { diff --git a/lab/internal/ui/inbox_test.go b/lab/internal/ui/inbox_test.go index b970d07..33b011c 100644 --- a/lab/internal/ui/inbox_test.go +++ b/lab/internal/ui/inbox_test.go @@ -186,8 +186,10 @@ func TestUIInboxTextHasNoRawCodes(t *testing.T) { // The browser renders only those plain fields: the Inbox code never turns a // phase, status, Atom, Formula or digest into text, and ids reach only // links' href and dataset. - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - inbox := between(t, js, "// ---- Inbox ----", "showView(viewFromURL());\n restoreSelection();") + inbox := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/js/inbox.js")).Body.String() + if !strings.Contains(inbox, "// ---- Inbox ----") || !strings.Contains(inbox, "function renderInbox(") { + t.Fatal("/js/inbox.js is not the Inbox module") + } for _, forbidden := range []string{"m.phase", "m.status", "m.currentAtom", "m.formulaId", "m.lane", ".digest", "short("} { if strings.Contains(inbox, forbidden) { t.Errorf("the Inbox renders %s", forbidden) @@ -369,14 +371,14 @@ func TestUIInboxSummaryScrubsOnlyRealIDs(t *testing.T) { // the resident all count it, so a Lab with only a blocked Molecule never reads // Nothing needs you or All quiet. The fixture's blocked Molecule is one of them. func TestUIInboxOneNeedsYouDefinition(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) if !strings.Contains(js, "const NEEDS_KINDS = ['scope', 'publish', 'blocked'];") { t.Error("Needs you does not cover scope, publish and blocked") } - if needs := jsFunction(t, js, "needsYou"); !strings.Contains(needs, "NEEDS_KINDS.includes(v.kind)") || !strings.Contains(needs, "live(m) && inboxOf(m)") { + if needs := moduleFunction(t, js, "needsYou"); !strings.Contains(needs, "NEEDS_KINDS.includes(v.kind)") || !strings.Contains(needs, "live(m) && inboxOf(m)") { t.Errorf("needsYou does not list the Needs-you cards: %s", needs) } - shell, inbox := jsFunction(t, js, "renderShell"), jsFunction(t, js, "renderInbox") + shell, inbox := moduleFunction(t, js, "renderShell"), moduleFunction(t, js, "renderInbox") for body, wants := range map[string][]string{ shell: {"const waiting = needsYou().length;", "badge.hidden = waiting === 0;", "residentPose(waiting, counts)", "RESIDENT_TITLES[pose](waiting, counts)"}, inbox: {"const needs = needsYou();", "const waiting = needs.length,", "waiting ? needsYouText(waiting) : 'Nothing needs you'", "setText($('#inbox-live'), waiting ? needsYouText(waiting) + '.' : 'Nothing needs you right now.');"}, @@ -387,7 +389,7 @@ func TestUIInboxOneNeedsYouDefinition(t *testing.T) { } } } - for name, body := range map[string]string{"renderShell": shell, "renderInbox": inbox, "residentPose": jsFunction(t, js, "residentPose")} { + for name, body := range map[string]string{"renderShell": shell, "renderInbox": inbox, "residentPose": moduleFunction(t, js, "residentPose")} { if strings.Contains(body, "counts.decision") { t.Errorf("%s counts the decision lane instead of Needs you", name) } @@ -453,7 +455,7 @@ func TestUIInboxEmptyStateAndIncompleteInventory(t *testing.T) { if !strings.Contains(page, `

All quiet

`) { t.Error("the resident is not titled All quiet") } - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "$('#inbox-needs-empty').hidden = needs.length > 0;", "setText($('#inbox-live'), waiting ? needsYouText(waiting) + '.' : 'Nothing needs you right now.');", @@ -499,7 +501,7 @@ func loopBody(mode, value string, n int) string { // engine receives exactly that selector and bound; Start intake posts {issue}. // A selector the server refuses never reaches the engine. func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "function loopSelector(mode, value) { return mode === 'oldest' ? 'oldest' : mode + ':' + String(value).trim(); }", "const loop = await submitDrive('/api/loop/start', JSON.stringify({ select: String(select).trim(), maxConcurrent: n }));", @@ -512,13 +514,13 @@ func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { t.Errorf("app.js lacks %q", want) } } - submit := jsFunction(t, js, "submitLoop") + submit := moduleFunction(t, js, "submitLoop") for _, want := range []string{"e.preventDefault();", "startLoop(loopSelector(drive.mode, c.value.value), c.max.value);", "driveMessage('Enter a ' + LOOP_MODES[drive.mode].field.toLowerCase() + ', or choose Oldest.');"} { if !strings.Contains(submit, want) { t.Errorf("submitLoop lacks %q", want) } } - if run := jsFunction(t, js, "submitRun"); !strings.Contains(run, "e.preventDefault();") || !strings.Contains(run, "startRun(drive.controls.issue.value);") { + if run := moduleFunction(t, js, "submitRun"); !strings.Contains(run, "e.preventDefault();") || !strings.Contains(run, "startRun(drive.controls.issue.value);") { t.Errorf("submitRun does not post the typed Issue: %s", run) } // The forms submit on Enter through a submit button; the CSP's form-action @@ -570,8 +572,8 @@ func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { // never refilled, and links are real ?view=work&m= links opened through // history.pushState. func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - patch := jsFunction(t, js, "patchList") + js := servedJS(t) + patch := moduleFunction(t, js, "patchList") for _, want := range []string{ "const existing = new Map([...container.children].map((node) => [node.dataset.inboxId, node]));", "let node = existing.get(m.id);", @@ -585,12 +587,12 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { t.Errorf("patchList lacks %q", want) } } - for _, gone := range []string{"fill(", "replaceChildren", "create();\n update"} { + for _, gone := range []string{"fill(", "replaceChildren", "create();\n update"} { if strings.Contains(patch, gone) { t.Errorf("patchList rebuilds with %q", gone) } } - render := jsFunction(t, js, "renderInbox") + render := moduleFunction(t, js, "renderInbox") for _, want := range []string{ "patchList($('#inbox-needs'), needs.slice(0, MAX_ENTRIES), createCard, updateCard);", "patchList($('#inbox-progress'), running.slice(0, MAX_ENTRIES), createRow, updateRow);", @@ -609,7 +611,7 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { "setLink": {"setAttr(a, 'href', moleculeHref(m.id));", "if (a.dataset.molecule !== m.id) a.dataset.molecule = m.id;"}, "setAttr": {"if (node.getAttribute(name) !== value) node.setAttribute(name, value);"}, } { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) for _, want := range wants { if !strings.Contains(body, want) { t.Errorf("%s lacks %q", name, want) @@ -621,13 +623,15 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { } for _, want := range []string{ "keepFocus(renderList)", - "renderInbox();\n }", + // The list fans out to the Inbox last, and the Inbox module renders it. + "inbox.update(state);\n}", + "export function update(state) { renderInbox(); }", "function moleculeHref(id) { return '?view=work&m=' + encodeURIComponent(id); }", "if (location.search !== moleculeHref(id)) history.pushState({ view: 'work', m: id }, '', moleculeHref(id));", "const link = e.target.closest('a[data-molecule]');", "if (moleculeFromURL() !== id) history.replaceState({ view: 'work', m: id }, '', moleculeHref(id));", "const MOLECULE_ID = /^[A-Za-z0-9_-]{1,200}$/;", - "setInterval(() => { if (!document.hidden && state.list) renderInbox(); }, 30000);", + "setInterval(() => { if (!document.hidden && state.list) inbox.update(state); }, 30000);", "el('ol', { class: 'inbox-steps', 'aria-label': 'Progress' }, STEPS.map(", "const STEPS = ['Plan', 'Your approval', 'Coding', 'Checks', 'Review', 'Draft PR', 'Merged'];", "el('a', { class: 'btn btn-primary inbox-action', dataset: { slot: 'action' } })", @@ -637,7 +641,7 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { } } // One primary action per card: createCard builds exactly one .btn. - if card := jsFunction(t, js, "createCard"); strings.Count(card, "btn") != 2 || strings.Count(card, "btn-primary") != 1 || strings.Contains(card, "btn-danger") { + if card := moduleFunction(t, js, "createCard"); strings.Count(card, "btn") != 2 || strings.Count(card, "btn-primary") != 1 || strings.Contains(card, "btn-danger") { t.Errorf("a card does not have exactly one primary action: %s", card) } } @@ -645,19 +649,25 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { // The Inbox CSS stacks cards with full-width primary buttons on phones, gives // coarse pointers 44px targets and honours forced colors. func TestUIInboxServedCSS(t *testing.T) { - css := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/style.css")).Body.String() + css := servedCSS(t) rules := cssRules(css) wantDecls(t, rules, ".inbox-card", "display: flex;", "gap: 20px;") wantDecls(t, rules, ".inbox-steps", "grid-template-columns: repeat(7, minmax(0, 1fr));") wantDecls(t, rules, ".inbox-steps li.is-current .step-bar", "background: linear-gradient(") wantDecls(t, rules, ".seg[aria-pressed=true]", "background: var(--paper);") wantDecls(t, rules, ".inbox-counts", "font-variant-numeric: tabular-nums;") - mobile := cssRules(mediaBlock(t, css, "@media (max-width: 720px)")) + mobile := cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")) wantDecls(t, mobile, ".inbox-card", "flex-direction: column;") wantDecls(t, mobile, ".inbox-action", "width: 100%;", "min-height: 48px;") wantDecls(t, mobile, ".inbox-greeting > .btn", "width: 100%;") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (pointer: coarse)")), ".seg, .stepper-button, .inbox-watch, .inbox-recent-row", "min-height: 44px;") - forced := cssRules(mediaBlock(t, css, "@media (forced-colors: active)")) + coarse := cssRules(mediaBlocks(t, css, "@media (pointer: coarse)")) + wantDecls(t, coarse, ".seg, .stepper-button", "min-height: 44px;") + wantDecls(t, coarse, ".inbox-watch, .inbox-recent-row", "min-height: 44px;") + // The Watch link keeps the .btn height it always had; base.css sets it. + if strings.Contains(rules[".inbox-watch"], "height") { + t.Errorf(".inbox-watch overrides the .btn height: %s", rules[".inbox-watch"]) + } + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) wantDecls(t, forced, ".seg[aria-pressed=true]", "background: Highlight;") wantDecls(t, forced, ".inbox-steps li.is-done .step-bar, .inbox-steps li.is-current .step-bar", "background: Highlight;") } diff --git a/lab/internal/ui/molecule_view_test.go b/lab/internal/ui/molecule_view_test.go index 5368046..9cf930f 100644 --- a/lab/internal/ui/molecule_view_test.go +++ b/lab/internal/ui/molecule_view_test.go @@ -340,8 +340,8 @@ func TestMoleculeViewServedPage(t *testing.T) { // the Molecule and apart from any primary, routes ?m= and Back through // history, and never rewrites the Publish title while polling. func TestMoleculeViewServedJS(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - build := jsFunction(t, js, "ensureMoleculeView") + js := servedJS(t) + build := moduleFunction(t, js, "ensureMoleculeView") decision := between(t, build, "fill($('#mv-decision'),", ");") danger := between(t, build, "c.stopOpen =", "fill($('#mv-danger-slot'), c.runnerNote, c.danger);") if strings.Contains(decision, "stop") || strings.Contains(decision, "retire") || strings.Contains(decision, "danger") { @@ -399,7 +399,7 @@ func TestMoleculeViewServedJS(t *testing.T) { "viewFromURL": {"if (!view && moleculeFromURL()) return 'work';"}, "restoreSelection": {"else if (!id && state.selectedId) overview();", "if (VIEWS.includes(from)) mv.from = from;"}, "openMolecule": {"rememberFrom(from);"}, - "navigate": {"if (moleculeFromURL()) history.pushState({ view: view }, '', '?view=' + view);", "restoreSelection();"}, + "navigate": {"if (moleculeFromURL()) history.pushState({ view: view }, '', '?view=' + view);", "ctx.restoreSelection();"}, "renderHeader": {"whenText(plan.plannedAt)", "dateTitle(plan.plannedAt)"}, "renderPlanDetails": {"el('code', null, digest.slice(0, 7))", "setText($('#mv-digest-short'), digest ? short(digest) : 'Not recorded');"}, "renderTimeline": {"if (panel.contains(document.activeElement) && mv.timelineId === d.id) { mv.timelineDirty = true; return; }"}, @@ -408,7 +408,7 @@ func TestMoleculeViewServedJS(t *testing.T) { "showMoleculeView": {"$('.shelf').hidden = open;"}, "renderPlan": {"if (key === mv.planKey) return;"}, } { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) for _, want := range wants { if !strings.Contains(body, want) { t.Errorf("%s lacks %q", name, want) @@ -416,7 +416,7 @@ func TestMoleculeViewServedJS(t *testing.T) { } } // A poll never rewrites a typed value or rebuilds the card. - render := jsFunction(t, js, "renderDecision") + render := moduleFunction(t, js, "renderDecision") for _, gone := range []string{"title.value", "reasonInput.value", "acceptance.value", "fill($('#mv-decision')", "short(", "innerHTML"} { if strings.Contains(render, gone) { t.Errorf("renderDecision has %q", gone) @@ -424,7 +424,7 @@ func TestMoleculeViewServedJS(t *testing.T) { } // No raw digest or timestamp text is set outside Plan details. for _, name := range []string{"renderHeader", "renderDecision", "renderPlan", "fileItem", "groupItem", "renderRoles"} { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) if regexp.MustCompile(`(setText|el)\([^;]*(\.digest\b[^)]*\)|plannedAt\))`).MatchString(strings.ReplaceAll(strings.ReplaceAll(body, "whenText(plan.plannedAt)", ""), "dateTitle(plan.plannedAt)", "")) { t.Errorf("%s renders a digest or a raw time", name) } @@ -441,7 +441,7 @@ func TestMoleculeViewServedJS(t *testing.T) { // sticky, stacks the view on narrow screens and keeps 44px targets and forced // colors for its controls. func TestMoleculeViewServedCSS(t *testing.T) { - css := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/style.css")).Body.String() + css := servedCSS(t) rules := cssRules(css) wantDecls(t, rules, ".btn-decision", "height: 48px;", "border-radius: 14px;") wantDecls(t, rules, ".mv-aside", "position: sticky;") @@ -450,12 +450,12 @@ func TestMoleculeViewServedCSS(t *testing.T) { wantDecls(t, rules, ".danger-zone", "border: 1px solid #eccfc6;") wantDecls(t, rules, ".state-pill[data-tone=needs]", "background: var(--needs-bg);") wantDecls(t, rules, ".shell.has-molecule .side-panel", "display: none;") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (max-width: 1199px)")), ".mv-layout", "grid-template-columns: minmax(0, 1fr);") - mobile := cssRules(mediaBlock(t, css, "@media (max-width: 720px)")) + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (max-width: 1199px)")), ".mv-layout", "grid-template-columns: minmax(0, 1fr);") + mobile := cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")) wantDecls(t, mobile, ".check-grid", "grid-template-columns: minmax(0, 1fr);") wantDecls(t, mobile, ".file", "grid-template-columns: minmax(0, 1fr);") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (pointer: coarse)")), ".mv-tab, .btn-small, .breadcrumb a, .accept-label, .preview-info .text-button", "min-height: 44px;") - forced := cssRules(mediaBlock(t, css, "@media (forced-colors: active)")) + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (pointer: coarse)")), ".mv-tab, .btn-small, .breadcrumb a, .accept-label, .preview-info .text-button", "min-height: 44px;") + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) wantDecls(t, forced, ".mv-tab[aria-selected=true]", "background: Highlight;") wantDecls(t, forced, ".mv-step.is-done .step-bar, .mv-step.is-current .step-bar, .mv-step.is-blocked .step-bar", "background: Highlight;") if bad := uncoveredOutlineResets(css); len(bad) != 0 { diff --git a/lab/internal/ui/retire_test.go b/lab/internal/ui/retire_test.go index 60405c1..9734f29 100644 --- a/lab/internal/ui/retire_test.go +++ b/lab/internal/ui/retire_test.go @@ -221,7 +221,7 @@ func TestUIRetireAction(t *testing.T) { // The served script has the Retired lane and filter, keeps retired Molecules out // of All and the headline counts, and posts the retire reason as JSON. func TestUIServedJSRetire(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "['retired', 'Retired']", "state.filter === 'all' ? live(m) : m.lane === state.filter", diff --git a/lab/internal/ui/served_test.go b/lab/internal/ui/served_test.go new file mode 100644 index 0000000..fc2bd82 --- /dev/null +++ b/lab/internal/ui/served_test.go @@ -0,0 +1,380 @@ +package ui + +import ( + "net/http" + "regexp" + "strings" + "testing" +) + +// servedModules are the entry and every script module it loads, in the fixed +// order servedJS concatenates them. +var servedModules = []string{ + "/app.js", + "/js/core.js", + "/js/core-gates.js", + "/js/inbox.js", + "/js/molecule.js", + "/js/molecule-plan.js", + "/js/molecule-decision.js", + "/js/molecule-timeline.js", + "/js/work.js", + "/js/issues.js", + "/js/insights.js", + "/js/formula.js", + "/js/loop.js", +} + +// servedStylesheets are the stylesheets index.html links, in link order: +// tokens, base, then one per view. +var servedStylesheets = []string{ + "/css/tokens.css", + "/css/base.css", + "/css/inbox.css", + "/css/molecule.css", + "/css/work.css", + "/css/issues.css", + "/css/insights.css", + "/css/formula.css", + "/css/loop.css", +} + +// viewModules are the view modules; each exports mount(root, ctx) and +// update(state), and imports only the core and its own sub-modules. +var viewModules = []string{"inbox", "molecule", "work", "issues", "insights", "formula", "loop"} + +// maxModuleLines bounds every script module and stylesheet. +const maxModuleLines = 600 + +func servedAll(t *testing.T, paths []string) string { + t.Helper() + h := testHandler(t, Options{LabDir: t.TempDir()}) + var b strings.Builder + for _, path := range paths { + w := do(t, h, authed(path)) + if w.Code != http.StatusOK { + t.Fatalf("%s: %d", path, w.Code) + } + b.WriteString(w.Body.String()) + b.WriteString("\n") + } + return b.String() +} + +// servedJS is every served script module, concatenated in servedModules order. +func servedJS(t *testing.T) string { + t.Helper() + return servedAll(t, servedModules) +} + +// servedCSS is every linked stylesheet, concatenated in link order. +func servedCSS(t *testing.T) string { + t.Helper() + return servedAll(t, servedStylesheets) +} + +// moduleFunction returns the body of the top-level function name in module +// code, which sits at column 0: up to its closing brace at column 0, or the +// rest of its line for a one-line function. +func moduleFunction(t *testing.T, js, name string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^(?:export )?(?:async )?function ` + regexp.QuoteMeta(name) + `\([^)]*\) \{(.*)$`).FindStringSubmatchIndex(js) + if m == nil { + t.Fatalf("the served JS has no top-level function %s", name) + } + if rest := js[m[2]:m[3]]; rest != "" { + return rest + } + end := strings.Index(js[m[1]:], "\n}\n") + if end < 0 { + t.Fatalf("function %s is not closed", name) + } + return js[m[1] : m[1]+end] +} + +// moduleTable returns the body of the top-level table "const name = …" in +// module code, up to its closing bracket at column 0. +func moduleTable(t *testing.T, js, name, closing string) string { + t.Helper() + m := regexp.MustCompile(`(?s)\nconst ` + regexp.QuoteMeta(name) + ` = [\[{](.*?)\n` + regexp.QuoteMeta(closing) + `;`).FindStringSubmatch(js) + if m == nil { + t.Fatalf("the served JS has no %s table", name) + } + return m[1] +} + +// mediaBlocks joins the bodies of every top-level at-rule that starts with +// head, across all stylesheets: each view keeps its own part of a media query. +func mediaBlocks(t *testing.T, css, head string) string { + t.Helper() + var parts []string + for rest := css; ; { + i := strings.Index(rest, head+" {") + if i < 0 { + break + } + rest = rest[i+len(head)+2:] + j := strings.Index(rest, "\n}\n") + if j < 0 { + t.Fatalf("%s block is not closed", head) + } + parts = append(parts, rest[:j]) + rest = rest[j:] + } + if len(parts) == 0 { + t.Fatalf("the served CSS has no %s block", head) + } + return strings.Join(parts, "\n") +} + +// importRe matches a module's static imports. +var importRe = regexp.MustCompile(`(?m)^import (?:\{[^}]*\}|\* as [A-Za-z]+) from '([^']+)';$`) + +// dynamicImportRe matches a dynamic import( call, and reExportRe an +// "export … from" re-export, in any form or spacing. +var ( + dynamicImportRe = regexp.MustCompile(`\bimport\s*\(`) + reExportRe = regexp.MustCompile(`\bexport\s*(?:\*|\{[^}]*\})[^;'"]*?\bfrom\s*['"]`) +) + +// Every module and stylesheet is named in the allowlist and served with its +// type, no-store, the security headers and the version mark replaced; each +// needs the cookie and GET. index.html links exactly the stylesheets in order +// and loads the entry as a module, and every import resolves to a served file. +func TestUIServesEveryModuleAndStylesheet(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir(), Version: "v1.4.0-rc.1"}) + index := do(t, h, authed("/")) + want := map[string]bool{} + for _, path := range append(append([]string{}, servedModules...), servedStylesheets...) { + want[path] = true + contentType := "text/javascript; charset=utf-8" + if strings.HasSuffix(path, ".css") { + contentType = "text/css; charset=utf-8" + } + w := do(t, h, authed(path)) + if w.Code != http.StatusOK || w.Header().Get("Content-Type") != contentType || w.Header().Get("Cache-Control") != "no-store" || w.Body.Len() == 0 { + t.Errorf("%s: %d %q %q %d bytes", path, w.Code, w.Header().Get("Content-Type"), w.Header().Get("Cache-Control"), w.Body.Len()) + } + if strings.Contains(w.Body.String(), assetVersionMark) { + t.Errorf("%s leaks the version placeholder", path) + } + for _, name := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Cross-Origin-Opener-Policy", "Cross-Origin-Resource-Policy"} { + if got := w.Header().Get(name); got == "" || got != index.Header().Get(name) { + t.Errorf("%s %s %q, the index sends %q", path, name, got, index.Header().Get(name)) + } + } + if w := do(t, h, request{path: path}); w.Code != http.StatusForbidden || w.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s without the cookie: %d %q", path, w.Code, w.Header().Get("Cache-Control")) + } + post := authed(path) + post.method, post.origin = http.MethodPost, "http://"+testHost + if w := do(t, h, post); w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodGet { + t.Errorf("POST %s: %d", path, w.Code) + } + // Each file is listed explicitly, by its own path under web/. + if staticFiles[path] != strings.TrimPrefix(path, "/") { + t.Errorf("%s is served from %q", path, staticFiles[path]) + } + } + // The allowlist holds no other script or stylesheet, and no /style.css. + for path := range staticFiles { + if (strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css")) && path != "/session.js" && !want[path] { + t.Errorf("staticFiles serves the unlisted %s", path) + } + } + + page := index.Body.String() + var links []string + for _, m := range regexp.MustCompile(``).FindAllStringSubmatch(page, -1) { + links = append(links, m[1]) + } + if strings.Join(links, " ") != strings.Join(servedStylesheets, " ") || strings.Count(page, "`) { + t.Error("index.html does not load the entry as its one module script") + } + + // Every import resolves, from the importing file's directory, to a served + // module, and every served module is imported. + imported := map[string]bool{"/app.js": true} + for _, path := range servedModules { + body := do(t, h, authed(path)).Body.String() + dir := path[:strings.LastIndex(path, "/")+1] + for _, m := range importRe.FindAllStringSubmatch(body, -1) { + target := dir + strings.TrimPrefix(m[1], "./") + if !strings.HasPrefix(m[1], "./") || !want[target] { + t.Errorf("%s imports %q, which is not a served module", path, m[1]) + } + imported[target] = true + } + if n := strings.Count("\n"+body, "\nimport "); n != len(importRe.FindAllString(body, -1)) { + t.Errorf("%s has %d import statements the check does not read", path, n) + } + } + for _, path := range servedModules { + if !imported[path] { + t.Errorf("no module imports %s", path) + } + } +} + +// Any path not named in the allowlist is not found and never cached: the +// removed /style.css, unlisted or directory paths under /js/ and /css/, +// traversal, a trailing slash and the wrong case. +func TestUIUnlistedStaticPathsAreNotFound(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + for _, path := range []string{ + "/style.css", "/js/nope.js", "/css/nope.css", "/js/", "/js", "/css/", "/css", + "/css/../app.js", "/js/../app.js", "/js/%2e%2e/app.js", "/css/%2e%2e/css/base.css", "/js/core", "/js/core.js/", "/css/base.css/", + "/js/CORE.js", "/CSS/base.css", "/web/js/core.js", "/web/css/base.css", "/js/style.css", "/css/core.js", "/js/core.js.map", + } { + if w := do(t, h, authed(path)); w.Code != http.StatusNotFound || w.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s: %d %q", path, w.Code, w.Header().Get("Cache-Control")) + } + } +} + +// Each view module exports mount(root, ctx) and update(state) and imports only +// the core and its own sub-modules, never another view; only the entry imports +// the views. No module or stylesheet exceeds the line bound. +func TestUIViewModulesImportOnlyTheCore(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + views := map[string]bool{} + for _, v := range viewModules { + views[v] = true + } + for _, path := range servedModules { + body := do(t, h, authed(path)).Body.String() + name := strings.TrimSuffix(path[strings.LastIndex(path, "/")+1:], ".js") + owner, _, _ := strings.Cut(name, "-") + // Only the static imports read below load code: no dynamic import( + // and no "export … from" re-export can reach around the check. + if dynamicImportRe.MatchString(body) { + t.Errorf("%s uses a dynamic import(", path) + } + if reExportRe.MatchString(body) { + t.Errorf("%s re-exports with export … from", path) + } + for _, m := range importRe.FindAllStringSubmatch(body, -1) { + target := strings.TrimSuffix(strings.TrimPrefix(m[1], "./"), ".js") + if path == "/app.js" { + continue + } + targetOwner, _, _ := strings.Cut(target, "-") + if target != "core" && !strings.HasPrefix(target, "core-") && targetOwner != owner { + t.Errorf("%s imports %s: a view imports only the core and its own sub-modules", path, m[1]) + } + if views[target] && target != name && owner != target { + t.Errorf("%s imports the view %s", path, target) + } + } + if views[name] { + if !strings.Contains(body, "\nexport function mount(root, ctx) {") || !strings.Contains(body, "\nexport function update(state) {") { + t.Errorf("%s does not export mount(root, ctx) and update(state)", path) + } + } + } + // Sub-modules of a view are imported only by that view. + entry := do(t, h, authed("/app.js")).Body.String() + for _, v := range viewModules { + if !strings.Contains(entry, "import * as "+v+" from './js/"+v+".js';") { + t.Errorf("the entry does not import the view %s", v) + } + } + if strings.Contains(entry, "molecule-") { + t.Error("the entry imports a view's sub-module") + } + // The two patterns catch every form they guard against, and no plain code. + for _, bad := range []string{"import('./inbox.js')", "await import (\n'./js/work.js')", "x = import(`./${v}.js`);"} { + if !dynamicImportRe.MatchString(bad) { + t.Errorf("dynamicImportRe misses %q", bad) + } + } + for _, bad := range []string{"export * from './inbox.js';", "export * as inbox from './inbox.js';", "export { renderInbox } from './inbox.js';", "export {\n a,\n b,\n} from \"./work.js\";", "export{a}from'./loop.js'"} { + if !reExportRe.MatchString(bad) { + t.Errorf("reExportRe misses %q", bad) + } + } + for _, good := range []string{"import { el } from './core.js';", "export function mount(root, ctx) {", "export const mv = {};", "const from = 'x'; // imported"} { + if dynamicImportRe.MatchString(good) || reExportRe.MatchString(good) { + t.Errorf("the import-form check rejects plain code %q", good) + } + } + for _, path := range append(append([]string{}, servedModules...), servedStylesheets...) { + data, err := web.ReadFile("web" + path) + if err != nil { + t.Fatalf("embedded %s: %v", path, err) + } + if n := strings.Count(string(data), "\n"); n > maxModuleLines { + t.Errorf("%s has %d lines, more than %d", path, n, maxModuleLines) + } + } +} + +// The concatenations follow the fixed orders, so a contract test sees every +// module and every sheet. +func TestUIServedJSAndCSSConcatenateEveryFile(t *testing.T) { + js, css := servedJS(t), servedCSS(t) + for _, want := range []string{"export function el(", "export async function submitGate(", "function renderInbox(", "export function ensureMoleculeView(", "export function renderPlan(", "export function renderTimeline(", "function renderGrid(", "function renderOverview(", "function renderFormula(", "function renderLoopPill(", "function renderShell("} { + if !strings.Contains(js, want) { + t.Errorf("servedJS lacks %q", want) + } + } + if strings.Index(js, "async function loadLab(") > strings.Index(js, "export const $ =") { + t.Error("servedJS does not start with the entry") + } + for _, want := range []string{":root {", "* { box-sizing: border-box; }", ".inbox-card {", ".mv-layout {", ".lane-bar {", ".placeholder {", ".review-row {", ".active-formula {", ".drive-form {"} { + if !strings.Contains(css, want) { + t.Errorf("servedCSS lacks %q", want) + } + } + if !(strings.Index(css, ":root {") < strings.Index(css, "* { box-sizing") && strings.Index(css, "* { box-sizing") < strings.Index(css, ".inbox {")) { + t.Error("servedCSS is not in link order") + } + // Each view keeps its own part of a shared media query; mediaBlocks finds + // them all. + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) + for _, selector := range []string{".glass, .card", ".inbox-row, .inbox-recent, .inbox-icon, .inbox-badge", ".atom-tab, .atom-detail", ".cube-button, .micro-cube", ".review-row", ".active-formula", ".drive-panel"} { + wantDecls(t, forced, selector, "background: Canvas;", "border: 1px solid CanvasText;") + } +} + +// In forced-colors mode the shelf's gradient gives way to Canvas: base.css's +// ".glass, .card" Canvas rule now loads before work.css, so the shelf needs +// its own forced-colors rule after its gradient, or the gradient wins. +func TestUIServedCSSShelfIsCanvasInForcedColors(t *testing.T) { + css := servedCSS(t) + gradient := strings.Index(css, "\n.shelf {") + if gradient < 0 { + t.Fatal("the served CSS has no top-level .shelf rule") + } + if rule, _, _ := strings.Cut(css[gradient+1:], "\n"); !strings.Contains(rule, "gradient(") { + t.Fatalf("the top-level .shelf rule has no gradient: %s", rule) + } + head := "@media (forced-colors: active) {" + found := false + for at := 0; ; { + i := strings.Index(css[at:], head) + if i < 0 { + break + } + start := at + i + len(head) + end := strings.Index(css[start:], "\n}\n") + if end < 0 { + t.Fatal("a forced-colors block is not closed") + } + block := css[start : start+end] + if decls, ok := cssRules(block)[".shelf"]; ok { + if start < gradient { + t.Error("a forced-colors .shelf rule loads before the .shelf gradient, which overrides it") + } else if strings.Contains(decls, "background: Canvas;") { + found = true + } + } + at = start + end + } + if !found { + t.Error("no forced-colors block after the .shelf gradient sets .shelf { background: Canvas; }") + } +} diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 3dbd4ce..8fb5d5b 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -197,10 +197,35 @@ type handler struct { } // staticFiles are the only embedded files served, with their content types. +// Every script module and stylesheet is named here: there is no wildcard or +// prefix match, so any other /js/ or /css/ path is not found. var staticFiles = map[string]string{ - "/": "index.html", - "/app.js": "app.js", - "/style.css": "style.css", + "/": "index.html", + "/app.js": "app.js", + // The entry's modules: the shared core and one module per view, with the + // Molecule view's sub-modules. + "/js/core.js": "js/core.js", + "/js/core-gates.js": "js/core-gates.js", + "/js/inbox.js": "js/inbox.js", + "/js/molecule.js": "js/molecule.js", + "/js/molecule-plan.js": "js/molecule-plan.js", + "/js/molecule-decision.js": "js/molecule-decision.js", + "/js/molecule-timeline.js": "js/molecule-timeline.js", + "/js/work.js": "js/work.js", + "/js/issues.js": "js/issues.js", + "/js/insights.js": "js/insights.js", + "/js/formula.js": "js/formula.js", + "/js/loop.js": "js/loop.js", + // The stylesheets index.html links, in its order: tokens, base, then views. + "/css/tokens.css": "css/tokens.css", + "/css/base.css": "css/base.css", + "/css/inbox.css": "css/inbox.css", + "/css/molecule.css": "css/molecule.css", + "/css/work.css": "css/work.css", + "/css/issues.css": "css/issues.css", + "/css/insights.css": "css/insights.css", + "/css/formula.css": "css/formula.css", + "/css/loop.css": "css/loop.css", // session.js only copies the exchange page's value into sessionStorage; it // holds no secret, so it is served without the cookie. "/session.js": "session.js", @@ -217,8 +242,9 @@ var staticFiles = map[string]string{ // change within a release; every other response stays no-store. const assetCache = "private, max-age=31536000, immutable" -// assetVersionMark is the placeholder index.html, app.js and style.css put in -// every asset URL's ?v= query; file replaces it with assetVersion, so an +// assetVersionMark is the placeholder index.html, the scripts and the +// stylesheets (css/tokens.css and css/base.css today) put in every asset URL's +// ?v= query; file replaces it with assetVersion, so an // upgrade changes each asset URL and the immutable cache never serves stale art // or fonts. const assetVersionMark = "__ASSET_VERSION__" diff --git a/lab/internal/ui/server_test.go b/lab/internal/ui/server_test.go index 3ff1378..048b913 100644 --- a/lab/internal/ui/server_test.go +++ b/lab/internal/ui/server_test.go @@ -144,7 +144,7 @@ func TestUITokenExchangeSetsStrictHttpOnlyCookie(t *testing.T) { if w := do(t, h, nav); w.Code != http.StatusForbidden { t.Fatalf("cross-site navigation to the API: %d", w.Code) } - if w := do(t, h, authed("/")); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "") { + if w := do(t, h, authed("/")); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "") { t.Fatalf("index with cookie: %d", w.Code) } } @@ -184,7 +184,7 @@ func TestUIAPIRequiresTheTabSessionHeader(t *testing.T) { func TestUIRefusesRequestsWithoutTheCookie(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - for _, path := range []string{"/", "/app.js", "/api/lab", "/api/molecules", "/api/molecules/m1"} { + for _, path := range []string{"/", "/app.js", "/js/core.js", "/css/base.css", "/api/lab", "/api/molecules", "/api/molecules/m1"} { if w := do(t, h, request{path: path}); w.Code != http.StatusForbidden { t.Errorf("%s without cookie: %d", path, w.Code) } @@ -634,7 +634,7 @@ func TestUINeverWritesTheLab(t *testing.T) { h := testHandler(t, Options{LabDir: lab, SourceRoot: "/src/checkout"}) engine := refuseEngine(t, h) drive := installDrive(h, &fakeDrive{}) - for _, path := range []string{"/", "/app.js", "/style.css", "/api/lab", "/api/loop", "/api/runs/7", "/api/molecules", "/api/molecules/" + r.ID, "/api/molecules/" + r.ID + "?yaml=1", "/api/molecules/" + r.ID + "/job", "/api/runs", "/api/loop/start", "/api/loop/stop", "/api/molecules/" + r.ID + "/stop"} { + for _, path := range []string{"/", "/app.js", "/js/core.js", "/css/base.css", "/style.css", "/api/lab", "/api/loop", "/api/runs/7", "/api/molecules", "/api/molecules/" + r.ID, "/api/molecules/" + r.ID + "?yaml=1", "/api/molecules/" + r.ID + "/job", "/api/runs", "/api/loop/start", "/api/loop/stop", "/api/molecules/" + r.ID + "/stop"} { do(t, h, authed(path)) post := authed(path) post.method, post.origin = http.MethodPost, "http://"+testHost @@ -667,23 +667,25 @@ func TestUINeverWritesTheLab(t *testing.T) { // page has no inline script, style or handler for data to reach. func TestUIServedJSNeverAssignsDataToInnerHTML(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - js := do(t, h, authed("/app.js")) - if js.Code != http.StatusOK || !strings.HasPrefix(js.Header().Get("Content-Type"), "text/javascript") { - t.Fatalf("app.js: %d %v", js.Code, js.Header()) - } - for _, sink := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser", "createContextualFragment", "eval(", "new Function", "srcdoc", "javascript:"} { - if strings.Contains(js.Body.String(), sink) { - t.Errorf("app.js uses %s", sink) + res := do(t, h, authed("/app.js")) + if res.Code != http.StatusOK || !strings.HasPrefix(res.Header().Get("Content-Type"), "text/javascript") { + t.Fatalf("app.js: %d %v", res.Code, res.Header()) + } + // Every module, so no sink hides in a file the entry imports. + js := servedJS(t) + for _, sink := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser", "createContextualFragment", "eval(", "new Function", "srcdoc", "javascript:", "import("} { + if strings.Contains(js, sink) { + t.Errorf("the served JS uses %s", sink) } } - if !strings.Contains(js.Body.String(), "document.createTextNode") || !strings.Contains(js.Body.String(), "textContent") { - t.Fatal("app.js does not render text through text nodes") + if !strings.Contains(js, "document.createTextNode") || !strings.Contains(js, "textContent") { + t.Fatal("the served JS does not render text through text nodes") } page := do(t, h, authed("/")).Body.String() if regexp.MustCompile(`]*)?>[^<]+| + diff --git a/lab/internal/ui/web/js/core-gates.js b/lab/internal/ui/web/js/core-gates.js new file mode 100644 index 0000000..2ebf00b --- /dev/null +++ b/lab/internal/ui/web/js/core-gates.js @@ -0,0 +1,117 @@ +// ---- Human gates --------------------------------------------------------------- +// Each gate posts the exact digest this page showed. One request is in flight +// at a time and the controls are disabled meanwhile. After an ambiguous +// response (no response, or a 5xx) the page reads the canonical state back +// and never resubmits. A decision shows as committed only when the server's +// read-back of the ledger or the receipt says so, never because a job ended. +import { el, state, ctx, notRecorded, SessionError, tabSession, api, failed, keepFocus } from './core.js'; + +export async function gatePost(id, action, body) { + const session = tabSession(); + if (!session || state.sessionLost) throw new SessionError('no session'); + const res = await fetch('/api/molecules/' + encodeURIComponent(id) + action, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'X-Forgecell-Session': session, 'Content-Type': 'application/json' }, body: body }); + if (res.status === 403) throw new SessionError('forbidden'); + return res; +} + +// reasonText is the server's bounded, redacted next step for a refusal, or ''. +export function reasonText(body) { + return body && typeof body.reason === 'string' && body.reason ? ' ' + body.reason + '.' : ''; +} + +export function refusalText(body) { + const category = body && typeof body.category === 'string' ? body.category : 'refused'; + return 'Refused (' + category + '): ' + notRecorded(body && body.error) + '.' + reasonText(body) + ' Nothing was resubmitted.'; +} + +export function rerenderGate(gate) { + if (state.gate === gate && state.detail && state.detail.id === gate.id) keepFocus(() => ctx.renderDecision(state.detail)); +} + +// GATE_FORMS names the form whose inline error shows each action's outcome; +// every other action reports in the decision card. +const GATE_FORMS = { '/amend': 'amend', '/delivery/publish': 'publish', '/stop': 'stop', '/retire': 'retire' }; + +// submitGate posts once. It returns the accepted body, or null after a refusal +// or an ambiguous response, which is read back instead of retried. Focus moves +// to the inline error of the action's form. +export async function submitGate(action, payload) { + if (state.gate.pending) return null; + const gate = state.gate; + gate.pending = true; + gate.where = GATE_FORMS[action] || 'decision'; + gate.message = ''; + rerenderGate(gate); + let res = null, body = {}; + try { + res = await gatePost(gate.id, action, payload); + body = await res.json().catch(() => ({})); + } catch (error) { + if (error instanceof SessionError) { gate.pending = false; failed(error); rerenderGate(gate); return null; } + res = null; + } + gate.pending = false; + if (state.gate !== gate) return null; + if (!res || res.status >= 500) { + gate.message = 'The response was lost or ambiguous. The canonical state is read back; nothing is resubmitted.'; + gate.announce = true; + readBack(gate); + return null; + } + if (!res.ok && action === '/amend' && body && body.category === 'stale') { + // Someone recorded a newer plan after this one was shown: load it to review. + gate.message = 'A newer plan exists, so your changes were not recorded. The newer plan is loaded; review it before requesting changes again.'; + gate.announce = true; + readBack(gate); + return null; + } + if (!res.ok) { gate.message = refusalText(body); gate.announce = true; rerenderGate(gate); return null; } + return body; +} + +// gateMessage shows a check this page makes before anything is sent. +export function gateMessage(where, text) { + const gate = state.gate; + gate.where = where; + gate.message = text; + gate.announce = true; + rerenderGate(gate); +} + +// fetchJob reads the job and its canonical read-back; while the job runs it +// reads again, one timer at a time. +export async function fetchJob(gate) { + clearTimeout(gate.timer); + try { + const res = await api('/api/molecules/' + encodeURIComponent(gate.id) + '/job'); + const job = res.ok ? await res.json() : null; + if (state.gate !== gate) return; + // A Molecule with no job from this Lab UI answers 200 with state none. + gate.job = job && job.state !== 'none' ? job : null; + if (job && job.state === 'running') gate.timer = setTimeout(() => readBack(gate), 2000); + } catch (error) { failed(error); } + rerenderGate(gate); +} + +export function readBack(gate) { + if (state.gate !== gate) return; + fetchJob(gate); + ctx.loadDetail(gate.id, false); +} + +export function jobText(job) { + if (!job) return null; + const c = job.canonical || {}; + const kind = job.kind === 'scope' ? 'Scope approval' : 'Draft publication'; + let status; + if (c.unreadable) status = 'The ' + (c.source || 'record') + ' could not be read back; nothing is shown as committed.'; + else if (job.committed && job.kind === 'scope') status = 'Committed: the ledger records your approval of this plan. Coding starts on its own.'; + else if (job.committed) status = 'Committed: receipt ' + notRecorded(c.receipt) + ', lifecycle ' + notRecorded(c.lifecycle) + (c.published ? '. Draft published; merge and production are not verified.' : '. Not published as a draft yet.'); + else if (job.state === 'running') status = 'Running. Not committed until the ' + (job.kind === 'scope' ? 'ledger records the approval.' : 'receipt records it.'); + else status = 'Not committed: the ' + (job.kind === 'scope' ? 'ledger does not record this approval' : 'receipt does not record a committed delivery') + (c.receipt ? ' (receipt ' + c.receipt + (c.lifecycle ? ', lifecycle ' + c.lifecycle : '') + ')' : '') + '.'; + // Not a live region itself: the view's one live region, #mv-live, carries it. + return el('div', { class: 'gate-job' }, + el('strong', null, kind + ' · ' + notRecorded(job.state)), + el('p', { class: 'scope-note' }, status), + job.error ? el('p', { class: 'scope-note' }, (job.category ? job.category + ': ' : '') + job.error + '.' + reasonText(job)) : null); +} diff --git a/lab/internal/ui/web/js/core.js b/lab/internal/ui/web/js/core.js new file mode 100644 index 0000000..62f46ef --- /dev/null +++ b/lab/internal/ui/web/js/core.js @@ -0,0 +1,284 @@ +// Forgecell local Lab UI: the core every view imports. It holds the page state, +// the router and URL state, the API session, the DOM patch helpers, the Intl +// formatters and the text dialog. Every value from the API is untrusted and +// reaches the page only as a text node or a validated attribute: no module +// parses data as markup. + +export const $ = (selector) => document.querySelector(selector); + +// el builds an element. Strings among children become text nodes; props are +// fixed by the calling module, never taken from data. +export function el(tag, props, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props || {})) { + if (value === undefined || value === null || value === false) continue; + if (key === 'class') node.className = value; + else if (key === 'text') node.textContent = String(value); + else if (key === 'dataset') Object.assign(node.dataset, value); + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); + else node.setAttribute(key, value === true ? '' : String(value)); + } + return add(node, ...children); +} + +// add appends children to node: nodes as they are, strings as text nodes, and +// null, undefined and false not at all, so no child ever renders as "null". +export function add(node, ...children) { + for (const child of children.flat()) { + if (child === undefined || child === null || child === false) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +// fill replaces node's children, skipping null children as add does. Every +// render goes through fill, never through replaceChildren with arguments. +export function fill(node, ...children) { + node.replaceChildren(); + return add(node, ...children); +} + +export const ISSUE_URL = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[1-9][0-9]*$/; +export const PULL_URL = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9][0-9]*$/; +export const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +export function externalLink(url, pattern, label) { + if (typeof url !== 'string' || !pattern.test(url)) return null; + return el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label); +} + +// One lane per Molecule, assigned by the server; Paused has no engine semantics. +export const LANES = { + running: { label: 'Running', short: 'running', symbol: '', cls: 'working' }, + decision: { label: 'Needs my decision', short: 'need you', symbol: '◇', cls: 'decision' }, + blocked: { label: 'Blocked', short: 'blocked', symbol: '!', cls: 'failed' }, + finished: { label: 'Draft published', short: 'draft published', symbol: '✓', cls: 'finished' }, + other: { label: 'Other', short: 'other', symbol: '·', cls: 'other' }, +}; +// Retired is finished history: its own lane and filter, never part of All, +// Needs my decision or the headline counts, but shown in analytics. +export const RETIRED = 'retired'; +export const ALL_LANES = Object.assign({}, LANES, { retired: { label: 'Retired', short: 'retired', symbol: '–', cls: 'other' } }); + +export const state = { lab: null, list: null, etag: null, analytics: null, analyticsEtag: null, analyticsToken: 0, filter: 'all', query: '', selectedId: null, detail: null, detailText: null, atomIndex: null, detailToken: 0, labToken: 0, pollToken: 0, polling: false, noteKey: null, sessionLost: false, gate: newGate(null) }; + +// gate is the selected Molecule's gate state: one request in flight at most, +// the last preview shown, and the last job read back from the server. +export function newGate(id) { return { id: id, pending: false, preview: null, job: null, stop: null, retire: null, amend: null, reason: '', message: '', where: 'decision', announce: false, title: '', accept: false, timer: null }; } + +// ctx is the one bridge between modules: each view's mount registers the calls +// another module makes into it (openMolecule, hideTip, renderAnalytics, …), so +// no view imports another. The entry passes this same object to every mount. +export const ctx = {}; + +export function notRecorded(value) { return value === undefined || value === null || value === '' ? 'Not recorded' : String(value); } +export function short(digest) { return typeof digest === 'string' && digest.length > 12 ? digest.slice(0, 12) + '…' : notRecorded(digest); } +export function durationLabel(ms) { + const total = Math.round(ms / 1000), h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60; + return [h ? h + 'h' : '', m ? m + 'm' : '', s || !total ? s + 's' : ''].filter(Boolean).join(' '); +} +export function elapsed(start, end) { + const a = Date.parse(start), b = Date.parse(end); + return Number.isFinite(a) && Number.isFinite(b) && b >= a ? b - a : null; +} + +// ---- API ---------------------------------------------------------------- +// Every API request carries this tab's session value, kept in sessionStorage +// by session.js at launch; the cookie alone is not enough. Without it, or once +// the server refuses it, the page stops asking instead of looping. +export class SessionError extends Error {} +export function tabSession() { + try { return sessionStorage.getItem('forgecell-session') || ''; } catch (error) { return ''; } +} +export async function api(path, headers) { + const session = tabSession(); + if (!session || state.sessionLost) throw new SessionError('no session'); + const res = await fetch(path, { credentials: 'same-origin', cache: 'no-store', headers: Object.assign({ 'X-Forgecell-Session': session }, headers || {}) }); + if (res.status === 403) throw new SessionError('forbidden'); + return res; +} +export function showError(message) { + const banner = $('#load-error'); + banner.textContent = message; + banner.hidden = !message; +} +export function failed(error) { + if (error instanceof SessionError) state.sessionLost = true; + showError(error instanceof SessionError + ? 'This tab has no Lab UI session. Each launch link works once: run forgecell ui again (or restart it) and open the new link it prints.' + : 'The Lab could not be read. The page keeps the last data it loaded and will retry.'); +} + +// ---- Focus across re-renders ---------------------------------------------- +// Re-rendered controls are found again by these data attributes, or else by +// their id, so keyboard focus survives a poll that rebuilds them. +const FOCUS_KEYS = ['id', 'filter', 'atom', 'origin', 'review', 'evidence', 'gate', 'drive']; +export function keepFocus(render) { + const active = document.activeElement; + const key = active && active.dataset ? FOCUS_KEYS.find((k) => active.dataset[k] !== undefined) : undefined; + const value = key && active.dataset[key]; + const id = active && active.id; + render(); + if ((!key && !id) || document.contains(active)) return; + const next = key + ? [...document.querySelectorAll('[data-' + key + ']')].find((node) => node.dataset[key] === value && !node.hidden && !node.disabled) + : document.getElementById(id); + if (!next) return; + if (key === 'id') { cells().forEach((b) => { b.tabIndex = -1; }); next.tabIndex = 0; } + next.focus({ preventScroll: true }); +} + +// cells are the Work grid's Molecule cells. +export function cells() { return [...document.querySelectorAll('#cubes [data-id]')]; } + +// ---- Molecules in the list ---------------------------------------------------- +export function molecules() { return (state.list && state.list.molecules) || []; } +export function laneOf(m) { return ALL_LANES[m.lane] || LANES.other; } +export function live(m) { return m.lane !== RETIRED; } +// A running Molecule waiting for the Lab's verification slot reads as queued, +// not running. Only the one known activity is shown; any other value is ignored. +const QUEUED = 'queued for checks'; +function queued(m) { return m.lane === 'running' && m.activity === QUEUED; } +export function laneLabel(m) { return queued(m) ? 'Queued for checks' : laneOf(m).label; } + +const NEEDS_KINDS = ['scope', 'publish', 'blocked']; +export function inboxOf(m) { + const v = m && m.inbox; + return v && typeof v === 'object' && typeof v.kind === 'string' && v.kind && MOLECULE_ID.test(m.id) ? v : null; +} +function timeOf(m) { const t = Date.parse(m.finishedAt || m.startedAt); return Number.isFinite(t) ? t : 0; } +export function newestFirst(a, b) { return timeOf(b) - timeOf(a) || (a.id < b.id ? -1 : 1); } +// needsYou is the one definition of what needs you: the Molecules shown under +// Needs you (scope gate, ready to publish, blocked), newest first. The headline, +// the live region, the nav badge and the resident all count it. +export function needsYou() { + return molecules().filter((m) => { const v = live(m) && inboxOf(m); return Boolean(v) && NEEDS_KINDS.includes(v.kind); }).sort(newestFirst); +} + +// ---- Router: views and the selected Molecule in the URL ------------------------ +// The view lives in the URL (?view=…), set through history.pushState by real +// links, so Back, reload and copied links restore it. +export const VIEWS = ['inbox', 'issues', 'work', 'insights', 'formula']; +export function viewFromURL() { + const view = new URLSearchParams(location.search).get('view'); + // A bare ?m= link opens the Molecule view under Work. + if (!view && moleculeFromURL()) return 'work'; + return VIEWS.includes(view) ? view : 'inbox'; +} +export function showView(view) { + state.view = view; + document.querySelectorAll('[data-view-panel]').forEach((panel) => { panel.hidden = panel.dataset.viewPanel !== view; }); + document.querySelectorAll('a[data-view]').forEach((link) => { + if (link.dataset.view === view && !link.closest('main')) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); + const more = $('#tab-more'); + more.classList.toggle('is-current', view === 'insights' || view === 'formula'); + more.open = false; + ctx.hideTip(); +} +export function navigate(view, focus) { + // Leaving a Molecule view, even for Work, is a new entry, so Back reopens it. + if (moleculeFromURL()) history.pushState({ view: view }, '', '?view=' + view); + else if (view !== viewFromURL()) history.pushState({ view: view }, '', '?view=' + view); + showView(view); + ctx.restoreSelection(); + if (focus) $('#main').focus({ preventScroll: true }); +} + +// A Molecule link (?view=work&m=…) is a real link: a plain click opens the +// Molecule under Work through history.pushState, so Back returns here. +export const MOLECULE_ID = /^[A-Za-z0-9_-]{1,200}$/; +export function moleculeHref(id) { return '?view=work&m=' + encodeURIComponent(id); } +export function moleculeFromURL() { + const id = new URLSearchParams(location.search).get('m'); + return id && MOLECULE_ID.test(id) ? id : null; +} + +// ---- Readable values and in-place patches ------------------------------------- +// Counts read as words through Intl.PluralRules. +export const plural = new Intl.PluralRules('en-US'); +export function needsYouText(n) { return n + (plural.select(n) === 'one' ? ' thing needs you' : ' things need you'); } +// setText changes a node's text only when it differs, so a poll never +// re-announces or disturbs what is already shown. +export function setText(node, text) { if (node.textContent !== text) node.textContent = text; } +export function setAttr(node, name, value) { if (node.getAttribute(name) !== value) node.setAttribute(name, value); } +export function plainText(value, fallback) { return typeof value === 'string' && value ? value : fallback; } +// count is a served count, or 0 when it was omitted as zero; callers show it +// only when its recorded flag (planned, verified, captured) is set. +export function count(n) { return Number.isInteger(n) && n >= 0 ? n : 0; } +export function counted(n, one, many) { return n + ' ' + (plural.select(n) === 'one' ? one : many); } + +const timeOnly = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }); +const dayOnly = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }); +export const wholeNumber = new Intl.NumberFormat('en-US'); +// One money rule: two decimals, and an amount under a cent says so instead of +// rounding to $0.00. +const dollars = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); +export function money(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''; + return value > 0 && value < 0.01 ? 'under $0.01' : dollars.format(value); +} + +// whenText is a recorded time in words: "today at 6:46 PM", "on Oct 24, 2026 +// at 9:00 AM"; dateTitle gives the full date for a tooltip. +export function whenText(iso) { + const t = Date.parse(iso || ''); + if (!Number.isFinite(t)) return ''; + const day = (ms) => new Date(ms).toDateString(); + const time = timeOnly.format(t); + if (day(t) === day(Date.now())) return 'today at ' + time; + if (day(t) === day(Date.now() - 86400000)) return 'yesterday at ' + time; + return 'on ' + dayOnly.format(t) + ' at ' + time; +} + +const relativeTime = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }); +const fullDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }); +// ago is a recorded time as relative words ("12 minutes ago"); dateTitle is +// the same time as a full local date, for a tooltip. +export function ago(iso) { + const t = Date.parse(iso || ''); + if (!Number.isFinite(t)) return ''; + const s = Math.round((t - Date.now()) / 1000), abs = Math.abs(s); + if (abs < 60) return 'just now'; + if (abs < 3600) return relativeTime.format(Math.round(s / 60), 'minute'); + if (abs < 86400) return relativeTime.format(Math.round(s / 3600), 'hour'); + return relativeTime.format(Math.round(s / 86400), 'day'); +} +export function dateTitle(iso) { const t = Date.parse(iso || ''); return Number.isFinite(t) ? fullDate.format(t) : ''; } +export function minutesText(ms) { + const min = Math.floor(ms / 60000); + if (min < 1) return 'under a minute'; + if (min < 60) return min + ' min'; + return Math.floor(min / 60) + ' h' + (min % 60 ? ' ' + (min % 60) + ' min' : ''); +} + +// The seven steps of a Molecule, shown in the Inbox rows and the Molecule view. +export const STEPS = ['Plan', 'Your approval', 'Coding', 'Checks', 'Review', 'Draft PR', 'Merged']; +// RERUN_NOTES are the fixed rerun notes the server sends; nothing else shows. +const RERUN_NOTES = ['rerun', 'rerun after a plan revision']; +// withAttempt appends the current attempt's elapsed minutes, when its start is +// recorded, and its rerun note: "Checks · 10 min · rerun after a plan revision". +export function withAttempt(label, since, note) { + const parts = [label]; + const t = Date.parse(since || ''); + if (Number.isFinite(t)) parts.push(minutesText(Math.max(0, Date.now() - t))); + if (RERUN_NOTES.includes(note)) parts.push(note); + return parts.join(' · '); +} + +// ---- Dialog --------------------------------------------------------------------- +let dialogReturn = null; +export function show(title, body, note) { + dialogReturn = document.activeElement; + $('#dialog-title').textContent = title; + const noteBox = $('#dialog-note'); + noteBox.textContent = note || ''; + noteBox.hidden = !note; + $('#dialog-body').textContent = body; + $('#detail').showModal(); +} +export function mountDialog() { + $('#close-dialog').addEventListener('click', () => $('#detail').close()); + $('#detail').addEventListener('close', () => { if (dialogReturn && dialogReturn.focus) dialogReturn.focus({ preventScroll: true }); }); +} diff --git a/lab/internal/ui/web/js/formula.js b/lab/internal/ui/web/js/formula.js new file mode 100644 index 0000000..f594311 --- /dev/null +++ b/lab/internal/ui/web/js/formula.js @@ -0,0 +1,29 @@ +// ---- Formula: the active-Formula card ------------------------------------------- +// The Formula new Molecules use, and its approval state. Existing Molecules keep +// their saved snapshot, shown in each Molecule's Timeline. +import { $, el, fill, notRecorded, short } from './core.js'; + +function renderFormula(lab) { + const f = lab.formula || {}; + const states = { + approved: 'Approved for execution', + none: 'No active Formula', + 'activation-locked': 'Activation in progress or interrupted; execution refuses it until resumed', + unapproved: 'Not approved for execution: missing, stale or unfinished decision', + unreadable: 'Formula unreadable', + sample: 'Sample data · not a real Formula', + }; + const box = $('#active-formula'); + fill(box, + el('div', null, + el('span', { class: 'label', text: 'NEW MOLECULES · ACTIVE FORMULA' }), + el('strong', null, f.formulaId || 'None', f.sha256 ? el('span', { class: 'version-tag', title: f.sha256 }, 'sha256 ' + short(f.sha256)) : null), + el('small', null, (states[f.state] || notRecorded(f.state)) + (f.decisionId ? ' · decision ' + f.decisionId : '') + '. Applies to future Molecules; existing Molecules keep their saved snapshot.')), + el('div', { class: 'formula-actions' }, el('span', { class: 'unavailable-tag', title: 'Version history and rollback are not connected in this slice' }, 'Versions & history · Unavailable'))); +} + +// The Formula view mounts on its data-view-panel; its markup stays in index.html. +export function mount(root, ctx) {} + +// update renders the active-Formula card from the Lab. +export function update(state) { renderFormula(state.lab); } diff --git a/lab/internal/ui/web/js/inbox.js b/lab/internal/ui/web/js/inbox.js new file mode 100644 index 0000000..1a26c86 --- /dev/null +++ b/lab/internal/ui/web/js/inbox.js @@ -0,0 +1,189 @@ +// ---- Inbox -------------------------------------------------------------------- +// The home view: what needs you first, then work in progress, then the last +// drafts recorded. Every entry is the server's InboxView, in plain words, so +// no phase code, id, digest or timestamp is turned into text here. Polling +// patches entries in place, keyed by Molecule id: an entry already shown keeps +// its node and has only its changed text and attributes updated. +import { $, el, fill, state, molecules, live, inboxOf, newestFirst, needsYou, needsYouText, moleculeHref, setText, setAttr, plainText, count, counted, ago, dateTitle, STEPS, withAttempt } from './core.js'; + +const CARD_KINDS = { scope: ['is-needs', '◇'], publish: ['is-ready', '✓'], blocked: ['is-blocked', '!'] }; +// Bounds: at most this many cards or rows each, and the last five recorded. +const MAX_ENTRIES = 20, MAX_RECENT = 5; + +function numberText(m) { return Number.isInteger(m.number) ? '#' + m.number : ''; } +function titleText(m) { return (state.list && state.list.sample ? '[Sample] ' : '') + plainText(m.title, 'Untitled'); } + +function stepIndex(v) { return Number.isInteger(v.step) && v.step >= 0 && v.step < STEPS.length ? v.step : 0; } +// stepText is the current step with its elapsed time: "Coding · 18 min". +function stepText(v) { + if (v.queued) return 'Queued for checks'; + return withAttempt(plainText(v.stepLabel, STEPS[stepIndex(v)]), v.stepSince, v.stepNote); +} +function detailText(v) { + const parts = []; + if (v.kind === 'scope') { + if (typeof v.summary === 'string' && v.summary) parts.push(v.summary); + if (v.planned) parts.push(counted(count(v.files), 'file', 'files'), counted(count(v.checks), 'check', 'checks')); + } else if (v.kind === 'publish') { + if (v.verified) parts.push(count(v.passed) + ' of ' + counted(count(v.ran), 'check', 'checks') + ' passed'); + if (v.captured) parts.push(counted(count(v.changed), 'file', 'files') + ' changed'); + } else if (typeof v.summary === 'string' && v.summary) parts.push(v.summary); + return parts.join(' · '); +} +function metaText(m, v) { + const when = v.kind === 'scope' ? ago(m.startedAt) : ago(m.finishedAt || m.startedAt); + const verb = { scope: 'planned ', publish: 'finished ', blocked: 'stopped ' }[v.kind] || ''; + return [numberText(m), when ? verb + when : ''].filter(Boolean).join(' · '); +} + +function slot(node, name) { return node.querySelector('[data-slot="' + name + '"]'); } +// setLink points a Molecule link at its ?view=work&m= URL, labelled with its action. +function setLink(a, m, text) { + setAttr(a, 'href', moleculeHref(m.id)); + if (a.dataset.molecule !== m.id) a.dataset.molecule = m.id; + setText(a, text); + setAttr(a, 'aria-label', text + ': ' + [numberText(m), titleText(m)].filter(Boolean).join(' ')); +} + +// patchList keeps container's children in items' order, keyed by Molecule id: +// the node of an id already shown is kept and updated in place, a new id gets +// a new node, and the node of an id no longer listed is removed. +function patchList(container, items, create, update) { + const existing = new Map([...container.children].map((node) => [node.dataset.inboxId, node])); + let at = container.firstElementChild; + for (const m of items) { + let node = existing.get(m.id); + existing.delete(m.id); + if (!node) { node = create(); node.dataset.inboxId = m.id; } + update(node, m, inboxOf(m)); + if (node === at) at = at.nextElementSibling; + else container.insertBefore(node, at); + } + for (const node of existing.values()) { + if (node.contains(document.activeElement)) $('#main').focus({ preventScroll: true }); + node.remove(); + } +} + +function createCard() { + return el('article', { class: 'inbox-card glass' }, + el('div', { class: 'inbox-icon', 'aria-hidden': 'true' }, el('span', { dataset: { slot: 'icon' } })), + el('div', { class: 'inbox-body' }, + el('div', { class: 'inbox-head' }, el('span', { class: 'inbox-badge', dataset: { slot: 'badge' } }), el('span', { class: 'inbox-meta', dataset: { slot: 'meta' } })), + el('h3', { class: 'inbox-title', dataset: { slot: 'title' } }), + el('p', { class: 'inbox-detail', dataset: { slot: 'detail' } })), + el('a', { class: 'btn btn-primary inbox-action', dataset: { slot: 'action' } })); +} +function updateCard(node, m, v) { + const [cls, icon] = CARD_KINDS[v.kind]; + setAttr(node, 'class', 'inbox-card glass ' + cls); + setText(slot(node, 'icon'), icon); + setText(slot(node, 'badge'), plainText(v.title, 'Needs you')); + const meta = slot(node, 'meta'); + setText(meta, metaText(m, v)); + setAttr(meta, 'title', dateTitle(v.kind === 'scope' ? m.startedAt : m.finishedAt || m.startedAt)); + setText(slot(node, 'title'), titleText(m)); + const detail = slot(node, 'detail'), text = detailText(v); + setText(detail, text); + detail.hidden = !text; + // One primary action per card; each opens the Molecule view (?view=work&m=…). + setLink(slot(node, 'action'), m, plainText(v.action, 'Open')); +} + +function createRow() { + return el('article', { class: 'inbox-row' }, + el('div', { class: 'inbox-row-head' }, + el('span', { class: 'inbox-number', dataset: { slot: 'number' } }), + el('h3', { class: 'inbox-title', dataset: { slot: 'title' } }), + el('span', { class: 'inbox-step', dataset: { slot: 'step' } }), + el('a', { class: 'btn btn-ghost inbox-watch', dataset: { slot: 'action' } })), + el('ol', { class: 'inbox-steps', 'aria-label': 'Progress' }, STEPS.map((label) => el('li', null, el('span', { class: 'step-bar', 'aria-hidden': 'true' }), label)))); +} +function updateRow(node, m, v) { + setText(slot(node, 'number'), numberText(m)); + setText(slot(node, 'title'), titleText(m)); + setText(slot(node, 'step'), stepText(v)); + setLink(slot(node, 'action'), m, plainText(v.action, 'Watch')); + const at = stepIndex(v); + node.querySelectorAll('.inbox-steps li').forEach((li, i) => { + const cls = i < at ? 'is-done' : i === at ? 'is-current' : ''; + if (li.className !== cls) li.className = cls; + if (i === at) setAttr(li, 'aria-current', 'step'); + else if (li.hasAttribute('aria-current')) li.removeAttribute('aria-current'); + }); +} + +function createRecent() { + return el('a', { class: 'inbox-recent-row' }, + el('span', { class: 'recent-dot', 'aria-hidden': 'true' }), + el('span', { class: 'inbox-number', dataset: { slot: 'number' } }), + el('span', { class: 'recent-title', dataset: { slot: 'title' } }), + el('span', { class: 'inbox-meta', dataset: { slot: 'meta' } })); +} +function updateRecent(node, m, v) { + setAttr(node, 'href', moleculeHref(m.id)); + if (node.dataset.molecule !== m.id) node.dataset.molecule = m.id; + setText(slot(node, 'number'), numberText(m)); + setText(slot(node, 'title'), titleText(m)); + const when = ago(m.finishedAt || m.startedAt); + setText(slot(node, 'meta'), plainText(v.title, 'Draft published') + (when ? ' · ' + when : '')); + setAttr(node, 'title', dateTitle(m.finishedAt || m.startedAt)); +} + +function moreText(node, total, where) { + const extra = total - MAX_ENTRIES; + setText(node, extra > 0 ? counted(extra, 'more', 'more') + ' under ' + where + '.' : ''); + node.hidden = extra <= 0; +} + +function partOfDay(hour) { return hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening'; } +// firstName is the first word of a configured operator name, or '' for the +// fallback. +function firstName() { + const o = state.lab && state.lab.operator; + if (!o || !o.set || typeof o.name !== 'string') return ''; + const word = o.name.trim().split(/\s+/)[0] || ''; + return /\p{L}/u.test(word) && !word.includes('[') ? word : ''; +} + +function renderInbox() { + const counts = (state.list && state.list.counts) || {}; + const entries = molecules().filter((m) => live(m) && inboxOf(m)); + const kind = (k) => entries.filter((m) => inboxOf(m).kind === k); + const needs = needsYou(); + const running = kind('progress').sort(newestFirst); + const recent = kind('recorded').sort(newestFirst).slice(0, MAX_RECENT); + patchList($('#inbox-needs'), needs.slice(0, MAX_ENTRIES), createCard, updateCard); + patchList($('#inbox-progress'), running.slice(0, MAX_ENTRIES), createRow, updateRow); + patchList($('#inbox-recent'), recent, createRecent, updateRecent); + $('#inbox-needs-empty').hidden = needs.length > 0; + $('#inbox-progress-empty').hidden = running.length > 0; + $('#inbox-recent-empty').hidden = recent.length > 0; + moreText($('#inbox-needs-more'), needs.length, 'Work'); + moreText($('#inbox-progress-more'), running.length, 'Work, Running'); + + const name = firstName(); + setText($('#inbox-title'), 'Good ' + partOfDay(new Date().getHours()) + (name ? ', ' + name : '')); + const waiting = needs.length, inFlight = counts.running || 0, blocked = counts.blocked || 0, drafts = counts.finished || 0; + // Merged counts need verified merge attribution; until then drafts are counted. + const parts = [ + waiting ? needsYouText(waiting) : 'Nothing needs you', + inFlight ? inFlight + ' running' : 'nothing running', + blocked ? blocked + ' blocked' : 'nothing blocked', + drafts ? counted(drafts, 'draft published', 'drafts published') : 'no draft published yet', + ]; + const line = $('#inbox-counts'); + if (line.textContent !== parts.join(' · ')) fill(line, el('strong', null, parts[0]), ' · ' + parts.slice(1).join(' · ')); + // One persistent live region, changed only when its text changes. + setText($('#inbox-live'), waiting ? needsYouText(waiting) + '.' : 'Nothing needs you right now.'); + const note = $('#inbox-note'); + const complete = !state.list || state.list.complete; + setText(note, complete ? '' : 'Some ledgers could not be read, so this Inbox may be missing Molecules. See Work for the diagnostics.'); + note.hidden = complete; +} + +// The Inbox mounts on its data-view-panel; its markup stays in index.html. +export function mount(root, ctx) {} + +// update patches the Inbox from the list and the Lab in place. +export function update(state) { renderInbox(); } diff --git a/lab/internal/ui/web/js/insights.js b/lab/internal/ui/web/js/insights.js new file mode 100644 index 0000000..45ecb28 --- /dev/null +++ b/lab/internal/ui/web/js/insights.js @@ -0,0 +1,85 @@ +// ---- Insights: the Lab overview and analytics ---------------------------------- +// The overview counts the live Molecules by lane and lists the ones waiting at a +// human gate; analytics show reported time and cost with their coverage. +import { $, el, fill, add, state, ctx, LANES, ALL_LANES, RETIRED, notRecorded, durationLabel, molecules, live } from './core.js'; + +function renderOverview() { + const all = molecules().filter(live); + const counts = (state.list && state.list.counts) || {}; + const sample = Boolean(state.list && state.list.sample); + $('#aggregate .panel-head .mono').textContent = sample ? 'LAB OVERVIEW · SAMPLE DATA' : 'LAB OVERVIEW'; + $('#aggregate-count').textContent = 'All ' + all.length + ' Molecules'; + const waiting = all.filter((m) => m.lane === 'decision'); + fill($('#review-queue'), (waiting.length + ? waiting.slice(0, 5).map((m) => el('button', { type: 'button', class: 'review-row', dataset: { review: m.id }, onclick: () => ctx.openMolecule(m.id) }, + el('span', { class: 'review-id' }, '#' + m.number), el('strong', null, m.title || m.id), el('span', null, notRecorded(m.phase)), el('span', { 'aria-hidden': 'true' }, '→'))) + : [el('p', { class: 'scope-note' }, 'No Molecules wait at a human gate.')])); + if (waiting.length > 5) add($('#review-queue'), el('p', { class: 'scope-note' }, (waiting.length - 5) + ' more under Needs my decision.')); + fill($('#outcome-bars'), Object.entries(LANES).map(([id, lane]) => { + const n = counts[id] || 0; + const bar = el('span', { class: 'bar ' + lane.cls }); + bar.style.width = (all.length ? (n / all.length) * 100 : 0) + '%'; + return el('div', { class: 'outcome-row' }, el('span', null, lane.label), el('div', { class: 'bar-track' }, bar), el('strong', null, String(n))); + }), el('div', { class: 'outcome-row' }, el('span', null, 'Paused'), el('div', { class: 'bar-track' }), el('strong', { title: 'The engine has no pause semantics' }, 'n/a'))); + renderAnalytics(); +} + +// ---- Analytics ----------------------------------------------------------------- +// A null measure is shown as text (Not recorded, Unknown, Unavailable), never as +// 0, and every total carries its coverage. +// Six decimals, like the engine's formatUSD, so a small reported cost never reads as $0. +function usd(value) { return typeof value === 'number' && Number.isFinite(value) ? '$' + value.toFixed(6) : null; } +function countText(value) { return typeof value === 'number' ? String(value) : 'Not recorded'; } +function measureText(m) { + if (!m || typeof m.totalMs !== 'number') return m && m.overflow ? 'Unavailable (sum overflowed)' : 'Not recorded'; + return durationLabel(m.totalMs); +} +function measureCoverage(m) { + return m ? 'recorded for ' + countText(m.recorded) + ' of ' + countText(m.molecules) + ' Molecules' : 'coverage not recorded'; +} +function groupCost(g) { + const total = usd(g.costUsd); + const coverage = 'reported for ' + countText(g.costReported) + ' of ' + countText(g.attempts); + if (total) return total + ' (' + notRecorded(g.costBasis) + ') · ' + coverage; + return (g.costNotFinite ? 'Unavailable (sum not finite)' : 'Unknown') + ' · ' + coverage; +} + +function renderAnalytics() { + const box = $('#analytics'); + const a = state.analytics; + if (!a) { fill(box, el('span', { class: 'label' }, 'ANALYTICS'), el('strong', null, 'Loading…')); return; } + const t = a.time || {}, c = a.cost || {}, models = a.models || {}, counts = a.counts || {}; + const groups = Array.isArray(models.groups) ? models.groups : []; + const total = usd(c.totalUsd); + const measure = (label, m) => el('div', null, el('dt', null, label), el('dd', null, measureText(m) + ' · ' + measureCoverage(m))); + // fill skips an absent note instead of rendering it as text. + fill(box, + el('span', { class: 'label' }, a.sample ? 'ANALYTICS · SAMPLE DATA' : 'ANALYTICS · REPORTED COST'), + el('strong', null, total || (c.notFinite ? 'Unavailable (sum not finite)' : 'Unknown')), + el('p', null, notRecorded(c.coverage) + (total ? ' · ' + notRecorded(c.basisLabel) : '') + '. Unknown is not zero.'), + a.complete ? null : el('p', { class: 'scope-note' }, (a.truncated ? 'Inventory truncated by safety bounds' : 'Inventory incomplete') + '; skipped ledgers are not counted.'), + el('dl', { class: 'atom-meta' }, + el('div', null, el('dt', null, 'Outcomes by lane'), el('dd', null, Object.entries(ALL_LANES).map(([id, lane]) => lane.label + ' ' + countText(id === RETIRED ? counts[id] || 0 : counts[id])).join(' · '))), + measure('Wall-clock', t.wallClock), + measure('Active Atom time', t.activeAtom), + measure('Waiting time', t.waiting), + el('div', null, el('dt', null, 'Lab elapsed'), el('dd', null, typeof t.elapsedMs === 'number' ? durationLabel(t.elapsedMs) : 'Not recorded'))), + t.note ? el('p', { class: 'scope-note' }, t.note) : null, + el('span', { class: 'label' }, 'HARNESS / MODEL · ' + countText(models.attempts) + ' ATTEMPTS'), + groups.length + ? el('ul', { class: 'record-list' }, groups.map((g) => el('li', null, + el('strong', null, notRecorded(g.harness) + ' · ' + notRecorded(g.model)), + el('span', null, countText(g.attempts) + (g.attempts === 1 ? ' attempt' : ' attempts')), + el('span', null, 'cost ' + groupCost(g))))) + : el('p', { class: 'scope-note' }, 'No harness attempts recorded.'), + models.truncated ? el('p', { class: 'scope-note' }, countText(models.ungrouped) + ' attempts beyond the group bound are not shown; the breakdown is truncated.') : null); +} + +// Insights mounts on its data-view-panel; analytics arrive on their own, so the +// entry renders them through ctx.renderAnalytics. +export function mount(root, ctx) { + ctx.renderAnalytics = renderAnalytics; +} + +// update renders the Lab overview, analytics included, from the list. +export function update(state) { renderOverview(); } diff --git a/lab/internal/ui/web/js/issues.js b/lab/internal/ui/web/js/issues.js new file mode 100644 index 0000000..9669103 --- /dev/null +++ b/lab/internal/ui/web/js/issues.js @@ -0,0 +1,10 @@ +// ---- Issues: coming next --------------------------------------------------------- +// The Issues view is still its coming-next placeholder, whose markup stays in +// index.html; until it is built, intake starts from Start intake in the side +// panel. This module owns the view so the slice that builds it edits only here. + +// The Issues view mounts on its data-view-panel; the placeholder needs nothing. +export function mount(root, ctx) {} + +// update has nothing to patch while the view is a placeholder. +export function update(state) {} diff --git a/lab/internal/ui/web/js/loop.js b/lab/internal/ui/web/js/loop.js new file mode 100644 index 0000000..376a6aa --- /dev/null +++ b/lab/internal/ui/web/js/loop.js @@ -0,0 +1,296 @@ +// ---- Loop: start intake, start or stop the loop ---------------------------------- +// The side panel's Run the loop and Start intake forms, and the header's loop +// pill. The same contract as the gates: one request in flight, the controls +// disabled meanwhile, and after an ambiguous response (none, or a 5xx) the +// state is read back and never resubmitted. A run shows as started only once +// the server's ledger read-back finds its Molecule; the loop's report comes +// from the engine. +import { $, el, fill, state, ctx, notRecorded, SessionError, tabSession, api, failed, setText } from './core.js'; +import { reasonText, refusalText } from './core-gates.js'; + +// where names the form (run or loop) whose status shows the pending note and +// whose inline error shows the last message; it changes only what is displayed +// where. announce moves focus to that error once, when a message is set. +const drive = { panel: null, controls: null, loaded: false, pending: false, where: 'run', message: '', announce: false, mode: 'oldest', runIssue: null, run: null, loop: null, timer: null, token: 0 }; + +// driveMessage shows text as the inline error of the form drive.where names, +// and moves focus to it. +function driveMessage(text) { + drive.message = text; + drive.announce = Boolean(text); + renderDrive(); +} + +async function drivePost(path, body) { + const session = tabSession(); + if (!session || state.sessionLost) throw new SessionError('no session'); + const res = await fetch(path, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'X-Forgecell-Session': session, 'Content-Type': 'application/json' }, body: body }); + if (res.status === 403) throw new SessionError('forbidden'); + return res; +} + +async function submitDrive(path, payload) { + if (drive.pending) return null; + drive.pending = true; + drive.where = path === '/api/runs' ? 'run' : 'loop'; + drive.message = ''; + renderDrive(); + let res = null, body = {}; + try { + res = await drivePost(path, payload); + body = await res.json().catch(() => ({})); + } catch (error) { + if (error instanceof SessionError) { drive.pending = false; failed(error); renderDrive(); return null; } + res = null; + } + drive.pending = false; + if (!res || res.status >= 500) { + drive.message = 'The response was lost or ambiguous. The state is read back; nothing is resubmitted.'; + drive.announce = true; + readDrive(); + return null; + } + if (!res.ok) { driveMessage(refusalText(body)); return null; } + return body; +} + +// readDrive reads the loop and the tracked run back; while either runs it reads +// again, one timer at a time, and a response older than the newest is dropped. +async function readDrive() { + clearTimeout(drive.timer); + const token = ++drive.token; + let loop = drive.loop, run = drive.run; + try { + const loopRes = await api('/api/loop'); + loop = loopRes.ok ? await loopRes.json() : null; + if (drive.runIssue) { + const runRes = await api('/api/runs/' + drive.runIssue); + run = runRes.ok ? await runRes.json() : null; + } + } catch (error) { failed(error); } + if (token !== drive.token) return; + drive.loop = loop; + drive.run = run; + const busy = (loop && (loop.state === 'running' || loop.state === 'draining')) || (run && run.state === 'running'); + if (busy && !state.sessionLost) drive.timer = setTimeout(readDrive, 2000); + renderDrive(); +} + +async function startRun(text) { + const value = String(text).trim(); + if (!drive.pending) drive.where = 'run'; + if (!/^[1-9][0-9]{0,9}$/.test(value)) { driveMessage('Enter an Issue number, such as 245.'); return; } + drive.runIssue = Number(value); + drive.run = null; + const job = await submitDrive('/api/runs', JSON.stringify({ issue: drive.runIssue })); + if (job) { drive.run = job; readDrive(); } +} + +async function startLoop(select, max) { + const n = Number(max); + if (!drive.pending) drive.where = 'loop'; + if (!Number.isInteger(n) || n < 1) { driveMessage('Enter how many Molecules to advance at once.'); return; } + const loop = await submitDrive('/api/loop/start', JSON.stringify({ select: String(select).trim(), maxConcurrent: n })); + if (loop) { drive.loop = loop; readDrive(); } +} + +async function stopLoop() { + const loop = await submitDrive('/api/loop/stop', '{}'); + if (loop) { drive.loop = loop; readDrive(); } +} + +// The Run-the-loop selector: Oldest, or a milestone title or label name from +// the value field, as the same selector string the loop takes; the server +// parses and validates it again. +const LOOP_MODES = { + oldest: { label: 'Oldest', field: '', placeholder: '' }, + milestone: { label: 'Milestone', field: 'Milestone title', placeholder: 'v1…' }, + label: { label: 'Label', field: 'Label name', placeholder: 'ready…' }, +}; +function loopSelector(mode, value) { return mode === 'oldest' ? 'oldest' : mode + ':' + String(value).trim(); } + +// submitLoop is the Run-the-loop form's submit handler, so Enter submits. The +// CSP sets form-action 'none': the form never navigates, it posts through +// startLoop exactly as before. +function submitLoop(e) { + e.preventDefault(); + const c = drive.controls; + if (drive.mode !== 'oldest' && !c.value.value.trim()) { + if (!drive.pending) drive.where = 'loop'; + driveMessage('Enter a ' + LOOP_MODES[drive.mode].field.toLowerCase() + ', or choose Oldest.'); + return; + } + startLoop(loopSelector(drive.mode, c.value.value), c.max.value); +} + +function submitRun(e) { + e.preventDefault(); + startRun(drive.controls.issue.value); +} + +function chooseMode(mode) { + const c = drive.controls, m = LOOP_MODES[mode]; + if (!c || !m) return; + drive.mode = mode; + for (const b of c.modes) b.setAttribute('aria-pressed', String(b.dataset.drive === 'mode-' + mode)); + c.valueField.hidden = mode === 'oldest'; + setText(c.valueLabel, m.field || 'Value'); + c.value.placeholder = m.placeholder; +} + +// ensureDrive builds the side panel's two forms once: Run the loop, which the +// header's Start loop link points at, and Start intake by Issue number. Their +// controls persist, so a poll never rebuilds an input being typed in. Stopping +// the loop is behind the header pill's disclosure and confirmation only. +function ensureDrive() { + if (drive.panel) return; + const modes = Object.entries(LOOP_MODES).map(([mode, m]) => el('button', { type: 'button', class: 'seg', 'aria-pressed': String(mode === drive.mode), dataset: { drive: 'mode-' + mode }, onclick: () => chooseMode(mode) }, m.label)); + const value = el('input', { type: 'text', id: 'drive-value', name: 'value', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'v1…', 'aria-describedby': 'drive-loop-error', dataset: { drive: 'value' } }); + const valueLabel = el('label', { class: 'field-label', for: 'drive-value' }, 'Milestone title'); + const valueField = el('div', { class: 'drive-field' }, valueLabel, value); + const max = el('input', { type: 'number', id: 'drive-max', name: 'maxConcurrent', min: '1', max: '8', step: '1', value: '1', inputmode: 'numeric', autocomplete: 'off', 'aria-describedby': 'drive-loop-error', dataset: { drive: 'max' } }); + // The stepper clamps to 1 and the input's max; startLoop still validates. + const step = (delta) => { + const limit = Number(max.max), n = Number(max.value); + const next = Number.isInteger(n) ? n + delta : 1; + max.value = String(Math.max(1, Math.min(Number.isInteger(limit) ? limit : 8, next))); + }; + const fewer = el('button', { type: 'button', class: 'stepper-button', 'aria-label': 'Fewer at once', 'aria-controls': 'drive-max', dataset: { drive: 'fewer' }, onclick: () => step(-1) }, '−'); + const more = el('button', { type: 'button', class: 'stepper-button', 'aria-label': 'More at once', 'aria-controls': 'drive-max', dataset: { drive: 'more' }, onclick: () => step(1) }, '+'); + const start = el('button', { type: 'submit', class: 'btn btn-primary drive-submit', dataset: { drive: 'start' } }, 'Start loop'); + const loopError = el('p', { class: 'drive-error', id: 'drive-loop-error', tabindex: '-1', hidden: true }); + const loopStatus = el('div', { class: 'drive-status', role: 'status' }); + const issue = el('input', { type: 'text', id: 'drive-issue', name: 'issue', inputmode: 'numeric', maxlength: '10', autocomplete: 'off', placeholder: '245…', 'aria-describedby': 'drive-run-error', dataset: { drive: 'issue' } }); + const run = el('button', { type: 'submit', class: 'btn btn-secondary', dataset: { drive: 'run' } }, 'Start intake'); + const runError = el('p', { class: 'drive-error', id: 'drive-run-error', tabindex: '-1', hidden: true }); + const runStatus = el('div', { class: 'drive-status', role: 'status' }); + drive.controls = { modes: modes, value: value, valueLabel: valueLabel, valueField: valueField, max: max, start: start, loopError: loopError, loopStatus: loopStatus, issue: issue, run: run, runError: runError, runStatus: runStatus }; + drive.panel = el('div', { class: 'drive-forms', id: 'drive-panel' }, + el('form', { class: 'drive-panel drive-form', id: 'drive-loop', tabindex: '-1', 'aria-labelledby': 'drive-loop-title', novalidate: true, onsubmit: submitLoop }, + el('div', null, + el('h2', { class: 'drive-title', id: 'drive-loop-title' }, 'Run the loop'), + el('p', { class: 'drive-help' }, 'Plans each eligible Issue, then waits for your approval.')), + el('div', { class: 'segmented', role: 'group', 'aria-label': 'Which Issues' }, modes), + valueField, + el('div', { class: 'drive-at-once' }, + el('label', { class: 'field-label', for: 'drive-max' }, 'At once'), + el('div', { class: 'stepper' }, fewer, max, more)), + start, loopError, loopStatus, + el('p', { class: 'drive-help' }, 'Coding runs in parallel. Checks wait their turn, one at a time. The loop approves only what the Formula policy approves.')), + el('form', { class: 'drive-panel drive-form', id: 'drive-run', 'aria-labelledby': 'drive-run-title', novalidate: true, onsubmit: submitRun }, + el('h2', { class: 'drive-title', id: 'drive-run-title' }, 'Start intake'), + el('div', { class: 'drive-row' }, + el('div', { class: 'drive-field' }, el('label', { class: 'field-label', for: 'drive-issue' }, 'Issue number'), issue), + run), + runError, runStatus, + el('p', { class: 'drive-help' }, 'Intake plans one Issue and stops at the scope gate for your approval.'))); + fill($('#drive-slot'), drive.panel); + chooseMode(drive.mode); +} + +function runText(run) { + if (!run) return null; + const c = run.canonical || {}; + let status; + if (c.unreadable) status = 'The ledger could not be read back; nothing is shown as started.'; + else if (run.committed) status = 'Started: the ledger records a Molecule for this Issue' + (c.lane === 'decision' ? ', waiting for your approval.' : '.'); + else if (run.state === 'running') status = 'Running. Not shown as started until a ledger for this Issue appears' + (c.incomplete ? '; the ledger inventory is incomplete' : '') + '.'; + else status = 'No ledger from this run was found' + (c.incomplete ? '; the ledger inventory is incomplete' : '') + '.'; + return el('div', { class: 'gate-job' }, + el('strong', null, 'Intake · Issue #' + notRecorded(run.issue) + ' · ' + notRecorded(run.state)), + el('p', { class: 'scope-note' }, status), + run.error ? el('p', { class: 'scope-note' }, (run.category ? run.category + ': ' : '') + run.error + '.' + reasonText(run)) : null, + run.committed && c.moleculeId ? el('button', { type: 'button', class: 'text-button', dataset: { drive: 'open' }, onclick: () => ctx.openMolecule(c.moleculeId) }, 'Open Molecule') : null); +} + +function loopText(loop) { + if (!loop || loop.state === 'idle') return null; + const r = loop.report; + const items = r && Array.isArray(r.items) ? r.items : []; + let status = ''; + if (loop.state === 'draining') status = 'Stopping: no new Issue is dispatched; Molecules in flight finish.'; + else if (loop.state === 'running') status = 'Running. Molecules appear in the Inbox as their ledgers are written.'; + else if (r) status = 'Final pass: ' + items.length + ' eligible Issue(s)' + (r.truncated ? ', list truncated' : '') + ' after ' + notRecorded(r.passes) + ' pass(es).'; + return el('div', { class: 'gate-job' }, + el('strong', null, 'Loop · ' + notRecorded(loop.state) + (loop.select ? ' · select ' + loop.select : '') + (loop.maxConcurrent ? ' · ' + loop.maxConcurrent + ' at once' : '')), + status ? el('p', { class: 'scope-note' }, status) : null, + loop.error ? el('p', { class: 'scope-note' }, (loop.category ? loop.category + ': ' : '') + loop.error + '.' + reasonText(loop)) : null, + items.length ? el('ul', { class: 'diagnostics' }, items.slice(0, 20).map((i) => el('li', null, '#' + notRecorded(i.issue) + ' · ' + notRecorded(i.state) + (i.started ? ' · started intake' : '') + (i.approved ? ' · approved by policy' : '')))) : null); +} + +function renderDrive() { + if (!drive.panel) return; + const c = drive.controls, loop = drive.loop; + const looping = Boolean(loop && (loop.state === 'running' || loop.state === 'draining')); + // A submit button stays enabled until its request starts, then shows progress. + c.run.disabled = drive.pending || Boolean(drive.run && drive.run.state === 'running'); + c.start.disabled = drive.pending || looping; + for (const b of [c.run, c.start]) b.setAttribute('aria-disabled', String(b.disabled)); + setText(c.run, drive.pending && drive.where === 'run' ? 'Starting…' : 'Start intake'); + setText(c.start, drive.pending && drive.where === 'loop' ? 'Starting…' : looping ? 'Loop is running' : 'Start loop'); + if (loop && Number.isInteger(loop.maxConcurrentLimit)) c.max.max = String(loop.maxConcurrentLimit); + // The message is the inline error of the form it belongs to, linked from its + // inputs by aria-describedby; focus moves to it once when it is set. + for (const [where, error] of [['run', c.runError], ['loop', c.loopError]]) { + const text = drive.where === where ? drive.message : ''; + setText(error, text); + error.hidden = !text; + if (text && drive.announce) { drive.announce = false; error.focus(); } + } + const pending = (where) => drive.pending && drive.where === where ? el('p', { class: 'scope-note' }, 'Submitting…') : null; + fill(c.runStatus, pending('run'), runText(drive.run)); + fill(c.loopStatus, pending('loop'), loopText(loop)); + renderLoopPill(); +} + +// The loop pill reads the loop this UI runs: idle with a Start loop link to the +// Run-the-loop form, or running with N in flight and Stop behind a disclosure +// and a confirmation that names the loop. Stop reuses stopLoop unchanged. +function renderLoopPill() { + const pill = $('#loop-pill'); + if (!drive.panel) { pill.hidden = true; return; } + pill.hidden = false; + const loop = drive.loop; + const phase = loop && (loop.state === 'running' || loop.state === 'draining') ? loop.state : 'idle'; + const inFlight = (state.list && state.list.counts && state.list.counts.running) || 0; + if (pill.dataset.loop !== phase) pill.dataset.loop = phase; + setText($('#loop-pill-text'), phase === 'idle' ? 'Loop is idle' : (phase === 'draining' ? 'Loop stopping · ' : 'Loop running · ') + inFlight + ' in flight'); + $('#loop-start-link').hidden = phase !== 'idle'; + const stop = $('#loop-stop'); + if (phase !== 'running' && !stop.hidden) { + if (stop.contains(document.activeElement)) pill.focus({ preventScroll: true }); + stop.open = false; + stop.hidden = true; + } else if (phase === 'running') stop.hidden = false; + setText($('#loop-stop-question'), 'Stop the loop' + (loop && loop.select ? ' on “' + loop.select + '”' : '') + '?'); + const confirm = $('#loop-stop-confirm'); + confirm.disabled = drive.pending; + setText(confirm, drive.pending && drive.where === 'loop' ? 'Stopping…' : 'Stop loop'); +} + +// The loop mounts on the side panel's #drive-slot; it also owns the header's +// loop pill, which the shell repaints through ctx.renderLoopPill. +export function mount(root, ctx) { + ctx.renderLoopPill = renderLoopPill; + $('#loop-stop-confirm').addEventListener('click', stopLoop); + $('#loop-start-link').addEventListener('click', () => { + // The form is in the side panel, beside every view; focus its chosen scope. + const input = document.querySelector('#drive-loop [aria-pressed="true"]'); + if (input) setTimeout(() => input.focus({ preventScroll: true }), 0); + }); +} + +// update shows the forms and the pill when the Lab drives work through the +// engine, and reads the loop back once. +export function update(state) { + const lab = state.lab; + const sample = Boolean(lab.sample); + const drives = !sample && Array.isArray(lab.actions) && lab.actions.length > 0; + $('.readonly-badge').hidden = drives; + if (!drives) $('#loop-pill').hidden = true; + if (drives) { + ensureDrive(); + renderDrive(); + if (!drive.loaded) { drive.loaded = true; readDrive(); } + } +} diff --git a/lab/internal/ui/web/js/molecule-decision.js b/lab/internal/ui/web/js/molecule-decision.js new file mode 100644 index 0000000..ceb9fda --- /dev/null +++ b/lab/internal/ui/web/js/molecule-decision.js @@ -0,0 +1,464 @@ +// ---- Molecule view: the decision card, the danger zone and the roles ------------ +// The sticky decision card has one primary action, bound to the exact digest +// the plan shows. Retire and Stop sit apart, in the collapsed Not needed +// anymore? block, each behind a confirmation that names the Molecule. Controls +// are built once and patched in place, so a poll never rebuilds a form being +// typed in, and the one live region changes only when its text does. +import { $, el, fill, state, ctx, newGate, RETIRED, PULL_URL, notRecorded, api, failed, setText, setAttr, plainText, count, counted, wholeNumber, money, whenText, dateTitle, minutesText, STEPS, show } from './core.js'; +import { submitGate, gateMessage, fetchJob, readBack, rerenderGate, jobText } from './core-gates.js'; + +// mv is the Molecule view's state, shared by its sub-modules. +export const mv = { controls: null, steps: null, from: 'work', planKey: null, statsKey: null, statsToken: 0, stats: null, lastDigest: null, replaced: false, timelineId: null, timelineDirty: false }; + +// approveScope posts the exact digest the plan shows; the approval shows as +// committed only once the ledger read back records it. +async function approveScope(digest) { + const job = await submitGate('/scope', JSON.stringify({ digest: digest })); + if (job) { state.gate.job = job; readBack(state.gate); } +} + +async function previewDelivery() { + const gate = state.gate; + const preview = await submitGate('/delivery/preview', '{}'); + if (preview && state.gate === gate) { gate.preview = preview; rerenderGate(gate); } +} + +async function publishDelivery() { + const gate = state.gate; + const preview = gate.preview, title = gate.title.trim(), accept = gate.accept; + if (!preview || !title) { gateMessage('publish', 'Enter a one-line title for the draft PR, such as the change in a few words.'); return; } + if (preview.acceptanceUnverified && !accept) { gateMessage('publish', 'Tick the box once you have checked the acceptance criteria yourself.'); return; } + const job = await submitGate('/delivery/publish', JSON.stringify({ digest: preview.digest, title: title, acceptUnverified: accept })); + if (job && state.gate === gate) { gate.job = job; readBack(gate); } +} + +// requestChanges posts the additions with the exact digest the plan shows. The +// engine records a new version of the plan and approves nothing: the new +// version waits for its own approval through Approve plan & start coding. +async function requestChanges(digest, acceptance, path, reason) { + const gate = state.gate; + const body = { digest: digest, addAcceptance: acceptance ? [acceptance] : [], addScope: path ? [{ path: path, reason: reason }] : [] }; + const amended = await submitGate('/amend', JSON.stringify(body)); + if (amended && state.gate === gate) { + gate.amend = amended; + if (amended.committed) clearChanges(); + rerenderGate(gate); + ctx.loadDetail(gate.id, false); + } +} + +// stopMolecule posts once; the stop shows as done only when the ledger read back +// no longer reports the Molecule running. +async function stopMolecule() { + const gate = state.gate; + const stop = await submitGate('/stop', '{}'); + if (stop && state.gate === gate) { gate.stop = stop; rerenderGate(gate); ctx.loadDetail(gate.id, false); } +} + +// retireMolecule posts once with the typed reason; the retirement shows as +// committed only when the response's ledger read-back records it. +async function retireMolecule() { + const gate = state.gate; + const reason = gate.reason.trim(); + if (!reason || /[\r\n]/.test(reason) || [...reason].length > 200) { gateMessage('retire', 'Enter a one-line reason of at most 200 characters, such as why the Molecule is no longer needed.'); return; } + const retired = await submitGate('/retire', JSON.stringify({ reason: reason })); + if (retired && state.gate === gate) { gate.retire = retired; rerenderGate(gate); ctx.loadDetail(gate.id, false); } +} + +function retireText(d, gate) { + const r = d.retired; + if (d.lane === RETIRED && r) { + const when = whenText(r.at); + return el('p', { class: 'scope-note', title: dateTitle(r.at) }, 'Retired' + (when ? ' ' + when : '') + '. Reason: ' + notRecorded(r.reason) + '. Every record is kept.'); + } + if (gate.retire) return el('p', { class: 'scope-note' }, gate.retire.committed ? 'Committed: the ledger records the retirement.' : 'Not committed: the ledger read back does not record the retirement.'); + return null; +} + +function hasAction(name) { return Boolean(state.lab && Array.isArray(state.lab.actions) && state.lab.actions.includes(name)); } + +function latestDigest(d) { + const plans = (d.readiness && d.readiness.plans) || []; + const last = plans[plans.length - 1]; + return last && typeof last.digest === 'string' && /^[0-9a-f]{64}$/.test(last.digest) ? last.digest : ''; +} + +// submitButton is a form's submit button, so Enter submits the form. +function submitButton(props, ...children) { + const button = el('button', props, ...children); + button.type = 'submit'; + return button; +} +function field(id, label, input) { return el('div', { class: 'drive-field' }, el('label', { class: 'field-label', for: id }, label), input); } +// fillKeyed rebuilds node only when key changes, so an unchanged status is +// neither rebuilt nor announced again. +function fillKeyed(node, key, build) { + if (node.dataset.key === key) return; + node.dataset.key = key; + fill(node, key ? build() : null); +} +function moleculeName(d) { + const title = plainText(d.issue && d.issue.title, 'Untitled'); + const number = d.issue && Number.isInteger(d.issue.number) ? '#' + d.issue.number + ' · ' : ''; + return number + ([...title].length > 80 ? [...title].slice(0, 79).join('') + '…' : title); +} + +// ensureMoleculeView builds the decision card, the danger zone, the roles and +// the steps once. +export function ensureMoleculeView() { + if (mv.controls) return mv.controls; + const c = {}; + c.heading = el('h2', { class: 'decision-title', id: 'mv-decision-title', tabindex: '-1' }); + c.copy = el('p', { class: 'decision-copy' }); + c.changed = el('p', { class: 'decision-changed', hidden: true }); + c.size = el('p', { class: 'decision-size', hidden: true }); + // The scope gate: Approve is the card's one primary, bound through its form to + // the digest the plan shows; Request changes is secondary and approves nothing. + c.approve = submitButton({ class: 'btn btn-primary btn-decision', 'aria-describedby': 'mv-gate-error', dataset: { gate: 'scope' } }, 'Approve plan & start coding'); + c.approveForm = el('form', { class: 'decision-form', id: 'mv-approve-form', novalidate: true, hidden: true, onsubmit: submitApprove }, c.approve); + c.changes = el('button', { type: 'button', class: 'btn btn-secondary btn-decision-secondary', 'aria-expanded': 'false', 'aria-controls': 'mv-amend-form', hidden: true, dataset: { gate: 'changes' }, onclick: toggleChanges }, 'Request changes…'); + c.acceptance = el('input', { type: 'text', id: 'mv-amend-acceptance', name: 'acceptance', maxlength: '1000', autocomplete: 'off', placeholder: 'The help text names the new flag…', 'aria-describedby': 'mv-amend-error', dataset: { gate: 'acceptance' } }); + c.path = el('input', { type: 'text', id: 'mv-amend-path', name: 'path', maxlength: '300', autocomplete: 'off', spellcheck: 'false', placeholder: 'lab/internal/ui/web/app.js…', 'aria-describedby': 'mv-amend-error', dataset: { gate: 'path' } }); + c.reason = el('input', { type: 'text', id: 'mv-amend-reason', name: 'reason', maxlength: '1000', autocomplete: 'off', placeholder: 'Wire the new button…', 'aria-describedby': 'mv-amend-error', dataset: { gate: 'path-reason' } }); + c.amendError = el('p', { class: 'drive-error', id: 'mv-amend-error', tabindex: '-1', hidden: true }); + c.amendSubmit = submitButton({ class: 'btn btn-secondary', dataset: { gate: 'amend' } }, 'Record changes'); + c.amendForm = el('form', { class: 'amend-form', id: 'mv-amend-form', 'aria-labelledby': 'mv-amend-title', novalidate: true, hidden: true, onsubmit: submitChanges }, + el('h3', { class: 'amend-title', id: 'mv-amend-title' }, 'Request changes'), + el('p', { class: 'drive-help' }, 'Forgecell records your changes as a new version of this plan. Nothing is approved now: you review and approve the new version on its own.'), + field('mv-amend-acceptance', 'Add an acceptance note', c.acceptance), + el('fieldset', { class: 'amend-scope' }, el('legend', { class: 'field-label' }, 'Or let it change one more file'), + field('mv-amend-path', 'File path', c.path), field('mv-amend-reason', 'Why it needs to change', c.reason)), + c.amendError, c.amendSubmit); + c.amendStatus = el('div', { class: 'gate-status' }); + // The review gate: Preview is the primary until a preview is shown; then + // Publish is, and Preview again turns secondary. + c.preview = el('button', { type: 'button', class: 'btn btn-primary btn-decision', 'aria-describedby': 'mv-gate-error', hidden: true, dataset: { gate: 'preview' }, onclick: previewDelivery }, 'Preview delivery'); + c.previewInfo = el('div', { class: 'preview-info' }); + c.title = el('input', { type: 'text', id: 'mv-publish-title', name: 'title', maxlength: '256', autocomplete: 'off', placeholder: 'Add the Molecule view to the Lab UI…', 'aria-describedby': 'mv-publish-error', dataset: { gate: 'title' }, oninput: (e) => { state.gate.title = e.target.value; } }); + c.accept = el('input', { type: 'checkbox', id: 'mv-publish-accept', name: 'acceptUnverified', 'aria-describedby': 'mv-accept-help', dataset: { gate: 'accept' }, onchange: (e) => { state.gate.accept = e.target.checked; } }); + c.acceptField = el('div', { class: 'accept-field', hidden: true }, + el('label', { class: 'accept-label', for: 'mv-publish-accept' }, c.accept, el('span', null, 'I checked the acceptance criteria myself')), + el('p', { class: 'drive-help', id: 'mv-accept-help' }, 'No independent check confirmed that this change meets its acceptance criteria. Tick this only once you have reviewed them yourself; your confirmation is recorded with the draft PR.')); + c.publishError = el('p', { class: 'drive-error', id: 'mv-publish-error', tabindex: '-1', hidden: true }); + c.publish = submitButton({ class: 'btn btn-primary btn-decision', dataset: { gate: 'publish' } }, 'Publish draft PR'); + c.publishForm = el('form', { class: 'decision-form', id: 'mv-publish-form', novalidate: true, hidden: true, onsubmit: submitPublish }, + field('mv-publish-title', 'Draft PR title', c.title), c.acceptField, c.publishError, c.publish); + c.pull = el('a', { class: 'btn btn-secondary decision-link', target: '_blank', rel: 'noopener noreferrer', hidden: true }, 'Open the pull request ↗'); + c.error = el('p', { class: 'drive-error', id: 'mv-gate-error', tabindex: '-1', hidden: true }); + c.job = el('div', { class: 'gate-status' }); + c.estimate = el('p', { class: 'decision-estimate', hidden: true }); + fill($('#mv-decision'), c.heading, c.copy, c.changed, c.size, + c.approveForm, c.changes, c.amendForm, c.amendStatus, + c.preview, c.previewInfo, c.publishForm, c.pull, + c.error, c.job, c.estimate); + + // The danger zone: its own collapsed block, never beside a primary. Each + // action opens a confirmation that names the Molecule. + c.stopOpen = el('button', { type: 'button', class: 'btn btn-danger', 'aria-expanded': 'false', 'aria-controls': 'mv-stop-form', dataset: { gate: 'stop-open' }, onclick: () => openConfirm('stop') }, 'Stop this Molecule…'); + c.stopQuestion = el('p', { class: 'confirm-question', id: 'mv-stop-question' }); + c.stopError = el('p', { class: 'drive-error', id: 'mv-stop-error', tabindex: '-1', hidden: true }); + c.stopSubmit = submitButton({ class: 'btn btn-danger', 'aria-describedby': 'mv-stop-error', dataset: { gate: 'stop' } }, 'Stop Molecule'); + c.stopForm = el('form', { class: 'confirm-form', id: 'mv-stop-form', 'aria-labelledby': 'mv-stop-question', novalidate: true, hidden: true, onsubmit: submitStop }, + c.stopQuestion, + el('p', { class: 'drive-help' }, 'Its process group is stopped and the ledger records the stop. Work a loop or the forgecell CLI runs is refused.'), + c.stopError, + el('div', { class: 'confirm-actions' }, c.stopSubmit, el('button', { type: 'button', class: 'btn btn-secondary', dataset: { gate: 'stop-cancel' }, onclick: () => closeConfirm('stop', true) }, 'Cancel'))); + c.stopStatus = el('div', { class: 'gate-status' }); + c.stopBlock = el('div', { class: 'danger-item' }, el('p', null, 'Stopping ends the work this Lab UI started for this Molecule.'), c.stopOpen, c.stopForm, c.stopStatus); + c.retireOpen = el('button', { type: 'button', class: 'btn btn-danger', 'aria-expanded': 'false', 'aria-controls': 'mv-retire-form', dataset: { gate: 'retire-open' }, onclick: () => openConfirm('retire') }, 'Retire this Molecule…'); + c.retireQuestion = el('p', { class: 'confirm-question', id: 'mv-retire-question' }); + c.reasonInput = el('input', { type: 'text', id: 'mv-retire-reason', name: 'reason', maxlength: '200', autocomplete: 'off', placeholder: 'Replaced by a fresh Molecule…', 'aria-describedby': 'mv-retire-error', dataset: { gate: 'reason' }, oninput: (e) => { state.gate.reason = e.target.value; } }); + c.retireError = el('p', { class: 'drive-error', id: 'mv-retire-error', tabindex: '-1', hidden: true }); + c.retireSubmit = submitButton({ class: 'btn btn-danger', dataset: { gate: 'retire' } }, 'Retire Molecule'); + c.retireForm = el('form', { class: 'confirm-form', id: 'mv-retire-form', 'aria-labelledby': 'mv-retire-question', novalidate: true, hidden: true, onsubmit: submitRetire }, + c.retireQuestion, field('mv-retire-reason', 'Reason', c.reasonInput), c.retireError, + el('div', { class: 'confirm-actions' }, c.retireSubmit, el('button', { type: 'button', class: 'btn btn-secondary', dataset: { gate: 'retire-cancel' }, onclick: () => closeConfirm('retire', true) }, 'Cancel'))); + c.retireStatus = el('div', { class: 'gate-status' }); + c.retireBlock = el('div', { class: 'danger-item' }, el('p', null, 'Retiring keeps every record, but this Molecule can’t be resumed. You can start a fresh one for the Issue later.'), c.retireOpen, c.retireForm, c.retireStatus); + c.danger = el('details', { class: 'danger-zone', id: 'mv-danger', hidden: true }, el('summary', null, 'Not needed anymore?'), el('div', { class: 'danger-body' }, c.stopBlock, c.retireBlock)); + // runnerNote replaces Stop when this Lab UI does not run the engine call. + c.runnerNote = el('p', { class: 'drive-help runner-note', id: 'mv-runner-note', hidden: true }); + fill($('#mv-danger-slot'), c.runnerNote, c.danger); + + c.roles = { coding: el('dd'), reviewer: el('dd'), cost: el('dd', { class: 'num' }) }; + fill($('#mv-roles'), + el('div', null, el('dt', null, 'Coding agent'), c.roles.coding), + el('div', null, el('dt', null, 'Reviewer'), c.roles.reviewer), + el('div', null, el('dt', null, 'Planning cost so far'), c.roles.cost)); + c.issue = el('a', { class: 'mv-issue-link', target: '_blank', rel: 'noopener noreferrer', hidden: true }); + c.issueText = el('span', { hidden: true }); + fill($('#mv-issue'), c.issue, c.issueText); + mv.steps = STEPS.map((label) => el('li', { class: 'mv-step is-todo' }, el('span', { class: 'step-bar', 'aria-hidden': 'true' }), el('span', { class: 'mv-step-label' }, label))); + fill($('#mv-steps'), mv.steps); + mv.controls = c; + return c; +} + +// resetDecision clears what was typed for the previous Molecule: a title, a +// reason or a requested change never carries over to another one. +function resetDecision(c) { + for (const input of [c.title, c.acceptance, c.path, c.reason, c.reasonInput]) input.value = ''; + c.accept.checked = false; + c.amendForm.hidden = true; + c.changes.setAttribute('aria-expanded', 'false'); + for (const kind of ['stop', 'retire']) { c[kind + 'Form'].hidden = true; c[kind + 'Open'].setAttribute('aria-expanded', 'false'); } + c.danger.open = false; + c.previewInfo.dataset.key = ''; + mv.replaced = false; +} + +function submitApprove(e) { + e.preventDefault(); + const digest = mv.controls.approveForm.dataset.digest; + if (digest) approveScope(digest); +} +function submitChanges(e) { + e.preventDefault(); + const c = mv.controls, digest = c.approveForm.dataset.digest; + const acceptance = c.acceptance.value.trim(), path = c.path.value.trim(), reason = c.reason.value.trim(); + if (!digest) return; + if (!acceptance && !path && !reason) { gateMessage('amend', 'Add an acceptance note, or a file path with the reason it needs to change.'); return; } + if (path && !reason) { gateMessage('amend', 'Say why this file needs to change, such as the behaviour it adds.'); return; } + if (reason && !path) { gateMessage('amend', 'Add the file path this reason is for, such as lab/internal/ui/web/app.js.'); return; } + requestChanges(digest, acceptance, path, reason); +} +function submitPublish(e) { e.preventDefault(); publishDelivery(); } +function submitStop(e) { e.preventDefault(); stopMolecule(); } +function submitRetire(e) { e.preventDefault(); retireMolecule(); } + +function toggleChanges() { + const c = mv.controls; + if (!c.amendForm.hidden) { closeChanges(true); return; } + c.amendForm.hidden = false; + c.changes.setAttribute('aria-expanded', 'true'); + c.acceptance.focus(); +} +function closeChanges(focus) { + const c = mv.controls, had = c.amendForm.contains(document.activeElement); + c.amendForm.hidden = true; + c.changes.setAttribute('aria-expanded', 'false'); + if (focus || had) (c.changes.hidden ? $('#mv-decision-title') : c.changes).focus({ preventScroll: true }); +} +function clearChanges() { + const c = mv.controls; + for (const input of [c.acceptance, c.path, c.reason]) input.value = ''; + closeChanges(false); +} +function openConfirm(kind) { + const c = mv.controls; + c[kind + 'Form'].hidden = false; + c[kind + 'Open'].setAttribute('aria-expanded', 'true'); + (kind === 'retire' ? c.reasonInput : c.stopSubmit).focus(); +} +function closeConfirm(kind, focus) { + const c = mv.controls, form = c[kind + 'Form'], open = c[kind + 'Open']; + const had = form.contains(document.activeElement); + form.hidden = true; + open.setAttribute('aria-expanded', 'false'); + if (focus || had) (open.hidden ? c.danger.querySelector('summary') : open).focus({ preventScroll: true }); +} + +// boundDigest is the digest the Approve form posts: the decision's primary +// digest, only when it is the plan's, the gate's and the latest plan's too. +function boundDigest(d) { + const v = d.view || {}, dec = v.decision || {}, primary = dec.primary || {}, plan = v.plan || {}, g = d.gate || {}; + const digest = typeof primary.digest === 'string' && /^[0-9a-f]{64}$/.test(primary.digest) ? primary.digest : ''; + return digest && primary.action === 'approve' && digest === plan.digest && digest === g.digest && digest === latestDigest(d) ? digest : ''; +} + +// renderDecision patches the decision card and the danger zone in place. It +// never sets the Publish title or any other typed value: only resetDecision, +// for another Molecule, clears them. +export function renderDecision(d) { + const c = ensureMoleculeView(); + if (state.gate.id !== d.id) { + clearTimeout(state.gate.timer); + state.gate = newGate(d.id); + resetDecision(c); + if (!d.sample) fetchJob(state.gate); + } + const gate = state.gate; + const v = d.view || {}, dec = v.decision || {}; + const readOnly = Boolean(d.sample || (state.lab && state.lab.readOnly)); + const running = Boolean(gate.job && gate.job.state === 'running'); + const busy = gate.pending || running; + const digest = readOnly ? '' : boundDigest(d); + const scope = dec.kind === 'scope' && Boolean(digest); + const delivery = !readOnly && dec.kind === 'delivery' && Boolean(d.gate && d.gate.state === 'delivery'); + setText(c.heading, readOnly ? 'Sample data' : plainText(dec.title, 'Nothing to do')); + setText(c.copy, readOnly ? 'Sample data has no gates. Nothing here writes.' : plainText(dec.copy, '')); + + // A plan replaced while it is open says so before it can be approved. + if (scope) { + const own = gate.amend && gate.amend.digest === digest; + if (mv.lastDigest && mv.lastDigest.id === d.id && mv.lastDigest.digest !== digest && !own) mv.replaced = true; + mv.lastDigest = { id: d.id, digest: digest }; + } + const replaced = scope && mv.replaced ? 'This plan was replaced by version ' + count(v.plan && v.plan.revision) + ' while it was open. Review it before you approve.' : ''; + setText(c.changed, replaced); + c.changed.hidden = !replaced; + + // The scope gate. + c.approveForm.hidden = !scope; + if (scope) c.approveForm.dataset.digest = digest; + else delete c.approveForm.dataset.digest; + c.approve.disabled = busy; + c.approve.setAttribute('aria-disabled', String(busy)); + setText(c.approve, gate.pending && gate.where === 'decision' && scope ? 'Approving…' : 'Approve plan & start coding'); + const amendable = scope && dec.requestChanges === true && hasAction('request plan changes'); + c.changes.hidden = !amendable; + if (!amendable && !c.amendForm.hidden) closeChanges(false); + c.changes.disabled = busy; + c.amendSubmit.disabled = busy; + setText(c.amendSubmit, gate.pending && gate.where === 'amend' ? 'Recording…' : 'Record changes'); + fillKeyed(c.amendStatus, gate.amend ? JSON.stringify(gate.amend) : '', () => amendText(gate.amend)); + + // The review gate: exactly one primary at a time. + const preview = delivery ? gate.preview : null; + c.preview.hidden = !delivery; + setAttr(c.preview, 'class', preview ? 'btn btn-secondary btn-decision-secondary' : 'btn btn-primary btn-decision'); + setText(c.preview, gate.pending && gate.where === 'decision' && delivery ? 'Previewing…' : preview ? 'Preview again' : 'Preview delivery'); + c.preview.disabled = busy; + c.publishForm.hidden = !preview; + c.acceptField.hidden = !(preview && preview.acceptanceUnverified); + c.publish.disabled = busy; + setText(c.publish, gate.pending && gate.where === 'publish' ? 'Publishing…' : 'Publish draft PR'); + renderPreviewInfo(c, preview); + + // A published or merged Molecule: nothing to do, and its pull request. + const pull = !readOnly && dec.kind === 'published' && typeof dec.pullUrl === 'string' && PULL_URL.test(dec.pullUrl) ? dec.pullUrl : ''; + c.pull.hidden = !pull; + if (pull) setAttr(c.pull, 'href', pull); + else c.pull.removeAttribute('href'); + + // The diff size on both gates; the estimate before coding. + const stats = (scope || delivery) && mv.stats && mv.stats.id === d.id ? mv.stats.data : null; + const size = stats ? diffText(stats.diff) : ''; + setText(c.size, size); + c.size.hidden = !size; + const estimate = stats && scope ? estimateText(stats.estimate) : ''; + setText(c.estimate, estimate); + c.estimate.hidden = !estimate; + fillKeyed(c.job, gate.job ? JSON.stringify(gate.job) : '', () => jobText(gate.job)); + + // The danger zone. + const live = d.status === 'running' || d.status === 'allocating'; + const retired = d.lane === RETIRED || Boolean(gate.retire); + // Stop is offered only when the server says this Lab UI runs the engine call. + const control = d.view && d.view.control; + const stoppable = live && Boolean(control && control.stoppable === true); + const canStop = !readOnly && (stoppable || Boolean(gate.stop)) && hasAction('stop Molecule'); + const canRetire = !readOnly && !live && !retired && hasAction('retire Molecule'); + const runner = live && !stoppable && !gate.stop && control && typeof control.text === 'string' ? plainText(control.text, '') : ''; + setText(c.runnerNote, runner); + c.runnerNote.hidden = !runner; + c.stopBlock.hidden = !canStop; + c.stopOpen.hidden = !stoppable || Boolean(gate.stop); + if (c.stopOpen.hidden && !c.stopForm.hidden) closeConfirm('stop', false); + c.stopSubmit.disabled = gate.pending; + setText(c.stopSubmit, gate.pending && gate.where === 'stop' ? 'Stopping…' : 'Stop Molecule'); + setText(c.stopQuestion, 'Stop ' + moleculeName(d) + '?'); + fillKeyed(c.stopStatus, gate.stop ? JSON.stringify([gate.stop, live]) : '', () => stopText(gate, live)); + c.retireBlock.hidden = !(canRetire || (!readOnly && retired)); + c.retireOpen.hidden = !canRetire; + if (!canRetire && !c.retireForm.hidden) closeConfirm('retire', false); + c.retireSubmit.disabled = gate.pending || running; + setText(c.retireSubmit, gate.pending && gate.where === 'retire' ? 'Retiring…' : 'Retire Molecule'); + setText(c.retireQuestion, 'Retire ' + moleculeName(d) + '?'); + fillKeyed(c.retireStatus, JSON.stringify([d.lane, d.retired, gate.retire]), () => retireText(d, gate)); + c.danger.hidden = c.stopBlock.hidden && c.retireBlock.hidden; + if (c.danger.hidden) c.danger.open = false; + + // One inline error, in the form that sent the request; focus moves to it once. + const errors = { decision: c.error, amend: c.amendError, publish: c.publishError, stop: c.stopError, retire: c.retireError }; + let target = errors[gate.where] || c.error; + if (target.parentElement.closest('[hidden]')) target = c.error; + for (const node of Object.values(errors)) { + const text = node === target ? gate.message : ''; + setText(node, text); + node.hidden = !text; + } + if (gate.message && gate.announce) { gate.announce = false; target.focus(); } + // The one live region of the view carries the outcomes read back. + setText($('#mv-live'), [c.job, c.amendStatus, c.stopStatus, c.retireStatus].map((n) => n.textContent).filter(Boolean).join(' ')); +} + +function amendText(a) { + return el('p', { class: 'scope-note' }, a.committed + ? 'Version ' + count(a.revision) + ' of the plan is recorded and waits for your approval. Nothing was approved.' + : 'Not committed: the ledger read back shows no new version of this plan.'); +} +function stopText(gate, live) { + if (!gate.stop) return null; + return el('p', { class: 'scope-note' }, live ? 'Stop requested. Not committed until the ledger no longer shows this Molecule running.' : 'Stopped: the ledger no longer shows this Molecule running.'); +} + +// renderPreviewInfo shows the preview once per preview, so its View diff +// button is not rebuilt by a poll. +function renderPreviewInfo(c, preview) { + const key = preview && typeof preview.digest === 'string' ? preview.digest : ''; + fillKeyed(c.previewInfo, key, () => { + const files = Array.isArray(preview.files) ? preview.files : []; + const bytes = count(preview.diffBytes); + return [ + el('p', { class: 'preview-line' }, 'Branch ', el('code', null, notRecorded(preview.branch)), ' into ', el('code', null, notRecorded(preview.baseBranch)), ' · ' + counted(files.length, 'file', 'files')), + el('button', { type: 'button', class: 'text-button', dataset: { gate: 'diff' }, onclick: () => show('Delivery diff · ' + moleculeName(state.detail || {}), typeof preview.diff === 'string' ? preview.diff : '', (preview.diffTruncated ? 'Truncated · showing the first part of ' + wholeNumber.format(bytes) + ' bytes.' : 'Complete · ' + wholeNumber.format(bytes) + ' bytes.') + ' Secrets and local paths are redacted; the CLI shows the full diff.') }, 'View diff ↗'), + ]; + }); +} + +// diffText is the measured size, "+412 −96 in 4 files"; a count stopped at a +// bound says it is a lower bound. Nothing measured shows nothing. +function diffText(diff) { + if (!diff || diff.state !== 'measured' || !Number.isInteger(diff.files) || diff.files < 1) return ''; + const text = '+' + wholeNumber.format(count(diff.added)) + ' −' + wholeNumber.format(count(diff.removed)) + ' in ' + counted(diff.files, 'file', 'files') + (count(diff.binary) ? ' (' + counted(count(diff.binary), 'binary file', 'binary files') + ')' : ''); + return diff.truncated ? 'At least ' + text + ' · the count stopped at a safety bound' : text; +} + +// estimateText is the history-based estimate with its basis, or nothing when +// the server hides it. +function estimateText(e) { + if (!e || e.shown !== true || !e.time || typeof e.basis !== 'string' || !e.basis) return ''; + let text = 'Usually ' + rangeText(count(e.time.lowMs), count(e.time.highMs)); + const cost = e.cost && money(e.cost.medianUsd); + if (cost) text += ' · about ' + cost + (e.cost.partial ? ' (' + plainText(e.cost.coverage, 'partial cost') + ')' : ''); + return text + ', ' + e.basis + '. ' + plainText(e.note, ''); +} +function rangeText(low, high) { + const a = minutesText(low), b = minutesText(high); + if (a === b) return 'about ' + a; + if (/^\d+ min$/.test(a) && /^\d+ min$/.test(b)) return a.slice(0, -4) + '–' + b; + return a + '–' + b; +} + +export function renderRoles(d) { + const c = ensureMoleculeView(); + const v = d.view; + $('#mv-roles').hidden = !v; + if (!v) return; + const r = v.roles || {}; + setText(c.roles.coding, [plainText(r.coding, ''), plainText(r.model, '')].filter(Boolean).join(' · ') || 'Not recorded'); + setText(c.roles.reviewer, typeof r.reviewer === 'string' && r.reviewer ? r.reviewer + ', read-only' : 'No review step'); + const p = v.planningCost, total = p && money(p.usd); + setText(c.roles.cost, !total ? 'Not recorded' : p.partial ? total + ' · reported for ' + count(p.reported) + ' of ' + counted(count(p.attempts), 'attempt', 'attempts') : total); +} + +// loadStats reads the diff size and the estimate once per Molecule, gate and +// plan; a response for another selection is dropped. +export async function loadStats(d) { + const v = d.view, kind = v && v.decision && v.decision.kind; + if (d.sample || (kind !== 'scope' && kind !== 'delivery')) { mv.statsKey = null; mv.stats = null; return; } + const key = d.id + ':' + kind + ':' + ((v.plan && v.plan.digest) || ''); + if (key === mv.statsKey) return; + mv.statsKey = key; + mv.stats = null; + const token = ++mv.statsToken; + try { + const res = await api('/api/molecules/' + encodeURIComponent(d.id) + '/stats'); + if (token !== mv.statsToken || !res.ok) return; + const data = await res.json(); + if (token !== mv.statsToken) return; + mv.stats = { id: d.id, data: data }; + if (state.detail && state.detail.id === d.id) renderDecision(state.detail); + } catch (error) { failed(error); } +} diff --git a/lab/internal/ui/web/js/molecule-plan.js b/lab/internal/ui/web/js/molecule-plan.js new file mode 100644 index 0000000..d1594e3 --- /dev/null +++ b/lab/internal/ui/web/js/molecule-plan.js @@ -0,0 +1,95 @@ +// ---- Molecule view: the plan in plain words ---------------------------------------- +// What Forgecell will do, the files it may change, the checks it must pass and +// what counts as done, from the server's plain MoleculeView: no phase code, id +// or full digest is turned into prose, and the plan's digest appears only +// short, inside Plan details. +import { $, el, fill, short, setText, plainText, count, counted, wholeNumber, minutesText } from './core.js'; +import { mv } from './molecule-decision.js'; + +const CHANGE_BADGES = { new: ['NEW', 'Not in the base tree: a new file'], edit: ['EDIT', 'In the base tree: an edit'] }; + +// renderPlan rebuilds the plan sections only when the plan itself changes. +export function renderPlan(d) { + const plan = d.view && d.view.plan; + const key = plan ? JSON.stringify(plan) : 'none'; + if (key === mv.planKey) return; + mv.planKey = key; + $('#mv-plan-sections').hidden = !plan; + const none = $('#mv-no-plan'); + none.hidden = Boolean(plan); + if (!plan) { setText(none, d.sample ? 'Sample data has no plan.' : 'No plan is recorded for this Molecule yet.'); return; } + setText($('#mv-summary'), plainText(plan.summary, 'No summary was recorded for this plan.')); + fill($('#mv-chips'), planChips(plan).map((text) => el('li', { class: 'chip' }, text))); + const note = $('#mv-note'); + setText(note, plan.note ? 'Your note for this version: ' + plan.note : ''); + note.hidden = !plan.note; + const files = Array.isArray(plan.files) ? plan.files : []; + setText($('#mv-files-count'), wholeNumber.format(count(plan.filesTotal))); + fill($('#mv-files'), files.map(fileItem)); + moreLine($('#mv-files-more'), count(plan.filesTotal) - files.length, 'file', 'files'); + const groups = Array.isArray(plan.checkGroups) ? plan.checkGroups : []; + setText($('#mv-checks-count'), wholeNumber.format(count(plan.checksTotal))); + fill($('#mv-checks'), groups.map(groupItem)); + const acceptance = Array.isArray(plan.acceptance) ? plan.acceptance : []; + setText($('#mv-acceptance-count'), wholeNumber.format(count(plan.acceptanceTotal))); + fill($('#mv-acceptance'), acceptance.map((text) => el('li', null, plainText(text, '')))); + moreLine($('#mv-acceptance-more'), count(plan.acceptanceTotal) - acceptance.length, 'criterion', 'criteria'); + $('#mv-acceptance-section').hidden = count(plan.acceptanceTotal) === 0; + renderPlanDetails(plan); +} + +function planChips(plan) { + const chips = []; + const questions = count(plan.questions); + chips.push(questions ? counted(questions, 'open question', 'open questions') : 'No open questions'); + const parts = Array.isArray(plan.components) ? plan.components.filter((p) => typeof p === 'string' && p) : []; + const extra = count(plan.componentsTotal) - parts.length; + if (parts.length) chips.push('Touches ' + parts.join(', ') + (extra > 0 ? ' and ' + counted(extra, 'more', 'more') : '')); + if (Number.isInteger(plan.budgetMs) && plan.budgetMs > 0) chips.push('Coding budget ' + minutesText(plan.budgetMs)); + return chips; +} + +// fileItem is one scope path with its NEW or EDIT badge, which the server +// derives from the frozen base tree; without that tree there is no badge. +function fileItem(f) { + const badge = CHANGE_BADGES[f.change]; + return el('li', { class: 'file' }, + badge ? el('span', { class: 'file-badge is-' + f.change, title: badge[1] }, badge[0]) : el('span', { class: 'file-badge is-unknown', title: 'The base tree is not recorded' }, '·'), + el('div', { class: 'file-text' }, el('code', null, plainText(f.path, '')), f.reason ? el('p', { class: 'file-reason' }, f.reason) : null)); +} + +function groupItem(g) { + const checks = Array.isArray(g.checks) ? g.checks : []; + return el('li', { class: 'check-group' + (g.baseline ? ' is-baseline' : '') }, + el('h3', { class: 'check-name' }, plainText(g.name, 'Checks')), + el('ul', { class: 'check-commands' }, checks.map((x) => el('li', null, el('code', null, plainText(x.command, '')), x.dir ? el('span', { class: 'check-dir' }, ' in ' + x.dir) : null)))); +} + +function moreLine(node, extra, one, many) { + setText(node, extra > 0 ? counted(extra, 'more ' + one, 'more ' + many) + ' in the full plan, under Timeline · Ledger record.' : ''); + node.hidden = extra <= 0; +} + +// renderPlanDetails fills the collapsed Plan details: the version, the short +// digest with a copy button and the frozen evidence. +function renderPlanDetails(plan) { + const digest = typeof plan.digest === 'string' && /^[0-9a-f]{64}$/.test(plan.digest) ? plan.digest : ''; + fill($('#mv-details-summary'), 'Plan details · version ' + count(plan.revision) + (digest ? ' · ' : ''), digest ? el('code', null, digest.slice(0, 7)) : null); + setText($('#mv-digest-short'), digest ? short(digest) : 'Not recorded'); + const copy = $('#mv-copy-digest'); + copy.hidden = !digest; + copy.dataset.digest = digest; + const evidence = Array.isArray(plan.evidence) ? plan.evidence : []; + fill($('#mv-evidence'), evidence.map((e) => el('li', null, el('code', null, plainText(e.path, ''))))); + moreLine($('#mv-evidence-more'), count(plan.evidenceTotal) - evidence.length, 'evidence file', 'evidence files'); +} + +export async function copyDigest() { + const button = $('#mv-copy-digest'), digest = button.dataset.digest || ''; + if (!/^[0-9a-f]{64}$/.test(digest)) return; + try { + await navigator.clipboard.writeText(digest); + setText(button, 'Copied'); + } catch (error) { setText(button, 'Copy unavailable'); } + setTimeout(() => setText(button, 'Copy full digest'), 2000); +} diff --git a/lab/internal/ui/web/js/molecule-timeline.js b/lab/internal/ui/web/js/molecule-timeline.js new file mode 100644 index 0000000..f94cce9 --- /dev/null +++ b/lab/internal/ui/web/js/molecule-timeline.js @@ -0,0 +1,235 @@ +// ---- Molecule view: the Timeline tab ------------------------------------------------ +// The Atom list, evidence and logs, as before: the origin links, the totals, +// the Formula snapshot, each Atom's record, the readiness revisions and the +// harness attempts. +import { $, el, fill, state, ctx, ISSUE_URL, PULL_URL, externalLink, notRecorded, short, durationLabel, elapsed, api, failed, show } from './core.js'; +import { mv } from './molecule-decision.js'; + +function attemptsById(d) { return new Map((d.attempts || []).map((a) => [a.id, a])); } +function costText(usage) { + if (!usage || typeof usage.totalCostUsd !== 'number') return 'Not recorded'; + const basis = usage.costBasis === 'list' ? 'CLI list-price estimate' : usage.costBasis ? 'basis ' + usage.costBasis : 'basis unavailable'; + return '$' + usage.totalCostUsd.toFixed(4) + ' (' + basis + ')'; +} + +// renderTimeline is the Atom list, evidence and logs. While focus is inside it, +// a poll does not rebuild it; it is brought up to date once focus leaves. +export function renderTimeline(d) { + const panel = $('#mv-timeline'); + if (panel.contains(document.activeElement) && mv.timelineId === d.id) { mv.timelineDirty = true; return; } + mv.timelineDirty = false; + mv.timelineId = d.id; + const issueLink = externalLink(d.issue.url, ISSUE_URL, 'Issue #' + d.issue.number + ' ↗'); + const pullLink = d.delivery ? externalLink(d.delivery.url, PULL_URL, 'Pull request ↗') : null; + fill($('#run-origin'), + el('span', null, notRecorded(d.issue.repository) + ' #' + d.issue.number + (d.issue.state ? ' · ' + d.issue.state : '')), + issueLink || el('span', { class: 'muted' }, 'Issue link unavailable'), + pullLink || el('span', { class: 'muted' }, d.delivery ? 'PR link unavailable' : 'PR · not created'), + el('button', { type: 'button', class: 'text-button', dataset: { origin: 'issue-text' }, onclick: () => show('Issue text · ' + d.id, d.issue.body || 'No Issue text recorded.') }, 'Issue text ↗'), + d.sample ? null : el('button', { type: 'button', class: 'text-button', dataset: { origin: 'ledger' }, onclick: () => showLedger(d.id) }, 'Ledger record ↗'), + el('button', { type: 'button', class: 'text-button', id: 'clear-selection', dataset: { origin: 'clear' }, onclick: ctx.overview }, 'Clear selection')); + if (issueLink) issueLink.dataset.origin = 'issue'; + if (pullLink) pullLink.dataset.origin = 'pull'; + + const wall = elapsed(d.startedAt, d.finishedAt); + const attempts = d.attempts || []; + const costs = attempts.filter((a) => a.usage && typeof a.usage.totalCostUsd === 'number'); + fill($('#molecule-totals'), + el('div', null, el('span', { class: 'label' }, 'WALL-CLOCK DURATION'), el('strong', null, wall !== null ? durationLabel(wall) : d.startedAt ? 'Not finished' : 'Not recorded'), + el('small', null, 'Started ' + notRecorded(d.startedAt) + (d.finishedAt ? ' · finished ' + d.finishedAt : ''))), + el('div', null, el('span', { class: 'label' }, 'REPORTED COST · PARTIAL'), + el('strong', null, costs.length ? '$' + costs.reduce((n, a) => n + a.usage.totalCostUsd, 0).toFixed(4) : 'Not recorded'), + el('small', null, 'Cost reported for ' + costs.length + ' of ' + attempts.length + ' harness attempts · unknown is not zero')), + el('div', null, el('span', { class: 'label' }, 'WORKSPACE'), el('strong', { class: 'mono-text' }, notRecorded(d.workspace.branch)), + el('small', { title: d.workspace.baseCommit || '' }, 'Base commit ' + short(d.workspace.baseCommit)))); + + $('#formula-name').textContent = notRecorded(d.formulaId) + (d.formula.approved ? ' · approved snapshot' : ' · snapshot not marked approved'); + const sha = $('#formula-sha'); + sha.textContent = 'sha256 ' + notRecorded(d.formula.sha256); + const delivery = $('#run-delivery'); + delivery.hidden = !d.delivery; + if (d.delivery) { + delivery.textContent = d.delivery.label + (d.delivery.published ? ' · merge and production unverified' : '') + (d.delivery.error ? ' · ' + d.delivery.error : ''); + } + + renderAtoms(d); + renderReadiness(d); + renderAttempts(d); + const diagnostics = $('#run-diagnostics'); + fill(diagnostics, (d.diagnostics || []).map((text) => el('li', null, text))); + diagnostics.hidden = !(d.diagnostics || []).length; +} + +// Atom statuses the engine writes (done, failed, active, waiting, skipped, +// blocked, pending) and the sample fixture's labels, mapped to a tab mark and +// class; anything else shows its step number. +const ATOM_MARKS = { + done: ['✓', 'is-done'], completed: ['✓', 'is-done'], complete: ['✓', 'is-done'], passed: ['✓', 'is-done'], succeeded: ['✓', 'is-done'], + approved: ['✓', 'is-done'], recorded: ['✓', 'is-done'], 'draft published': ['✓', 'is-done'], + failed: ['✕', 'is-failed'], blocked: ['!', 'is-failed'], skipped: ['–', 'is-skipped'], + waiting: ['◇', 'is-waiting'], 'needs you': ['◇', 'is-waiting'], + active: [null, 'is-running'], running: [null, 'is-running'], +}; +function atomMark(status, i) { + const [mark, cls] = ATOM_MARKS[String(status || '').toLowerCase()] || [null, '']; + return { mark: mark || String(i + 1).padStart(2, '0'), cls }; +} +function defaultAtom(d) { + const atoms = d.atoms || []; + const current = atoms.findIndex((a) => a.id === d.currentAtom); + if (current >= 0) return current; + const failedAt = atoms.findIndex((a) => /fail|block/i.test(a.status || '')); + return failedAt >= 0 ? failedAt : 0; +} + +function renderAtoms(d) { + const atoms = d.atoms || []; + const box = $('#atoms'); + if (!atoms.length) { + fill(box, el('p', { class: 'scope-note' }, 'No Atoms are recorded for this Molecule.')); + return; + } + if (state.atomIndex === null || state.atomIndex >= atoms.length) state.atomIndex = defaultAtom(d); + const flow = el('div', { class: 'atom-flow', role: 'tablist', 'aria-label': 'Molecule Atoms' }, atoms.map((a, i) => { + const { mark, cls } = atomMark(a.status, i); + return el('button', { + type: 'button', role: 'tab', id: 'atom-tab-' + i, class: 'atom-tab' + (cls ? ' ' + cls : ''), + 'aria-selected': String(i === state.atomIndex), 'aria-controls': 'atom-detail', tabindex: i === state.atomIndex ? '0' : '-1', dataset: { atom: String(i) }, + }, el('span', { class: 'atom-number', 'aria-hidden': 'true' }, mark), el('strong', null, a.id), el('small', null, notRecorded(a.status))); + })); + flow.addEventListener('click', (e) => { const b = e.target.closest('[data-atom]'); if (b) chooseAtom(Number(b.dataset.atom)); }); + flow.addEventListener('keydown', (e) => { + const b = e.target.closest('[data-atom]'); + if (!b) return; + const i = Number(b.dataset.atom), n = atoms.length; + const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[e.key]; + if (next !== undefined) { e.preventDefault(); chooseAtom(next); } + }); + fill(box, flow, atomDetail(d, atoms[state.atomIndex], state.atomIndex)); +} + +function chooseAtom(i) { + state.atomIndex = i; + renderAtoms(state.detail); + const tab = $('#atom-tab-' + i); + if (tab) tab.focus({ preventScroll: true }); +} + +function tags(values, cls) { return (values || []).filter(Boolean).map((v) => el('span', { class: 'access-tag' + (cls ? ' ' + cls : '') }, v)); } + +function atomDetail(d, a, i) { + const p = a.provenance || {}; + const actions = p.actions || []; + const byId = attemptsById(d); + const attempt = actions.map((x) => byId.get(x.attemptId)).filter(Boolean).pop(); + const binding = actions.map((x) => x.executedBinding).filter(Boolean).pop() || p.declaredBinding; + const ms = a.elapsedMs || elapsed(a.startedAt, a.finishedAt); + const observed = actions.filter((x) => x.observed); + return el('div', { id: 'atom-detail', class: 'atom-detail', role: 'tabpanel', 'aria-labelledby': 'atom-tab-' + i, tabindex: '0' }, + el('div', { class: 'atom-detail-head' }, el('strong', null, a.id), el('span', null, notRecorded(a.status)), el('span', { class: 'source' }, notRecorded(a.type))), + el('dl', { class: 'atom-meta' }, + el('div', null, el('dt', null, 'Harness / binding'), el('dd', null, notRecorded(binding))), + el('div', null, el('dt', null, 'Model'), el('dd', null, attempt && attempt.model ? attempt.model : 'Not recorded')), + el('div', null, el('dt', null, 'Duration'), el('dd', null, ms ? durationLabel(ms) : 'Not recorded')), + el('div', null, el('dt', null, 'Cost'), el('dd', null, costText(attempt && attempt.usage)))), + // Planned capabilities: what the Formula and plan declared. Nothing here + // says the Atom used them. + el('div', { class: 'atom-access' }, + el('span', { class: 'label' }, 'PLANNED CAPABILITIES · DECLARED, NOT OBSERVED'), + el('div', null, tags([p.declaredSource && 'source ' + p.declaredSource, p.declaredBinding && 'binding ' + p.declaredBinding]), tags(p.declaredWorkflows, 'source-tag'), (!p.declaredSource && !p.declaredBinding && !(p.declaredWorkflows || []).length) ? el('span', { class: 'muted' }, 'None declared') : null)), + el('dl', { class: 'atom-behavior' }, + el('dt', null, 'Purpose'), el('dd', null, notRecorded(p.declaredPurpose)), + el('dt', null, 'Planned action'), el('dd', null, notRecorded(p.plannedAction)), + el('dt', null, 'Detail'), el('dd', null, notRecorded(a.detail)), + el('dt', null, 'Execution'), el('dd', null, notRecorded(p.execution), p.skipReason ? el('small', null, 'Skip reason: ' + p.skipReason) : null), + typeof a.exitCode === 'number' ? [el('dt', null, 'Exit code'), el('dd', null, String(a.exitCode))] : null), + // Actions taken: only what an observation recorded, with its evidence. + el('div', { class: 'atom-actions' }, + el('span', { class: 'label' }, 'ACTIONS TAKEN · OBSERVED'), + observed.length ? observed.map((x) => el('div', { class: 'atom-action' }, + el('p', null, el('strong', null, notRecorded(x.observed.action) + ' → ' + notRecorded(x.observed.result)), el('small', null, 'Attempt ' + notRecorded(x.attemptId) + ' · ' + notRecorded(x.startedAt) + ' → ' + notRecorded(x.observed.finishedAt))), + x.observed.externalAction ? el('p', { class: 'scope-note' }, 'External: ' + x.observed.externalAction + ' → ' + notRecorded(x.observed.externalResult)) : null, + (x.observed.evidence || []).length + ? el('ul', { class: 'evidence-list' }, x.observed.evidence.map((e) => evidenceItem(d, e))) + : el('p', { class: 'scope-note' }, 'No evidence references recorded.'))) + : el('p', { class: 'scope-note' }, actions.length ? 'Actions started; no observation recorded yet.' : 'No observed actions recorded.')), + // Context sources: what each started action resolved and ran under, as + // recorded, kept apart from the declarations above. + el('div', { class: 'atom-actions' }, + el('span', { class: 'label' }, 'CONTEXT SOURCES · AS RECORDED PER ACTION'), + actions.length + ? el('ul', { class: 'record-list' }, actions.map((x) => el('li', null, + el('strong', null, 'Attempt ' + notRecorded(x.attemptId)), + el('span', null, 'resolved source ' + notRecorded(x.resolvedSource)), + el('span', null, 'binding ' + notRecorded(x.executedBinding)), + el('span', null, 'planned ' + notRecorded(x.plannedAction)), + el('code', { title: x.planDigest || '' }, 'plan ' + short(x.planDigest))))) + : el('p', { class: 'scope-note' }, 'No actions started; no context source recorded.')), + el('div', { class: 'atom-buttons' }, + el('button', { type: 'button', class: 'text-button', dataset: { origin: 'atom-record' }, onclick: () => show('Atom record · ' + a.id + ' · ' + d.id, JSON.stringify(a, null, 2)) }, 'Atom record ↗'), + el('button', { type: 'button', class: 'text-button', dataset: { origin: 'atom-ledger' }, onclick: () => showLedger(d.id) }, 'Ledger record ↗'))); +} + +// One evidence reference. A file the ledger lists with a SHA-256 opens as +// bounded, redacted text; the server checks the digest and the file again. +function evidenceItem(d, e) { + return el('li', null, + el('code', null, e.path || e.pointer || 'reference'), + e.sha256 ? el('small', { title: e.sha256 }, 'sha256 ' + short(e.sha256)) : null, + e.note ? el('small', null, e.note) : null, + e.viewable && e.path + ? el('button', { type: 'button', class: 'text-button', dataset: { evidence: e.path }, onclick: () => showText('Evidence · ' + e.path + ' · ' + d.id, '/api/molecules/' + encodeURIComponent(d.id) + '/evidence?path=' + encodeURIComponent(e.path)) }, 'View ↗') + : null); +} + +function showLedger(id) { showText('Ledger record · ' + id, '/api/molecules/' + encodeURIComponent(id) + '/ledger'); } + +// showText fetches a bounded file view and shows it as text, with its size and +// whether it was cut; a refusal shows the server's category message. +async function showText(title, path) { + try { + const res = await api(path); + const body = await res.json().catch(() => ({})); + if (!res.ok) { show(title, 'Not served: ' + notRecorded(body && body.error), ''); return; } + const size = typeof body.size === 'number' ? body.size : null; + const note = body.truncated + ? 'Truncated · showing the first part of ' + (size === null ? 'an unknown number of' : size) + ' bytes. Secrets and local paths are redacted.' + : (size === null ? 'Size not recorded' : size + ' bytes') + ' · complete. Secrets and local paths are redacted.'; + show(title + (body.sha256 ? ' · sha256 ' + short(body.sha256) : ''), typeof body.text === 'string' ? body.text : '', note); + } catch (error) { failed(error); } +} + +function renderReadiness(d) { + const r = d.readiness; + const box = $('#readiness'); + if (!r) { fill(box, el('p', { class: 'scope-note' }, 'No readiness state recorded.')); return; } + fill(box, + el('p', { class: 'scope-note' }, 'Phase ' + notRecorded(r.phase)), + el('ul', { class: 'record-list' }, (r.plans || []).map((p) => el('li', null, el('strong', null, 'Revision ' + p.revision), el('code', { title: p.digest }, 'digest ' + short(p.digest)), el('code', { title: p.parentDigest || '' }, p.parentDigest ? 'parent ' + short(p.parentDigest) : 'no parent')))), + el('ul', { class: 'record-list events' }, (r.events || []).map((e) => el('li', null, el('span', { class: 'mono-text' }, notRecorded(e.at)), el('strong', null, notRecorded(e.kind)), e.detail ? el('span', null, e.detail) : null)))); +} + +function renderAttempts(d) { + const attempts = d.attempts || []; + fill($('#attempts'), attempts.length + ? el('ul', { class: 'record-list' }, attempts.map((a) => { + const u = a.usage, t = (u && u.total) || {}; + const tokens = u ? 'tokens in ' + notRecorded(t.input) + ' · out ' + notRecorded(t.output) : 'usage unavailable'; + return el('li', null, el('strong', null, a.kind + ' ' + a.id), el('span', null, notRecorded(a.status)), el('span', null, 'model ' + notRecorded(a.model || (u && (u.effectiveModels || []).join(', ')))), el('span', null, tokens), el('span', null, 'cost ' + costText(u))); + })) + : el('p', { class: 'scope-note' }, 'No harness attempts recorded.')); +} + +// mountTimeline wires the Formula snapshot's View YAML button. +export function mountTimeline() { + $('#yaml').addEventListener('click', async () => { + const id = state.selectedId; + if (!id) return; + try { + const res = await api('/api/molecules/' + encodeURIComponent(id) + '?yaml=1'); + if (!res.ok) { show('Formula snapshot', 'The snapshot could not be read.'); return; } + const d = await res.json(); + show('Formula snapshot · ' + notRecorded(d.formulaId) + ' · sha256 ' + short(d.formula.sha256), d.formula.yaml || 'No YAML recorded.'); + } catch (error) { failed(error); } + }); +} diff --git a/lab/internal/ui/web/js/molecule.js b/lab/internal/ui/web/js/molecule.js new file mode 100644 index 0000000..9671e19 --- /dev/null +++ b/lab/internal/ui/web/js/molecule.js @@ -0,0 +1,247 @@ +// ---- Molecule view --------------------------------------------------------------- +// The Molecule view (?view=work&m=…) replaces the grid under Work: a breadcrumb +// back, the state pill, the seven steps, the plan in plain words and a sticky +// decision card with one primary action, bound to the exact digest the plan +// shows. The plan, the decision card and the Timeline are its sub-modules. +import { $, add, el, fill, state, ctx, newGate, ISSUE_URL, laneLabel, cells, molecules, api, failed, keepFocus, VIEWS, navigate, showView, moleculeHref, moleculeFromURL, setText, setAttr, plainText, whenText, dateTitle, withAttempt, STEPS } from './core.js'; +import { mv, ensureMoleculeView, renderDecision, renderRoles, loadStats } from './molecule-decision.js'; +import { renderPlan, copyDigest } from './molecule-plan.js'; +import { renderTimeline, mountTimeline } from './molecule-timeline.js'; + +const TONES = ['needs', 'running', 'blocked', 'merged', 'neutral']; +const STEP_STATES = ['done', 'current', 'blocked', 'todo']; +const VIEW_NAMES = { inbox: 'Inbox', issues: 'Issues', work: 'Work', insights: 'Insights', formula: 'Formula' }; + +async function loadDetail(id, reset) { + const token = ++state.detailToken; + try { + const res = await api('/api/molecules/' + encodeURIComponent(id)); + if (token !== state.detailToken) return; + if (!res.ok) { + state.detail = null; + state.detailText = null; + renderMissingDetail(id, res.status); + return; + } + const text = await res.text(); + if (token !== state.detailToken) return; + // An unchanged detail is not re-rendered, so focus and scroll stay put. + if (!reset && state.detail && text === state.detailText) return; + const detail = JSON.parse(text); + if (reset || !state.detail || state.detail.id !== detail.id) state.atomIndex = null; + state.detail = detail; + state.detailText = text; + keepFocus(renderDetail); + } catch (error) { failed(error); } +} + +// ---- Selection -------------------------------------------------------------- +// The selected Molecule opens as the Molecule view, which replaces the grid +// under Work until the user goes back. +function select(id) { + // A Molecule opens under Work, from any view (the Insights queue, Start Work). + const elsewhere = state.view !== 'work'; + if (elsewhere) navigate('work', false); + if (id === state.selectedId && !$('#selected').hidden) { if (!elsewhere) overview(); return; } + state.selectedId = id; + state.detail = null; + state.atomIndex = null; + // The selection lives in the URL (?view=work&m=…), so reload and copied links + // restore it. + if (moleculeFromURL() !== id) history.replaceState({ view: 'work', m: id }, '', moleculeHref(id)); + showMoleculeView(true); + cells().forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.id === id))); + const m = molecules().find((x) => x.id === id); + $('#selection-label').textContent = m ? 'Selected #' + m.number + ' · ' + (m.title || m.id) : 'Selected ' + id; + renderLoadingDetail(id); + ctx.applyFilter(); + loadDetail(id, true); +} + +function overview() { + state.selectedId = null; + state.detail = null; + state.detailToken++; + clearTimeout(state.gate.timer); + state.gate = newGate(null); + if (moleculeFromURL()) history.replaceState({ view: state.view }, '', '?view=' + state.view); + showMoleculeView(false); + cells().forEach((b) => b.setAttribute('aria-pressed', 'false')); + $('#selection-label').textContent = 'All Molecules · select a cell to inspect'; + ctx.applyFilter(); +} + +// showMoleculeView swaps the grid for the Molecule view, or back. The shell's +// side panel steps aside while a Molecule is open, as in the mockup. +function showMoleculeView(open) { + $('#selected').hidden = !open; + $('.shelf').hidden = open; + $('.shell').classList.toggle('has-molecule', open); + if (!open) { mv.planKey = null; mv.statsKey = null; mv.stats = null; mv.lastDigest = null; chooseTab('plan', false); } +} + +function renderLoadingDetail(id) { + setText($('#mv-title'), 'Loading…'); + setText($('#mv-state-text'), 'Loading'); + setAttr($('#mv-state'), 'data-tone', 'neutral'); + setText($('#mv-crumb'), 'Molecule'); + renderBack(); + mv.planKey = null; + // The previous Molecule's plan and decision card never show under this one's title. + $('#mv-aside').hidden = true; + $('#mv-plan-sections').hidden = true; + $('#mv-no-plan').hidden = true; + $('#mv-steps').hidden = true; + $('#mv-planned').hidden = true; + for (const s of ['#run-origin', '#molecule-totals', '#atoms', '#readiness', '#attempts']) fill($(s)); +} + +function renderMissingDetail(id, status) { + renderLoadingDetail(id); + $('#mv-plan-sections').hidden = true; + $('#mv-no-plan').hidden = false; + setText($('#mv-no-plan'), status === 404 ? 'This Molecule is no longer in the Lab.' : 'This ledger was refused: it is invalid, unsafe, oversized or not this Lab\'s.'); + setText($('#mv-title'), status === 404 ? 'Molecule not found' : 'Ledger refused'); + add($('#run-origin'), el('button', { type: 'button', class: 'text-button', onclick: overview }, 'Clear selection')); +} + +// renderDetail patches the Molecule view: its header, plan, decision card and +// roles in place, and the Timeline tab, which keeps the Atoms, evidence and +// logs as before. +function renderDetail() { + const d = state.detail; + $('#mv-aside').hidden = false; + renderHeader(d); + renderPlan(d); + renderDecision(d); + renderRoles(d); + loadStats(d); + renderTimeline(d); +} + +// ---- Molecule view: header and steps ---------------------------------------------- +function renderBack() { + const from = VIEW_NAMES[mv.from] ? mv.from : 'work'; + const back = $('#mv-back'); + setAttr(back, 'href', '?view=' + from); + if (back.dataset.view !== from) back.dataset.view = from; + setText(back, VIEW_NAMES[from]); +} +function crumbText(d) { + const kind = d.view && d.view.decision && d.view.decision.kind; + const number = d.issue && Number.isInteger(d.issue.number) ? '#' + d.issue.number + ' · ' : ''; + return number + ({ scope: 'Review plan', delivery: 'Review & publish' }[kind] || 'Molecule'); +} +// pillText is the state in plain words, with the minutes of a running step. +function pillText(d) { + const s = d.view && d.view.state; + if (!s || typeof s.label !== 'string' || !s.label) return laneLabel(d); + return withAttempt(s.label, s.since, s.note); +} + +function renderHeader(d) { + const c = ensureMoleculeView(); + const v = d.view || {}, s = v.state || {}; + setText($('#mv-title'), (d.sample ? '[Sample] ' : '') + plainText(d.issue && d.issue.title, 'Untitled')); + setText($('#mv-crumb'), crumbText(d)); + renderBack(); + setAttr($('#mv-state'), 'data-tone', TONES.includes(s.tone) ? s.tone : 'neutral'); + setText($('#mv-state-text'), pillText(d)); + const number = d.issue && Number.isInteger(d.issue.number) ? '#' + d.issue.number : ''; + const url = d.issue && typeof d.issue.url === 'string' && ISSUE_URL.test(d.issue.url) ? d.issue.url : ''; + c.issue.hidden = !url; + c.issueText.hidden = Boolean(url) || !number; + if (url) setAttr(c.issue, 'href', url); + setText(c.issue, 'Issue ' + number + ' ↗'); + setText(c.issueText, 'Issue ' + number); + const plan = v.plan; + const when = plan ? whenText(plan.plannedAt) : ''; + const planned = $('#mv-planned'); + setText(planned, when ? '· planned ' + when + ' from the Issue as written then' : ''); + setAttr(planned, 'title', when ? dateTitle(plan.plannedAt) : ''); + planned.hidden = !when; + renderSteps(v); +} + +// renderSteps marks each of the seven steps from the server's states, with +// aria-current on the current one. +function renderSteps(v) { + const steps = Array.isArray(v.steps) && v.steps.length === STEPS.length ? v.steps : null; + $('#mv-steps').hidden = !steps; + if (!steps) return; + mv.steps.forEach((li, i) => { + const s = steps[i] && STEP_STATES.includes(steps[i].state) ? steps[i].state : 'todo'; + const current = i === v.current; + setAttr(li, 'class', 'mv-step is-' + s + (current ? ' is-now' : '')); + if (current) setAttr(li, 'aria-current', 'step'); + else if (li.hasAttribute('aria-current')) li.removeAttribute('aria-current'); + setText(li.lastChild, STEPS[i] + (s === 'done' ? ' · done' : s === 'blocked' ? ' · stopped' : current ? ' · now' : '')); + }); +} + +// The Plan and Timeline tabs. The Timeline keeps the Atom list, evidence and +// logs unchanged. +function chooseTab(tab, focus) { + const plan = tab !== 'timeline'; + for (const [tabId, panelId, on] of [['#mv-tab-plan', '#mv-plan', plan], ['#mv-tab-timeline', '#mv-timeline', !plan]]) { + const button = $(tabId); + button.setAttribute('aria-selected', String(on)); + button.tabIndex = on ? 0 : -1; + $(panelId).hidden = !on; + if (on && focus) button.focus(); + } + if (!plan && mv.timelineDirty && state.detail) renderTimeline(state.detail); +} + +// ---- Molecule links and history ---------------------------------------------------- +// restoreSelection follows the URL: ?m= opens that Molecule's view, and a URL +// without one closes it, so Back and Forward move between the two. +function restoreSelection() { + const id = moleculeFromURL(); + const from = history.state && history.state.from; + if (VIEWS.includes(from)) mv.from = from; + if (state.view === 'work' && id && id !== state.selectedId) select(id); + else if (!id && state.selectedId) overview(); +} +function openMolecule(id) { + const from = state.view || 'work'; + if (location.search !== moleculeHref(id)) history.pushState({ view: 'work', m: id }, '', moleculeHref(id)); + rememberFrom(from); + showView('work'); + if (id !== state.selectedId || $('#selected').hidden) select(id); + window.scrollTo(0, 0); + $('#mv-title').focus({ preventScroll: true }); +} +// rememberFrom keeps the view a Molecule was opened from in its history entry, +// so its breadcrumb leads back there after Back, Forward or a reload. +function rememberFrom(from) { + mv.from = VIEWS.includes(from) ? from : 'work'; + renderBack(); + try { history.replaceState(Object.assign({}, history.state, { from: mv.from }), ''); } catch (error) { /* the breadcrumb falls back to Work */ } +} + +// The Molecule view mounts on #selected, inside the Work panel. It registers +// the calls other modules make: opening a Molecule, following the URL, reading +// a detail back after a gate, re-rendering the decision card and closing the +// view. +export function mount(root, ctx) { + Object.assign(ctx, { openMolecule, restoreSelection, loadDetail, renderDecision, overview }); + $('#mv-tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (b) chooseTab(b.dataset.tab, false); }); + $('#mv-tabs').addEventListener('keydown', (e) => { + const b = e.target.closest('[data-tab]'); + if (!b || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; + e.preventDefault(); + chooseTab(e.key === 'Home' ? 'plan' : e.key === 'End' ? 'timeline' : b.dataset.tab === 'plan' ? 'timeline' : 'plan', true); + }); + $('#mv-timeline').addEventListener('focusout', (e) => { + if (mv.timelineDirty && state.detail && !$('#mv-timeline').contains(e.relatedTarget)) renderTimeline(state.detail); + }); + $('#mv-copy-digest').addEventListener('click', copyDigest); + // A running step's minutes move on without a ledger change. + setInterval(() => { if (!document.hidden && state.detail && !$('#selected').hidden) setText($('#mv-state-text'), pillText(state.detail)); }, 30000); + mountTimeline(); +} + +// update reads the selected Molecule back after a poll; an unchanged detail is +// not re-rendered. +export function update(state) { if (state.selectedId) loadDetail(state.selectedId, false); } diff --git a/lab/internal/ui/web/js/work.js b/lab/internal/ui/web/js/work.js new file mode 100644 index 0000000..576fe04 --- /dev/null +++ b/lab/internal/ui/web/js/work.js @@ -0,0 +1,180 @@ +// ---- Work: grid, filters and search ------------------------------------------- +// The contribution-style Molecule grid under Work, its lane bar, search and +// tooltip. A cell opens the Molecule view, which ctx.openMolecule owns. +import { $, el, fill, add, state, LANES, RETIRED, notRecorded, molecules, laneOf, live, laneLabel, cells } from './core.js'; + +// The lane bar. Paused is not a filter: the engine has no pause semantics. +const FILTERS = [ + ['all', 'All'], ['running', 'Running'], ['decision', 'Needs my decision'], + ['blocked', 'Blocked'], ['finished', 'Finished · Draft published'], ['other', 'Other'], ['retired', 'Retired'], +]; + +function statusLabel(m) { + return laneLabel(m) + ' · ' + notRecorded(m.status) + (m.phase ? ' · ' + m.phase : ''); +} + +function matches(m) { + const inLane = state.filter === 'all' ? live(m) : m.lane === state.filter; + const terms = state.query.toLowerCase().trim().split(/\s+/).filter(Boolean); + const haystack = ('#' + m.number + ' ' + (m.title || '') + ' ' + (m.formulaId || '') + ' ' + m.id).toLowerCase(); + return inLane && terms.every((term) => haystack.includes(term)); +} + +function renderGrid() { + const list = state.list; + const all = molecules(); + const counts = list.counts || {}; + const sample = Boolean(list.sample); + $('#shelf-count').textContent = (sample ? 'Sample data · ' : '') + all.filter(live).length + ' Molecules · ' + + Object.entries(LANES).map(([id, lane]) => (counts[id] || 0) + ' ' + lane.short).join(' · ') + + (counts[RETIRED] ? ' · ' + counts[RETIRED] + ' retired (not counted)' : ''); + renderNote(list); + const cubes = $('#cubes'); + fill(cubes, all.map((m, i) => { + const lane = laneOf(m); + const button = el('button', { + type: 'button', class: 'cube-button ' + lane.cls, dataset: { id: m.id }, + 'aria-pressed': String(m.id === state.selectedId), + 'aria-label': 'Molecule ' + m.id + ': #' + m.number + ' ' + (m.title || 'untitled') + ', ' + statusLabel(m), + tabindex: '-1', + }, el('span', { class: 'micro-cube', 'aria-hidden': 'true' }, lane.symbol)); + button.style.setProperty('--delay', '-' + ((i % 9) * 0.47).toFixed(2) + 's'); + return button; + })); + renderFilters(); + applyFilter(); +} + +// The note is a status region: it is rebuilt, and so announced, only when its +// content changes, and an open Diagnostics list stays open. +function renderNote(list) { + const diagnostics = list.complete ? [] : (list.diagnostics || []).slice(0, 50); + const key = JSON.stringify([Boolean(list.complete), Boolean(list.truncated), (list.diagnostics || []).length, diagnostics]); + if (key === state.noteKey) return; + state.noteKey = key; + const note = $('#inventory-note'); + const wasOpen = Boolean(note.querySelector('details[open]')); + fill(note); + if (!list.complete) { + const count = (list.diagnostics || []).length; + add(note, el('strong', null, list.truncated ? 'Inventory truncated by safety bounds. ' : 'Inventory incomplete. '), + count + ' ledger problem' + (count === 1 ? '' : 's') + ' reported; those Molecules are not shown and are not counted.', + count ? el('details', { open: wasOpen }, el('summary', null, 'Diagnostics'), el('ul', null, diagnostics.map((d) => el('li', null, d)))) : null); + } + note.hidden = Boolean(list.complete); +} + +// Each lane is a segment of one bar: its cube-legend dot, label and count. +// Retired sits last, after a hairline divider. +function renderFilters() { + const counts = (state.list && state.list.counts) || {}; + fill($('#run-filters'), FILTERS.flatMap(([id, label]) => [ + id === RETIRED ? el('span', { class: 'lane-divider', 'aria-hidden': 'true' }) : null, + el('button', { + type: 'button', class: 'lane-segment', dataset: { filter: id }, 'aria-pressed': String(state.filter === id), + onclick: () => { hideTip(); state.filter = id; renderFilters(); applyFilter(); $('[data-filter="' + id + '"]').focus({ preventScroll: true }); }, + }, id === 'all' ? null : el('span', { class: 'lane-dot lane-dot-' + id, 'aria-hidden': 'true' }), + el('span', { class: 'lane-name' }, label), + el('span', { class: 'lane-count' }, String(id === 'all' ? counts.all || 0 : counts[id] || 0))), + ])); +} + +function applyFilter() { + const byId = new Map(molecules().map((m) => [m.id, m])); + const all = cells(); + all.forEach((b) => { b.hidden = !matches(byId.get(b.dataset.id)); b.tabIndex = -1; }); + const shown = all.filter((b) => !b.hidden); + const chosen = shown.find((b) => b.dataset.id === state.selectedId) || shown[0]; + if (chosen) chosen.tabIndex = 0; + const empty = $('#filter-empty'); + empty.hidden = shown.length > 0; + empty.textContent = molecules().length === 0 ? 'No Molecules are recorded in this Lab yet.' : state.query.trim() ? 'No matching Molecules in this filter. Clear search or choose All.' : 'No Molecules in this lane.'; + $('#clear-search').hidden = !state.query; + const label = (FILTERS.find(([id]) => id === state.filter) || FILTERS[0])[1]; + $('#search-status').textContent = state.query.trim() ? shown.length + ' matching ' + (shown.length === 1 ? 'Molecule' : 'Molecules') + ' · ' + (state.filter === 'all' ? 'all lanes' : label) : ''; + const outside = $('#outside-filter'); + const selected = state.selectedId && byId.get(state.selectedId); + outside.hidden = !(state.selectedId && !$('#selected').hidden && !(selected && matches(selected))); + outside.textContent = selected ? 'The selected Molecule ' + selected.id + ' is outside this filter; its details stay open below.' : 'The selected Molecule is no longer in the inventory.'; +} + +function clearSearch() { state.query = ''; $('#molecule-search').value = ''; applyFilter(); $('#molecule-search').focus({ preventScroll: true }); } + +// ---- Tooltip -------------------------------------------------------------- +let tipTimer = null, tipAnchor = null; +const tip = $('#cube-tooltip'); +function hideTip() { clearTimeout(tipTimer); tip.hidden = true; if (tipAnchor) tipAnchor.removeAttribute('aria-describedby'); tipAnchor = null; } +function queueHideTip() { clearTimeout(tipTimer); tipTimer = setTimeout(hideTip, 180); } +function showTip(button) { + const m = molecules().find((x) => x.id === button.dataset.id); + if (!m) return; + clearTimeout(tipTimer); + if (tipAnchor) tipAnchor.removeAttribute('aria-describedby'); + tipAnchor = button; + fill(tip, + el('span', { class: 'label' }, 'MOLECULE #' + m.number + ' · ' + m.id), + el('strong', null, m.title || 'Untitled'), + el('dl', null, el('dt', null, 'Current Atom'), el('dd', null, m.currentAtom || 'None'), el('dt', null, 'Lane'), el('dd', null, laneOf(m).label), el('dt', null, 'Status'), el('dd', null, notRecorded(m.status) + (m.phase ? ' · ' + m.phase : '')))); + tip.hidden = false; + button.setAttribute('aria-describedby', 'cube-tooltip'); + const box = button.getBoundingClientRect(); + const width = tip.offsetWidth; + tip.style.left = Math.max(12, Math.min(innerWidth - width - 12, box.left + box.width / 2 - width / 2)) + 'px'; + tip.style.top = (box.top > tip.offsetHeight + 16 ? box.top - tip.offsetHeight - 8 : box.bottom + 8) + 'px'; +} + +// Work mounts on its data-view-panel: the search, grid and tooltip listeners, +// the Motion checkbox and the offscreen pause. +export function mount(root, ctx) { + ctx.hideTip = hideTip; + ctx.applyFilter = applyFilter; + + $('#molecule-search').addEventListener('input', (e) => { hideTip(); state.query = e.target.value; applyFilter(); }); + $('#clear-search').addEventListener('click', clearSearch); + $('#molecule-search').addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.query) { e.preventDefault(); clearSearch(); } }); + + const cubes = $('#cubes'); + cubes.addEventListener('click', (e) => { + const button = e.target.closest('[data-id]'); + // A cell opens the Molecule view through history.pushState, so Back returns + // to the grid. + if (button) { hideTip(); ctx.openMolecule(button.dataset.id); } + }); + // Seven-row, column-major movement among visible cells, as in the prototype. + cubes.addEventListener('keydown', (e) => { + const button = e.target.closest('[data-id]'); + if (!button) return; + const shown = cells().filter((b) => !b.hidden); + const at = shown.indexOf(button); + const delta = { ArrowRight: 7, ArrowLeft: -7, ArrowDown: 1, ArrowUp: -1 }[e.key]; + let next = null; + if (delta !== undefined) next = shown[Math.max(0, Math.min(shown.length - 1, at + delta))]; + if (e.key === 'Home') next = shown[0]; + if (e.key === 'End') next = shown[shown.length - 1]; + if (next) { + e.preventDefault(); + button.tabIndex = -1; + next.tabIndex = 0; + next.focus(); + } + }); + cubes.addEventListener('pointerover', (e) => { const b = e.target.closest('[data-id]'); if (b) showTip(b); }); + cubes.addEventListener('pointerout', (e) => { if (e.target.closest('[data-id]')) queueHideTip(); }); + cubes.addEventListener('focusin', (e) => { const b = e.target.closest('[data-id]'); if (b) showTip(b); }); + cubes.addEventListener('focusout', hideTip); + + tip.addEventListener('pointerenter', () => clearTimeout(tipTimer)); + tip.addEventListener('pointerleave', hideTip); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTip(); }); + window.addEventListener('resize', hideTip); + document.addEventListener('scroll', hideTip, true); + + $('#motion-toggle').addEventListener('change', (e) => document.body.classList.toggle('motion-paused', !e.target.checked)); + if ('IntersectionObserver' in window) { + new IntersectionObserver(([entry]) => document.body.classList.toggle('shelf-offscreen', !entry.isIntersecting)).observe($('.shelf')); + } +} + +// update renders the grid, its count, the inventory note and the lane bar from +// the list. +export function update(state) { renderGrid(); } From 2b0c279d15835b075ed60b8bb71101f36620d544 Mon Sep 17 00:00:00 2001 From: Daniel Netzer Date: Tue, 27 Oct 2026 14:38:22 +0200 Subject: [PATCH 2/2] chore(ui): remove the old web/style.css (no longer served or referenced after the split) Co-Authored-By: Claude Opus 5.5 --- lab/internal/ui/web/style.css | 682 ---------------------------------- 1 file changed, 682 deletions(-) delete mode 100644 lab/internal/ui/web/style.css diff --git a/lab/internal/ui/web/style.css b/lab/internal/ui/web/style.css deleted file mode 100644 index ae2c289..0000000 --- a/lab/internal/ui/web/style.css +++ /dev/null @@ -1,682 +0,0 @@ -/* Forgecell local Lab UI. Consolidated from the forest-glass prototype - (docs/design/ui/forest-glass/style.css): its final light palette with the - appended overrides merged into one set of rules. The app shell follows the - approved redesign mockup (docs/design/ui/redesign-2026-10-26). Nothing is - fetched from a network: the font and art are served from the embedded - /assets/, and the font falls back to the system stack. The server replaces - __ASSET_VERSION__ with the binary's version, so the long asset cache never - outlives an upgrade. */ - -@font-face { font-family: "Instrument Sans"; font-style: normal; font-weight: 400; font-display: swap; src: url("/assets/instrument-sans-400.woff2?v=__ASSET_VERSION__") format("woff2"); } -@font-face { font-family: "Instrument Sans"; font-style: normal; font-weight: 500 600; font-display: swap; src: url("/assets/instrument-sans-500.woff2?v=__ASSET_VERSION__") format("woff2"); } - -:root { - color-scheme: light; - --base: #faf7ec; - --forest: #183e32; - --teal: #239f89; - --mint: #b4e3ce; - --paper: #fffef9; - --ink: #183e32; - --muted: #576e62; - --soft: #627966; - --line: #254d3224; - --edge: #b9ccba; - --edge-strong: #7f9f89; - --card-edge: #c9d8c8; - --glass: #fbfcf6e6; - --glass-solid: #fbfcf6; - --focus: #14523c; - --radius-panel: 20px; - --radius-card: 14px; - --radius-control: 12px; - --shadow-glass: inset 0 1px 0 #ffffffcc, 0 1px 2px #1a3a2a14, 0 12px 32px #1a3a2a12; - --shadow-card: 0 1px 2px #1a3a2a0f, 0 4px 12px #1a3a2a08; - --shell-edge: #d9e3d6; - --needs-ink: #7a5410; - --needs-bg: #f6e7c8; - --danger-ink: #8a3322; - --danger-edge: #e2b9ad; -} - -* { box-sizing: border-box; } -body { margin: 0; background: var(--base); color: var(--ink); font: 15px/1.5 "Instrument Sans", system-ui, -apple-system, "Segoe UI", sans-serif; overflow-x: hidden; } -button, a, input { font: inherit; } -a, button, summary, input, label { touch-action: manipulation; } -button { cursor: pointer; min-height: 44px; transition: background .2s, border-color .2s; } -button:disabled { cursor: default; } -a { color: inherit; } -button:focus-visible, a:focus-visible, summary:focus-visible, [tabindex]:focus-visible, .motion-option input:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; } -h1, h2, h3, p { margin: 0; } -h1 { font-size: 18px; font-weight: 500; letter-spacing: -.4px; } -h2 { font-size: 20px; font-weight: 500; letter-spacing: -.5px; line-height: 1.3; overflow-wrap: anywhere; } -h3 { font-weight: 500; } -code, .mono-text { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; overflow-wrap: anywhere; } -[hidden] { display: none !important; } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } -.muted { color: var(--muted); } -.mono, .label { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .11em; } -.mono { font-size: 10px; } -.label { font-size: 9px; color: #53665b; } - -.field { position: fixed; inset: -80px; z-index: -1; background: radial-gradient(ellipse at 90% 10%, #d3e9de99, transparent 55%), radial-gradient(ellipse at 0 75%, #e5edce80, transparent 50%), var(--base); filter: blur(70px); } - -/* App shell: header, left nav, one main and the side panel. */ -.skip-link { position: absolute; left: 16px; top: -80px; z-index: 200; padding: 10px 16px; border-radius: var(--radius-control); background: var(--forest); color: var(--paper); text-decoration: none; } -.skip-link:focus { top: 12px; } -.app-header { position: relative; z-index: 2; display: flex; align-items: center; gap: 16px; min-height: 72px; padding: 0 32px; border-bottom: 1px solid #1e4d3b14; background: #fbfaf3f2; } -@supports (backdrop-filter: blur(16px)) { .app-header { background: #fbfaf3cc; backdrop-filter: blur(16px) saturate(160%); } } -.brand { display: flex; align-items: center; gap: 10px; font-size: 19px; font-weight: 600; letter-spacing: -.01em; } -.divider { height: 24px; width: 1px; background: #1e4d3b26; } -.repo { min-width: 0; font-size: 14px; color: #3e5448; overflow-wrap: anywhere; } -.repo a { text-decoration: none; } -.repo a:hover { text-decoration: underline; text-underline-offset: 3px; } -.readonly-badge, .sample-badge { flex: none; } -.app-header .readonly-badge, .app-header .sample-badge { margin-left: auto; } -.app-header .sample-badge + .readonly-badge { margin-left: 0; } -.loop-pill { position: relative; display: flex; align-items: center; gap: 8px; min-height: 40px; margin-left: auto; padding: 0 6px 0 14px; border-radius: 999px; background: var(--paper); border: 1px solid var(--shell-edge); color: #3e5448; font-size: 13px; font-variant-numeric: tabular-nums; } -.readonly-badge:not([hidden]) ~ .loop-pill, .sample-badge:not([hidden]) ~ .loop-pill { margin-left: 0; } -.loop-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: #9fb3a6; } -.loop-pill[data-loop=running] .loop-dot { background: var(--teal); } -.loop-pill[data-loop=draining] .loop-dot { background: #d99a2b; } -.loop-pill-action { height: 30px; min-height: 30px; padding: 0 12px; border-radius: 999px; font-size: 13px; text-decoration: none; } -.loop-stop > summary { list-style: none; cursor: pointer; } -.loop-stop > summary::-webkit-details-marker { display: none; } -.loop-stop-confirm { position: absolute; right: 0; top: calc(100% + 8px); z-index: 50; width: 280px; max-width: calc(100vw - 32px); padding: 16px; border-radius: var(--radius-card); background: var(--paper); border: 1px solid var(--card-edge); box-shadow: var(--shadow-glass); color: var(--ink); } -.loop-stop-confirm p:first-child { font-weight: 500; overflow-wrap: anywhere; } -.loop-stop-confirm .btn { margin-top: 12px; width: 100%; } -.avatar { flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--forest); background: #1e4d3b; color: var(--paper); font-size: 14px; font-weight: 600; } - -.shell { position: relative; z-index: 1; display: grid; grid-template-columns: 216px minmax(0, 1fr) 300px; gap: 28px; align-items: start; max-width: 1440px; margin: 0 auto; padding: 28px 32px; } -main { min-width: 0; } -main:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; } -.app-nav { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 4px; padding: 10px; border-radius: var(--radius-panel); background: var(--glass-solid); border: 1px solid var(--shell-edge); box-shadow: var(--shadow-glass); } -@supports (backdrop-filter: blur(20px)) { .app-nav { background: var(--glass); backdrop-filter: blur(20px) saturate(160%); } } -.nav-link { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; border: 1px solid transparent; border-radius: var(--radius-control); color: #3e5448; font-size: 14px; text-decoration: none; } -.nav-link:hover { background: #1e4d3b0d; color: var(--ink); } -.nav-link[aria-current=page] { background: var(--paper); color: var(--ink); font-weight: 500; border-color: var(--shell-edge); box-shadow: 0 1px 2px #1a3a2a14; } -.nav-badge { margin-left: auto; min-width: 22px; height: 22px; padding: 0 7px; border-radius: 999px; background: var(--needs-bg); color: var(--needs-ink); font-size: 12px; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; font-variant-numeric: tabular-nums; } -.view { min-width: 0; } -.placeholder { padding: 28px; } -.placeholder h1, .formula-view h1, .aggregate h1 { font-size: 28px; letter-spacing: -.02em; } -.placeholder-summary { margin-top: 6px; font-size: 15px; color: var(--needs-ink); font-weight: 500; } -.placeholder-note { margin-top: 12px; max-width: 62ch; font-size: 14px; color: #56695e; } -.formula-view { padding: 24px; } -.side-panel { display: flex; flex-direction: column; gap: 20px; min-width: 0; } -.side-panel .drive-panel { margin-top: 0; } -.side-panel .drive-groups { grid-template-columns: minmax(0, 1fr); } - -/* The resident (Blob): a three-frame sprite, studying a cube, reading and - waving. Only the working pose floats. */ -.resident { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; } -.resident-figure { position: relative; width: 132px; height: 132px; } -.resident-sprite { position: absolute; inset: 0; background: url("/assets/lab-companions.webp?v=__ASSET_VERSION__") center center / 300% 100% no-repeat; filter: drop-shadow(0 10px 10px #0f2e2433); } -.resident[data-pose=studying] .resident-sprite { background-position: left center; animation: resident-float 4.2s ease-in-out infinite; } -.resident[data-pose=reading] .resident-sprite { background-position: center center; } -.resident[data-pose=waving] .resident-sprite { background-position: right center; } -.resident-dish { position: absolute; left: 22px; right: 22px; bottom: -4px; height: 14px; border-radius: 50%; background: #c8e2d366; border: 1px solid #8caf9b80; } -.resident-title { margin-top: 10px; font-size: 16px; font-weight: 500; font-variant-numeric: tabular-nums; } -.resident-copy { font-size: 13px; color: #56695e; } -@keyframes resident-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } - -/* Art: decorative, behind the nav and side panel edges, never behind text. */ -.art { position: fixed; z-index: 0; pointer-events: none; user-select: none; opacity: .5; } -.art-fern { left: -150px; top: 220px; width: 300px; height: 450px; } -.art-orchid { right: -130px; bottom: -50px; width: 360px; height: 300px; } - -/* The bottom tab bar replaces the nav on phones. */ -.tab-bar { display: none; } -.tab { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 60px; font-size: 11px; color: #56695e; text-decoration: none; } -.tab[aria-current=page], .tab-more.is-current > .tab { color: var(--ink); font-weight: 600; } -.tab-badge { position: absolute; top: 4px; left: calc(50% + 6px); margin: 0; min-width: 18px; height: 18px; padding: 0 5px; font-size: 11px; } -.tab-more { flex: 1; display: flex; } -.tab-more > summary { list-style: none; cursor: pointer; } -.tab-more > summary::-webkit-details-marker { display: none; } -.tab-more-menu { position: absolute; right: 8px; bottom: calc(100% + 8px); display: grid; gap: 4px; min-width: 180px; padding: 8px; border-radius: var(--radius-card); background: var(--paper); border: 1px solid var(--card-edge); box-shadow: var(--shadow-glass); } -.sample-badge, .readonly-badge { font: 10px ui-monospace, monospace; border: 1px solid #aac5b2; border-radius: 5px; padding: 3px 6px; color: #53715e; white-space: nowrap; } -.sample-badge { background: #fbe9c9; border-color: #c9a35d; color: #6b4a12; font-weight: 600; } -.banner { margin-bottom: 16px; padding: 12px 16px; border-radius: 10px; background: #fbe9c9; border: 1px solid #c9a35d; color: #5a3d0c; font-size: 13px; } - -/* Glass: opaque paper by default, translucent only where backdrop-filter works. - A crisp 1px edge, a 1px inner top highlight and a layered shadow. */ -.glass { background: var(--glass-solid); border: 1px solid #b3cbb9; border-radius: var(--radius-panel); box-shadow: var(--shadow-glass); } -@supports (backdrop-filter: blur(20px)) { .glass { background: var(--glass); backdrop-filter: blur(20px) saturate(160%); } } -.card { background: var(--paper); color: var(--ink); border: 1px solid var(--card-edge); border-radius: var(--radius-card); box-shadow: var(--shadow-card); } - -/* Shelf: the contribution-style Molecule grid. One cell is one Molecule. */ -.shelf { position: relative; margin-top: 0; padding: 20px 26px; background: linear-gradient(130deg, #fffffff2, #edf5e9e8); overflow: hidden; } -.shelf::before { content: ''; position: absolute; inset: 90px 0 0; background-image: linear-gradient(#235f3a08 1px, transparent 1px), linear-gradient(90deg, #235f3a08 1px, transparent 1px); background-size: 26px 26px; pointer-events: none; mask-image: linear-gradient(transparent, #000); } -.shelf > * { position: relative; } -.shelf-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; } -.shelf-count { font-size: 13px; color: var(--muted); margin-top: 5px; } -.inventory-note { margin-top: 10px; font-size: 12px; color: #5a3d0c; background: #fbe9c9a6; border: 1px solid #d9b77a; border-radius: 8px; padding: 8px 12px; overflow-wrap: anywhere; } -.inventory-note summary { cursor: pointer; min-height: 28px; } -.inventory-note ul { margin: 4px 0 0; padding-left: 18px; } - -.active-formula { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; margin-top: 13px; border: 1px solid var(--card-edge); border-radius: var(--radius-card); background: var(--paper); box-shadow: var(--shadow-card); } -.active-formula > div:first-child { min-width: 0; } -.active-formula strong { display: block; font-size: 12px; font-weight: 500; margin: 3px 0; overflow-wrap: anywhere; } -.active-formula small { display: block; font-size: 10px; color: var(--muted); } -.version-tag { display: inline-block; padding: 1px 6px; margin-left: 5px; background: #d4e8d6; border-radius: 4px; font: 10px ui-monospace, monospace; color: #22573c; } -.formula-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; } -.unavailable-tag { font-size: 10px; padding: 3px 7px; border: 1px dashed #9fb7a3; border-radius: 5px; color: var(--muted); } -.unavailable { font-size: 18px; font-weight: 500; margin: 6px 0 0; } - -/* Start Work: two composed groups, Start intake and Run the loop. */ -.drive-panel { margin-top: 13px; padding: 14px 16px; border: 1px solid var(--card-edge); border-radius: var(--radius-card); background: var(--paper); box-shadow: var(--shadow-card); min-width: 0; } -.drive-groups { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr); gap: 14px 28px; margin-top: 10px; } -.drive-group { display: grid; gap: 8px; align-content: start; min-width: 0; } -.drive-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; min-width: 0; } -.drive-field { display: grid; gap: 4px; flex: 1 1 150px; min-width: 0; } -.drive-field-stepper { flex: 0 0 auto; } -.field-label { font-size: 11px; color: var(--muted); } -.drive-field input, .stepper input { height: 40px; min-width: 0; width: 100%; padding: 0 12px; border: 1px solid var(--edge); border-radius: var(--radius-control); background: #fdfdf8; color: var(--ink); font-size: 14px; box-shadow: inset 0 1px 2px #1a3a2a12; transition: border-color .15s, box-shadow .15s; } -.drive-field input:hover, .stepper input:hover { border-color: var(--edge-strong); } -.drive-field input:focus-visible, .stepper input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-color: var(--edge-strong); } -.drive-field input::placeholder { color: var(--muted); opacity: 1; } -.stepper { display: inline-flex; align-items: center; gap: 4px; } -.stepper input { width: 56px; padding: 0 6px; text-align: center; font-variant-numeric: tabular-nums; appearance: textfield; -moz-appearance: textfield; } -.stepper input::-webkit-inner-spin-button, .stepper input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } -.stepper-button { width: 40px; height: 40px; min-height: 40px; padding: 0; border: 1px solid var(--edge); border-radius: var(--radius-control); background: var(--paper); color: var(--forest); font-size: 18px; line-height: 1; } -.stepper-button:hover { border-color: var(--edge-strong); background: #f1f6ee; } -.stepper-button:active { background: #e6efe4; } -.drive-status { padding: 7px 12px; border: 1px solid #d6e2d4; border-radius: var(--radius-control); background: #f1f6ee; color: #345642; font-size: 12px; overflow-wrap: anywhere; } -.drive-status:empty { display: none; } -.drive-status > :first-child, .drive-status .gate-job > :first-child { margin-top: 0; } -.drive-status .diagnostics { margin-top: 8px; } -.drive-help { margin-top: 12px; max-width: 70ch; font-size: 12px; line-height: 1.5; color: var(--muted); } - -/* Run the loop and Start intake: real forms in the side panel. */ -.drive-forms { display: grid; gap: 20px; min-width: 0; } -.drive-form { display: flex; flex-direction: column; gap: 14px; padding: 22px; border-radius: var(--radius-panel); } -.drive-form .drive-help { margin-top: 2px; } -.drive-form:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; } -.drive-title { font-size: 16px; font-weight: 500; letter-spacing: 0; } -.segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border-radius: var(--radius-control); background: #eef0e6; border: 1px solid var(--shell-edge); } -.seg { min-height: 34px; padding: 0 6px; border: 0; border-radius: 9px; background: transparent; color: #3e5448; font-size: 13px; font-weight: 500; } -.seg:hover { background: #ffffff99; } -.seg[aria-pressed=true] { background: var(--paper); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px #1a3a2a24; } -.drive-at-once { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.drive-at-once .field-label { font-size: 13px; font-weight: 500; color: var(--ink); } -.drive-submit { width: 100%; } -.drive-error { padding: 8px 12px; border-radius: var(--radius-control); background: #f4ddd5; color: var(--danger-ink); font-size: 13px; overflow-wrap: anywhere; } -.drive-error:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } - -/* Inbox: the greeting, Needs-you cards with one primary action, In-progress - rows with the seven-step bar, and Recently recorded links. */ -.inbox { display: flex; flex-direction: column; gap: 28px; } -.inbox-greeting { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px 24px; flex-wrap: wrap; } -.inbox-greeting h1 { font-size: 34px; letter-spacing: -.02em; text-wrap: balance; overflow-wrap: anywhere; } -.inbox-counts { margin-top: 6px; font-size: 15px; color: #56695e; font-variant-numeric: tabular-nums; } -.inbox-counts strong { color: var(--needs-ink); font-weight: 600; } -.inbox-section { display: flex; flex-direction: column; gap: 14px; min-width: 0; } -.inbox-heading { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #6b7f72; } -.inbox-list { display: flex; flex-direction: column; gap: 14px; min-width: 0; } -.inbox-list:empty, .inbox-recent:empty { display: none; } -.inbox-empty, .inbox-more { font-size: 14px; color: #56695e; } -.inbox-card { display: flex; align-items: center; gap: 20px; padding: 22px 24px; min-width: 0; } -.inbox-card.is-blocked { border-style: dashed; } -.inbox-icon { flex: 0 0 48px; height: 48px; display: grid; place-items: center; border-radius: var(--radius-card); background: var(--needs-bg); color: var(--needs-ink); font-size: 20px; font-weight: 600; } -.inbox-card.is-ready .inbox-icon, .inbox-card.is-ready .inbox-badge { background: #dcefe9; color: #1f5f52; } -.inbox-card.is-blocked .inbox-icon, .inbox-card.is-blocked .inbox-badge { background: #f4ddd5; color: var(--danger-ink); } -.inbox-body { flex: 1; min-width: 0; } -.inbox-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } -.inbox-badge { display: inline-flex; align-items: center; min-height: 24px; padding: 0 10px; border-radius: 999px; background: var(--needs-bg); color: var(--needs-ink); font-size: 12px; font-weight: 600; } -.inbox-meta { font-size: 13px; color: #6b7f72; font-variant-numeric: tabular-nums; } -.inbox-title { margin-top: 8px; font-size: 18px; line-height: 1.35; overflow-wrap: anywhere; } -.inbox-detail { margin-top: 4px; font-size: 14px; color: #56695e; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } -.inbox-action { flex: 0 0 auto; text-decoration: none; } -.inbox-row { padding: 20px 24px; border-radius: var(--radius-panel); background: var(--paper); border: 1px solid #1e4d3b1a; box-shadow: 0 1px 2px #1a3a2a0f; min-width: 0; } -.inbox-row-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } -.inbox-number { font-size: 13px; color: #6b7f72; font-variant-numeric: tabular-nums; } -.inbox-row .inbox-title { flex: 1; min-width: 0; margin: 0; font-size: 16px; } -.inbox-step { font-size: 13px; color: #1f5f52; font-variant-numeric: tabular-nums; } -.inbox-watch { height: 34px; min-height: 34px; text-decoration: none; } -.inbox-steps { list-style: none; margin: 16px 0 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; } -.inbox-steps li { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: 12px; color: #6b7f72; overflow-wrap: anywhere; } -.step-bar { height: 6px; border-radius: 999px; background: #d6e6dd; } -.inbox-steps li.is-done { color: #3e5448; } -.inbox-steps li.is-done .step-bar { background: #1e4d3b; } -.inbox-steps li.is-current { color: var(--ink); font-weight: 600; } -.inbox-steps li.is-current .step-bar { background: linear-gradient(90deg, var(--teal) 55%, #d6e6dd 55%); } -.inbox-recent { border-radius: 16px; background: var(--paper); border: 1px solid #1e4d3b14; overflow: hidden; } -.inbox-recent-row { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 20px; color: var(--ink); text-decoration: none; border-bottom: 1px solid #1e4d3b0f; } -.inbox-recent-row:last-child { border-bottom: 0; } -.inbox-recent-row:hover { background: #1e4d3b08; } -.inbox-recent-row:focus-visible { outline-offset: -2px; } -.recent-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: #2d5b46; } -.inbox-recent-row .inbox-number { width: 52px; flex: none; } -.recent-title { flex: 1; min-width: 0; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - -/* Buttons: primary (forest fill) and secondary (paper, forest edge). Scoped to - .btn; .text-button keeps its link look elsewhere. */ -.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 40px; min-height: 40px; padding: 0 16px; border-radius: var(--radius-control); border: 1px solid transparent; font-size: 13px; font-weight: 500; white-space: nowrap; transition: background .15s, border-color .15s, box-shadow .15s, transform .15s; } -.btn-primary { background: var(--forest); color: var(--paper); border-color: var(--forest); box-shadow: inset 0 1px 0 #ffffff2e, 0 1px 2px #1a3a2a29; } -.btn-primary:hover:not(:disabled) { background: #1f4c3d; } -.btn-primary:active:not(:disabled) { transform: translateY(1px); box-shadow: inset 0 1px 3px #0000004d; } -.btn-secondary { background: var(--paper); color: var(--forest); border-color: var(--forest); } -.btn-secondary:hover:not(:disabled) { background: #eef5ec; } -.btn-secondary:active:not(:disabled) { transform: translateY(1px); background: #e3eee1; } -.btn-ghost { background: transparent; color: var(--forest); border-color: transparent; } -.btn-ghost:hover:not(:disabled) { background: #1e4d3b0f; } -/* Danger: paper with clay text, kept in its own block behind a disclosure. */ -.btn-danger { background: var(--paper); color: var(--danger-ink); border-color: var(--danger-edge); } -.btn-danger:hover:not(:disabled) { background: #fbefeb; border-color: var(--danger-ink); } -.btn:disabled, .btn[aria-disabled=true] { background: #e7ede4; color: #546a5c; border-color: #c3d2c3; box-shadow: none; cursor: not-allowed; } - -/* Lane bar: one pill of segments; the active segment is a raised paper thumb. */ -.grid-tools { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 13px; } -.lane-bar { display: flex; flex-wrap: nowrap; align-items: center; gap: 2px; flex: 0 1 auto; min-width: 0; max-width: 100%; padding: 4px; border: 1px solid var(--edge); border-radius: 999px; background: #ffffffa6; box-shadow: inset 0 1px 2px #1a3a2a0d; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-width: none; } -.lane-bar::-webkit-scrollbar { display: none; } -.lane-segment { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 4px 5px 4px 12px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: #345642; font-size: 12px; white-space: nowrap; scroll-snap-align: start; transition: background .15s, box-shadow .15s, color .15s; } -.lane-segment:hover { background: #e4efe3; } -.lane-segment:focus-visible { outline-offset: -2px; } -.lane-segment[aria-pressed=true] { background: var(--paper); color: var(--forest); border-color: #d2dfd1; font-weight: 500; box-shadow: 0 1px 2px #1a3a2a1f, 0 3px 8px #1a3a2a14; } -.lane-count { min-width: 22px; padding: 3px 7px; border-radius: 999px; background: #e3ebe1; color: #3a5847; font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; text-align: center; } -.lane-segment[aria-pressed=true] .lane-count { background: var(--mint); color: var(--forest); } -.lane-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; border: 1px solid transparent; } -.lane-dot-running { background: #27a98e; border-color: #1c896f; } -.lane-dot-decision { background: #416c50; border-color: #2b5a3b; } -.lane-dot-blocked { background: transparent; border: 1.5px dashed #5d8268; } -.lane-dot-finished { background: #83b59c; border-color: #5f9277; } -.lane-dot-other, .lane-dot-retired { background: #eef2e8; border-color: #94ab98; } -.lane-divider { flex: none; align-self: stretch; width: 1px; margin: 6px 4px; background: var(--line); } -.grid-search { margin-left: auto; display: flex; align-items: center; gap: 7px; min-height: 44px; border: 1px solid var(--edge); border-radius: 999px; background: #ffffffa6; padding: 0 14px; width: 236px; color: #53715e; } -.grid-search:focus-within { outline: 2px solid #24684d; outline-offset: 2px; } -.grid-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: none; color: var(--ink); font-size: 12px; padding: 7px 0; } -.grid-search input::placeholder { color: #637a6b; } -.grid-search input::-webkit-search-cancel-button { display: none; } -.grid-search button { min-height: 28px; min-width: 24px; padding: 0; border: 0; background: none; color: #365b45; font-size: 18px; } -.search-status { display: block; font-size: 11px; color: var(--muted); margin-top: 7px; } -.search-status:empty { display: none; } -.outside-filter { margin-top: 7px; font-size: 11px; color: #315b43; } - -.culture-scroll { margin-top: 16px; min-width: 0; max-width: 100%; overflow-x: auto; padding: 4px 4px 10px; scrollbar-width: thin; scrollbar-color: #8bad97 #e8f0e3; } -.grid-axis { display: flex; justify-content: space-between; gap: 12px; font-size: 10px; color: var(--muted); margin: 0 0 11px; min-width: 260px; } -.cubes { display: grid; grid-template-rows: repeat(7, 26px); grid-auto-flow: column; grid-auto-columns: 26px; gap: 6px; width: max-content; min-height: 218px; align-content: start; } -.cube-button { position: relative; display: grid; place-items: center; width: 26px; height: 26px; min-height: 26px; padding: 1px; border-radius: 3px; border: 1px solid transparent; background: transparent; } -.cube-button:hover { background: #bcecd718; border-color: #bcecd780; } -.cube-button[aria-pressed=true] { outline: 2px solid #155a43; outline-offset: 1px; background: #d4e9da; } -.cube-button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -.micro-cube { display: grid; place-items: center; width: 100%; height: 100%; border-radius: 3px; border: 1px solid #83ac90; background: #c7dfcd; color: #365a45; font: 9px ui-monospace, monospace; box-shadow: inset 0 1px 0 #ffffff66, 0 1px 1px #1f493519; } -.cube-button.working .micro-cube { background: #27a98e; border-color: #1c896f; animation: micro-work 3.8s ease-in-out infinite; animation-delay: var(--delay, 0s); } -.cube-button.decision .micro-cube { background: #416c50; border-color: #2b5a3b; color: #fff; } -.cube-button.failed .micro-cube { background: transparent; border-style: dashed; border-color: #72977c; color: #375b45; } -.cube-button.finished .micro-cube { background: #83b59c; border-color: #5f9277; color: #173c2f; } -.cube-button.other .micro-cube { background: #eef2e8; border-color: #b7c9b8; color: #6a7f6e; } -.filter-empty { padding: 24px 4px; font-size: 12px; color: var(--muted); } -.cubes:has(+ .filter-empty:not([hidden])) { display: none; } -@keyframes micro-work { 0%, 100% { opacity: .52; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-1px); } } - -.shelf-caption { border-top: 1px solid #254d3220; margin-top: 10px; padding-top: 11px; display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 11px; flex-wrap: wrap; } -.shelf-caption > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%; } -.motion-option { display: inline-flex; gap: 5px; align-items: center; margin-left: 12px; min-height: 24px; cursor: pointer; } -.motion-option input { accent-color: #1d5943; width: 13px; height: 13px; } - -#cube-tooltip { position: fixed; z-index: 100; width: 270px; max-width: calc(100vw - 24px); padding: 16px; background: #fffef7; color: var(--ink); border: 1px solid #9dbba8; border-radius: 12px; box-shadow: 0 12px 35px #183e3229; font-size: 12px; overflow-wrap: anywhere; } -#cube-tooltip strong { display: block; font-size: 14px; line-height: 1.4; margin: 6px 0 12px; font-weight: 500; } -#cube-tooltip dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 0; } -#cube-tooltip dt { color: var(--muted); } -#cube-tooltip dd { margin: 0; font-family: ui-monospace, monospace; font-size: 11px; text-align: right; } - -/* Molecule view (Plan.html): the breadcrumb, state pill and title, the seven - steps, the plan in plain words and a sticky decision card. The shell's side - panel steps aside while a Molecule is open. */ -.shell.has-molecule { grid-template-columns: 216px minmax(0, 1fr); } -.shell.has-molecule .side-panel { display: none; } -.molecule-view { display: flex; flex-direction: column; gap: 20px; min-width: 0; } -.breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 14px; color: #56695e; min-width: 0; } -.breadcrumb a { color: #3e5448; text-decoration: none; min-height: 32px; display: inline-flex; align-items: center; } -.breadcrumb a:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } -.crumb-sep { color: #9fb3a6; } -#mv-crumb { color: var(--ink); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } -.mv-head { min-width: 0; } -.mv-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; font-size: 14px; } -.mv-title { margin-top: 12px; font-size: 32px; font-weight: 500; letter-spacing: -.02em; line-height: 1.2; text-wrap: balance; overflow-wrap: anywhere; } -.mv-title:focus-visible { outline-offset: 4px; } -.mv-issue a { color: var(--forest); } -.mv-planned { color: #6b7f72; font-variant-numeric: tabular-nums; } -.state-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 26px; padding: 0 12px; border-radius: 999px; background: #eef0e6; color: #3e5448; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.state-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: #9fb3a6; } -.state-pill[data-tone=needs] { background: var(--needs-bg); color: var(--needs-ink); } -.state-pill[data-tone=needs] .state-dot { background: #d99a2b; } -.state-pill[data-tone=running] { background: #dcefe9; color: #1f5f52; } -.state-pill[data-tone=running] .state-dot { background: var(--teal); } -.state-pill[data-tone=blocked] { background: #f4ddd5; color: var(--danger-ink); } -.state-pill[data-tone=blocked] .state-dot { background: #b4492f; } -.state-pill[data-tone=merged] { background: #e3ede6; color: #2d5b46; } -.state-pill[data-tone=merged] .state-dot { background: #2d5b46; } -.mv-steps { list-style: none; margin: 0; padding: 18px 20px; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; border-radius: 18px; background: #fffef8b3; border: 1px solid #1e4d3b14; } -.mv-step { display: flex; flex-direction: column; gap: 8px; min-width: 0; font-size: 13px; color: #6b7f72; overflow-wrap: anywhere; } -.mv-step.is-done { color: #3e5448; } -.mv-step.is-done .step-bar { background: #1e4d3b; } -.mv-step.is-current { color: var(--needs-ink); font-weight: 600; } -.mv-step.is-current .step-bar { background: #d99a2b; } -.mv-step.is-blocked { color: var(--danger-ink); font-weight: 600; } -.mv-step.is-blocked .step-bar { background: #b4492f; } -.mv-tabs { display: flex; gap: 4px; padding: 4px; width: max-content; max-width: 100%; border-radius: var(--radius-control); background: #eef0e6; border: 1px solid var(--shell-edge); } -.mv-tab { min-height: 36px; padding: 0 16px; border: 0; border-radius: 9px; background: transparent; color: #3e5448; font-size: 14px; font-weight: 500; } -.mv-tab:hover { background: #ffffff99; } -.mv-tab[aria-selected=true] { background: var(--paper); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px #1a3a2a24; } -.mv-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 28px; align-items: start; } -.mv-main, .mv-plan, .mv-sections { display: flex; flex-direction: column; gap: 20px; min-width: 0; } -.mv-card { padding: 24px 28px; min-width: 0; } -.mv-card-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 16px; } -.mv-card-title { font-size: 18px; font-weight: 600; letter-spacing: 0; } -.mv-count { color: #6b7f72; font-weight: 400; font-variant-numeric: tabular-nums; } -.mv-card-aside { font-size: 13px; color: #6b7f72; } -.mv-summary { margin-top: 10px; font-size: 15px; line-height: 1.6; color: #2e4639; overflow-wrap: anywhere; } -.mv-note { margin-top: 12px; font-size: 14px; color: var(--needs-ink); overflow-wrap: anywhere; } -.mv-empty { font-size: 15px; color: #56695e; } -.mv-more { margin-top: 10px; font-size: 13px; color: #6b7f72; } -.chips { list-style: none; display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 0; padding: 0; } -.chip { display: inline-flex; align-items: center; min-height: 28px; padding: 2px 12px; border-radius: 999px; background: #eef0e6; font-size: 13px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } -.file-list, .check-grid, .acceptance-list, .evidence-names { list-style: none; margin: 12px 0 0; padding: 0; } -.file { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-top: 1px solid #1e4d3b0f; } -.file:hover { background: #1e4d3b08; } -.file-badge { align-self: start; display: inline-flex; justify-content: center; height: 22px; line-height: 22px; border-radius: 6px; background: #eef0e6; color: #3e5448; font-size: 11px; font-weight: 600; letter-spacing: .04em; } -.file-badge.is-new { background: #dcefe9; color: #1f5f52; } -.file-badge.is-unknown { background: transparent; color: #9fb3a6; } -.file-text { min-width: 0; } -.file-text code, .check-commands code, .evidence-names code { font-size: 13px; color: var(--ink); } -.file-reason { margin-top: 4px; font-size: 14px; color: #56695e; overflow-wrap: anywhere; } -.check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.check-group { padding: 12px 14px; border-radius: var(--radius-control); background: #f6f7f0; border: 1px solid #1e4d3b0f; min-width: 0; } -.check-name { font-size: 14px; font-weight: 500; } -.check-commands { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 2px; } -.check-commands code { font-size: 12px; color: #56695e; } -.check-dir { font-size: 12px; color: #6b7f72; } -.acceptance-list { padding-left: 20px; list-style: disc; display: grid; gap: 6px; font-size: 14px; color: #2e4639; overflow-wrap: anywhere; } -.mv-details { border-radius: 16px; background: #fffef880; border: 1px solid #1e4d3b14; padding: 0 24px; min-width: 0; } -.mv-details summary { cursor: pointer; padding: 16px 0; min-height: 44px; font-size: 14px; color: #3e5448; font-weight: 500; overflow-wrap: anywhere; } -.mv-details-body { padding-bottom: 16px; display: grid; gap: 10px; min-width: 0; } -.digest-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; } -.digest-row code { font-size: 13px; } -.btn-small { height: 32px; min-height: 32px; padding: 0 12px; font-size: 12px; } -.mv-aside { position: sticky; top: 24px; display: flex; flex-direction: column; gap: 16px; min-width: 0; } -.decision-card { display: flex; flex-direction: column; gap: 10px; padding: 26px; border-radius: 22px; background: var(--paper); border: 1px solid #1e4d3b24; box-shadow: inset 0 1px 0 #ffffff, 0 2px 4px #1a3a2a14, 0 20px 48px #1a3a2a1f; min-width: 0; } -.decision-title { font-size: 20px; font-weight: 600; letter-spacing: -.01em; } -.decision-title:focus-visible { outline-offset: 4px; } -.decision-copy { font-size: 14px; line-height: 1.55; color: #3e5448; overflow-wrap: anywhere; } -.decision-changed { padding: 8px 12px; border-radius: var(--radius-control); background: var(--needs-bg); color: var(--needs-ink); font-size: 13px; } -.decision-size { font-size: 14px; font-weight: 500; font-variant-numeric: tabular-nums; } -.decision-estimate { font-size: 12px; color: #6b7f72; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.decision-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } -.btn-decision { width: 100%; height: 48px; min-height: 48px; border-radius: 14px; font-size: 15px; white-space: normal; } -.btn-decision-secondary, .decision-link { width: 100%; height: 44px; min-height: 44px; text-decoration: none; white-space: normal; } -.amend-form, .confirm-form { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: var(--radius-card); background: #f6f7f0; border: 1px solid #1e4d3b14; min-width: 0; } -.amend-title { font-size: 15px; font-weight: 600; } -.amend-scope { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; min-width: 0; } -.amend-form .drive-help, .confirm-form .drive-help, .accept-field .drive-help { margin-top: 0; } -.preview-info { display: grid; gap: 4px; font-size: 13px; color: #3e5448; overflow-wrap: anywhere; } -.preview-info .text-button { justify-self: start; padding: 4px 0; } -.accept-field { display: grid; gap: 6px; } -.accept-label { display: flex; align-items: flex-start; gap: 8px; font-size: 14px; font-weight: 500; cursor: pointer; min-height: 32px; } -.accept-label input { margin-top: 3px; width: 18px; height: 18px; accent-color: #1d5943; flex: none; } -.accept-label input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -.gate-status:empty { display: none; } -.gate-status .gate-job { padding: 8px 12px; border-radius: var(--radius-control); background: #f1f6ee; border: 1px solid #d6e2d4; font-size: 12px; overflow-wrap: anywhere; } -.gate-status .scope-note { margin-top: 0; } -.mv-roles { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 18px 20px; border-radius: 18px; background: #fffef8b3; border: 1px solid #1e4d3b14; font-size: 13px; color: #3e5448; } -.mv-roles > div { display: flex; justify-content: space-between; gap: 12px; min-width: 0; } -.mv-roles dd { margin: 0; color: var(--ink); font-weight: 500; text-align: right; overflow-wrap: anywhere; min-width: 0; } -.mv-roles .num { font-variant-numeric: tabular-nums; } -.danger-zone { padding: 0 20px; border-radius: 18px; border: 1px solid #eccfc6; background: #fdf8f5; min-width: 0; } -.danger-zone > summary { cursor: pointer; padding: 14px 0; min-height: 44px; font-size: 13px; font-weight: 500; color: var(--danger-ink); } -.danger-body { display: flex; flex-direction: column; gap: 16px; padding-bottom: 16px; } -.danger-item { display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: #5e3a31; } -.danger-item > .btn { align-self: stretch; } -.confirm-form { background: var(--paper); border-color: #eccfc6; } -.confirm-question { font-size: 14px; font-weight: 600; color: var(--ink); overflow-wrap: anywhere; } -.confirm-actions { display: flex; flex-wrap: wrap; gap: 10px; } -.confirm-actions .btn { flex: 1 1 auto; } -.run-panel { padding: 22px; } -.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; min-height: 30px; color: var(--muted); } -.panel-head .mono { overflow-wrap: anywhere; } -.pill { border: 1px solid #9abb9f; padding: 4px 10px; border-radius: 20px; font-size: 10px; color: #315b43; text-align: right; } -.pill.current { background: #d6ead7; } -.origin { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 12px; color: var(--muted); margin-top: 12px; } -.origin a { text-decoration: underline; text-underline-offset: 3px; } -.origin .text-button { padding: 4px 0; min-height: 28px; } -#clear-selection { margin-left: auto; } -.text-button { border: 0; background: transparent; color: inherit; padding: 8px; font-size: 12px; text-decoration: underline; text-underline-offset: 4px; } -.molecule-totals { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 12px 0; margin-top: 10px; border-bottom: 1px solid var(--line); } -.molecule-totals > div { min-width: 0; } -.molecule-totals strong { display: block; font-size: 15px; font-weight: 500; margin: 3px 0; overflow-wrap: anywhere; } -.molecule-totals small { display: block; color: var(--muted); font-size: 10px; overflow-wrap: anywhere; } -.formula { padding: 15px; display: flex; gap: 12px; align-items: center; margin: 16px 0; } -.formula-text { min-width: 0; } -.formula strong { display: block; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } -.formula small { display: block; font-size: 10px; color: #53665b; } -.formula .text-button { margin-left: auto; white-space: nowrap; } -.icon-tile { width: 28px; height: 28px; border-radius: 7px; display: grid; place-items: center; background: #e8eee6; font: 18px monospace; flex-shrink: 0; } -.delivery-line { font-size: 12px; margin: -6px 0 14px; padding: 8px 12px; border-radius: 8px; background: #e9f1e4; border: 1px solid #bdd1bb; } -.section-label { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 11px; margin-bottom: 5px; } - -.atoms { min-width: 0; max-width: 100%; } -.atom-flow { display: flex; gap: 5px; overflow-x: auto; padding: 4px 3px 8px; scrollbar-width: thin; scrollbar-color: #9bb9a3 #edf2e8; } -.atom-tab { flex: 1 0 86px; min-width: 0; position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-height: 84px; padding: 10px 8px; border: 1px solid #becfbd; border-radius: 8px; background: #ffffff80; color: var(--ink); text-align: left; } -.atom-tab strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } -.atom-tab small { font-size: 9px; line-height: 1.25; color: #56705e; overflow-wrap: anywhere; } -.atom-number { font: 9px ui-monospace, monospace; color: #467758; } -.atom-tab[aria-selected=true] { background: #dcecdf; border-color: #488568; box-shadow: inset 0 2px 0 #488568; } -.atom-tab.is-running .atom-number { color: #147d67; } -.atom-tab.is-failed { border-style: dashed; } -.atom-tab.is-skipped { opacity: 0.72; } -.atom-tab::after { content: '›'; position: absolute; right: -5px; top: 36px; font-size: 12px; z-index: 1; color: #63816b; } -.atom-tab:last-child::after { display: none; } -.atom-detail { border: 1px solid #bacfbd; border-radius: 10px; background: #fffef8; padding: 16px; margin-top: 5px; min-width: 0; } -.atom-detail-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; padding-bottom: 12px; border-bottom: 1px solid #d8e2d3; } -.atom-detail-head > span { font-size: 10px; color: #526e5c; } -.atom-detail-head .source { margin-left: auto; } -.source { display: inline-block; border: 1px solid #c2d4c5; border-radius: 5px; padding: 2px 7px; } -.atom-detail dl { display: grid; grid-template-columns: 75px minmax(0, 1fr); gap: 10px; margin: 14px 0 5px; font-size: 12px; } -.atom-detail dt { color: var(--soft); } -.atom-detail dd { margin: 0; line-height: 1.5; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } -.atom-detail dd small { display: block; font-size: 10px; color: var(--soft); margin-top: 3px; } -.atom-detail dl.atom-meta { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 12px 0; padding-bottom: 12px; border-bottom: 1px solid #d8e2d3; } -.atom-meta > div { min-width: 0; } -.atom-detail .atom-meta dt { font-size: 9px; margin-bottom: 4px; } -.atom-detail .atom-meta dd { font-size: 11px; } -.atom-access, .atom-actions { padding-bottom: 10px; } -.atom-access > div { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 7px; font-size: 11px; } -.access-tag { font-size: 10px; line-height: 1.4; padding: 3px 6px; border: 1px solid #c1d3bf; border-radius: 5px; background: #e8f0e3; color: #345a43; overflow-wrap: anywhere; } -.source-tag { background: #f6f6ed; border-style: dashed; } -.atom-action { margin-top: 8px; padding: 8px 10px; border: 1px solid #d8e2d3; border-radius: 8px; font-size: 12px; overflow-wrap: anywhere; } -.atom-action small { display: block; font-size: 10px; color: var(--soft); } -.evidence-list { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } -.evidence-list li { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; } -.evidence-list small { font-size: 10px; color: var(--soft); } -.atom-buttons { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } -.atom-buttons .text-button { padding: 6px 0; min-height: 30px; font-size: 11px; } - -.ledger-inline { margin-top: 16px; border-top: 1px solid #b8cdb9; padding-top: 12px; } -.ledger-inline summary { font-size: 12px; cursor: pointer; color: var(--muted); min-height: 32px; } -.record-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 6px; font-size: 11px; } -.record-list li { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 8px 10px; border-radius: 8px; background: #e9f0e4; border: 1px solid #c2d4bf; color: #345843; overflow-wrap: anywhere; } -.record-list li > * { min-width: 0; } -.diagnostics { margin: 16px 0 0; padding: 10px 10px 10px 28px; font-size: 11px; color: #5a3d0c; background: #fbe9c9a6; border-radius: 8px; overflow-wrap: anywhere; } - -.aside-copy { margin: 12px 0 21px; color: var(--muted); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; } -.action-card { padding: 16px; margin-top: 12px; } -.scope-note { font-size: 11px; color: #53665b; line-height: 1.6; margin-top: 8px; } - -/* Lab overview. */ -.aggregate { padding: 23px; } -.aggregate h2 { font-size: 21px; } -.aggregate-intro { font-size: 12px; margin-top: 7px; color: var(--muted); } -.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 18px; } -.metrics article { padding: 18px; min-width: 0; } -.metrics strong { display: block; font-size: 23px; margin: 7px 0; font-weight: 500; } -.metrics p { font-size: 12px; color: #526559; } -.approval-queue, .outcome-chart { margin-top: 22px; border-top: 1px solid var(--line); padding-top: 18px; } -.review-row { display: grid; grid-template-columns: 60px minmax(0, 1fr) auto 15px; gap: 12px; width: 100%; align-items: center; text-align: left; color: var(--ink); background: var(--paper); border: 1px solid var(--card-edge); border-radius: var(--radius-card); box-shadow: var(--shadow-card); padding: 10px 13px; margin-top: 7px; min-height: 44px; font-size: 12px; } -.review-row strong { font-weight: 500; overflow-wrap: anywhere; } -.review-row > span:nth-child(3) { font-size: 11px; color: var(--muted); } -.review-row:hover { background: #f1f6ee; border-color: var(--edge-strong); } -.review-id { font-family: ui-monospace, monospace; color: #557563; } -.outcome-row { display: grid; grid-template-columns: 165px minmax(0, 1fr) 36px; align-items: center; gap: 18px; margin-top: 12px; font-size: 12px; } -.outcome-row strong { text-align: right; font-weight: 500; } -.bar-track { height: 9px; border-radius: 3px; background: #dce8da; overflow: hidden; } -.bar { height: 100%; display: block; background: #82ad91; border-radius: 3px; } -.bar.working { background: #239b82; } -.bar.decision { background: #385f45; } -.bar.failed { background: repeating-linear-gradient(135deg, #6d8f77 0 2px, #dce8da 2px 5px); } -.bar.other { background: #b7c9b8; } - -footer { display: flex; justify-content: space-between; gap: 20px; padding: 25px 0; font-size: 11px; color: #5d7366; overflow-wrap: anywhere; } - -dialog { background: var(--paper); color: var(--ink); border: 1px solid #b3cbbd; border-radius: 18px; padding: 24px; max-width: min(760px, calc(100vw - 32px)); width: 100%; max-height: calc(100vh - 48px); } -dialog::backdrop { background: #071b16b3; backdrop-filter: blur(6px); } -.dialog-head { display: flex; justify-content: space-between; gap: 15px; } -.dialog-head h2 { font-size: 16px; } -dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.7 ui-monospace, monospace; margin-top: 18px; } - -@media (max-width: 1000px) { - .formula { flex-wrap: wrap; } - .formula .text-button { margin-left: 28px; } - .molecule-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .drive-groups { grid-template-columns: minmax(0, 1fr); } -} -@media (max-width: 1199px) { - .shell { grid-template-columns: 200px minmax(0, 1fr); } - .side-panel { grid-column: 2; display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); align-items: start; } - .side-panel .drive-groups { grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr); } - .art-orchid { display: none; } - .shell.has-molecule { grid-template-columns: 200px minmax(0, 1fr); } - .mv-layout { grid-template-columns: minmax(0, 1fr); } - .mv-aside { position: static; } -} -@media (max-width: 900px) { - .art { display: none; } - .side-panel { grid-template-columns: minmax(0, 1fr); } - .side-panel .drive-groups { grid-template-columns: minmax(0, 1fr); } -} -@media (max-width: 720px) { - .app-header { gap: 10px; flex-wrap: wrap; min-height: 60px; padding: 8px 16px; } - .brand { font-size: 17px; } - .divider { display: none; } - .repo { order: 3; flex-basis: 100%; font-size: 13px; } - .loop-pill { order: 2; } - .avatar { order: 2; width: 36px; height: 36px; font-size: 13px; } - .shell { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px 16px 96px; } - .app-nav { display: none; } - .side-panel { grid-column: 1; } - .resident { flex-direction: row; text-align: left; padding: 16px; } - .resident-figure { flex: none; width: 64px; height: 64px; } - .resident-copy { display: none; } - .tab-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 60; display: flex; padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #1e4d3b14; background: #fbfaf3f2; } - .placeholder { padding: 20px; } - .inbox { gap: 22px; } - .inbox-greeting h1 { font-size: 26px; } - .inbox-greeting > .btn { width: 100%; height: 44px; } - .inbox-card { flex-direction: column; align-items: stretch; gap: 12px; padding: 18px; border-radius: 16px; } - .inbox-icon { display: none; } - .inbox-title { font-size: 17px; } - .inbox-action { width: 100%; height: 48px; min-height: 48px; } - .inbox-row { padding: 16px; border-radius: 16px; } - .inbox-row .inbox-title { flex-basis: 100%; order: -1; } - .inbox-steps { gap: 4px; } - .inbox-steps li { font-size: 0; gap: 0; } - .inbox-steps li.is-current { font-size: 11px; gap: 4px; grid-column: span 1; } - .inbox-recent-row { padding: 0 14px; } - .inbox-recent-row .inbox-meta { display: none; } - .drive-form { padding: 16px; } - .placeholder h1, .formula-view h1, .aggregate h1 { font-size: 22px; } - .formula-view { padding: 18px; } - .shelf { padding: 16px; border-radius: 16px; } - .grid-tools { gap: 10px; } - .lane-bar { flex: 1 1 100%; } - .lane-segment { min-height: 44px; } - .grid-search { width: 100%; min-height: 44px; margin-left: 0; } - .drive-panel { padding: 12px; } - .drive-row > .btn { flex: 1 1 auto; height: 44px; min-height: 44px; } - .drive-field input, .stepper input { height: 44px; font-size: 16px; } - .stepper-button { width: 44px; height: 44px; min-height: 44px; } - .grid-search input { font-size: 16px; } - .grid-search button { min-height: 32px; min-width: 32px; } - .cubes { grid-template-rows: repeat(7, 24px); grid-auto-columns: 24px; gap: 4px; min-height: 192px; } - .cube-button { width: 24px; height: 24px; min-height: 24px; padding: 2px; } - .shelf-caption { align-items: flex-start; flex-direction: column; gap: 6px; } - .shelf-caption > span:first-child { white-space: normal; max-width: 100%; font-size: 10px; } - .shelf-caption > span:last-child { font-size: 10px; line-height: 1.6; } - .active-formula { align-items: flex-start; flex-direction: column; gap: 6px; } - .shell.has-molecule { grid-template-columns: minmax(0, 1fr); } - .mv-title { font-size: 24px; } - .mv-steps { padding: 14px; gap: 4px; } - .mv-step { font-size: 0; gap: 0; } - .mv-step.is-now { font-size: 11px; gap: 4px; } - .mv-card { padding: 18px; border-radius: 16px; } - .file { grid-template-columns: minmax(0, 1fr); gap: 6px; } - .file-badge { justify-self: start; padding: 0 8px; } - .check-grid { grid-template-columns: minmax(0, 1fr); } - .mv-details { padding: 0 16px; } - .decision-card { padding: 18px; border-radius: 16px; } - .mv-roles > div { flex-direction: column; gap: 2px; } - .mv-roles dd { text-align: left; } - .run-panel { padding: 18px; border-radius: 16px; } - .section-label { flex-direction: column; gap: 4px; } - .molecule-totals { grid-template-columns: minmax(0, 1fr); gap: 10px; } - .formula { padding: 12px; } - .formula .text-button { margin: 0; } - #clear-selection { margin-left: 0; } - .atom-tab { flex: 0 0 86px; } - .atom-detail { padding: 13px; } - .atom-detail dl { grid-template-columns: 64px minmax(0, 1fr); } - .atom-detail dl.atom-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } - .atom-detail-head .source { margin-left: 0; } - .metrics { grid-template-columns: minmax(0, 1fr); } - .aggregate { padding: 18px; } - .review-row { grid-template-columns: 48px minmax(0, 1fr) 12px; gap: 8px; } - .review-row > span:nth-child(3) { grid-column: 2; grid-row: 2; } - .review-row > span:last-child { grid-column: 3; grid-row: 1; } - .outcome-row { grid-template-columns: 110px minmax(0, 1fr) 30px; gap: 10px; } - footer { flex-direction: column; gap: 5px; } -} - -/* Coarse pointers get 44px targets everywhere. */ -@media (pointer: coarse) { - .nav-link, .tab, .btn, .loop-pill-action, .lane-segment, .text-button, summary, .motion-option { min-height: 44px; } - .seg, .stepper-button, .inbox-watch, .inbox-recent-row { min-height: 44px; } - .mv-tab, .btn-small, .breadcrumb a, .accept-label, .preview-info .text-button { min-height: 44px; } -} - -/* Motion: running cells and the resident's working pose animate; everything - stops for reduced motion, the Motion checkbox, a hidden page or an offscreen - grid or resident. */ -.motion-paused *, .page-hidden *, .shelf-offscreen .shelf * { animation-play-state: paused !important; } -.motion-paused .resident-sprite, .page-hidden .resident-sprite, .resident-offscreen .resident-sprite { animation-play-state: paused !important; } -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } - .resident-sprite { animation: none !important; transform: none; } - .motion-option { display: none; } -} -@media (forced-colors: active) { - .field { display: none; } - .art { display: none; } - .app-header, .app-nav, .loop-pill, .loop-stop-confirm, .tab-bar, .tab-more-menu { background: Canvas; color: CanvasText; border: 1px solid CanvasText; box-shadow: none; backdrop-filter: none; } - .nav-link[aria-current=page], .tab[aria-current=page] { border: 1px solid Highlight; outline: 2px solid Highlight; } - .avatar, .nav-badge, .loop-dot { background: Canvas; color: CanvasText; border: 1px solid CanvasText; } - .resident-sprite { forced-color-adjust: none; } - .glass, .card, .active-formula, .drive-panel, .review-row, .cube-button, .micro-cube, .atom-tab, .atom-detail { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } - .btn, .stepper-button, .lane-segment, .drive-field input, .stepper input, .grid-search, .lane-bar { border: 1px solid ButtonText; box-shadow: none; } - .btn:disabled, .btn[aria-disabled=true] { color: GrayText; border-color: GrayText; } - .lane-segment[aria-pressed=true] { background: Highlight; color: HighlightText; border-color: Highlight; } - .lane-segment[aria-pressed=true] .lane-count { background: Canvas; color: CanvasText; } - .lane-dot { border: 1px solid CanvasText; } - .lane-divider { background: CanvasText; } - .inbox-row, .inbox-recent, .inbox-icon, .inbox-badge, .drive-error, .segmented { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } - .seg { border: 1px solid ButtonText; } - .seg[aria-pressed=true] { background: Highlight; color: HighlightText; border-color: Highlight; } - .step-bar { forced-color-adjust: none; background: Canvas; border: 1px solid CanvasText; } - .inbox-steps li.is-done .step-bar, .inbox-steps li.is-current .step-bar { background: Highlight; } - .recent-dot { background: CanvasText; } - .state-pill, .file-badge, .chip, .check-group, .mv-steps, .mv-tabs, .decision-card, .mv-roles, .danger-zone, .amend-form, .confirm-form, .mv-details, .decision-changed { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } - .state-dot { forced-color-adjust: none; background: CanvasText; } - .mv-step .step-bar { forced-color-adjust: none; background: Canvas; border: 1px solid CanvasText; } - .mv-step.is-done .step-bar, .mv-step.is-current .step-bar, .mv-step.is-blocked .step-bar { background: Highlight; } - .mv-tab { border: 1px solid ButtonText; } - .mv-tab[aria-selected=true] { background: Highlight; color: HighlightText; border-color: Highlight; } - .cube-button[aria-pressed=true], .atom-tab[aria-selected=true], .lane-segment[aria-pressed=true] { outline: 2px solid Highlight; } - button:focus-visible, summary:focus-visible, a:focus-visible, input:focus-visible { outline-color: Highlight; } -}