diff --git a/app/globals.css b/app/globals.css index 26eadb54..aa2ec544 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,44 +1,59 @@ @import "tailwindcss"; /* - * Align dark theme. Tokens below were authored for this app; the Tailwind palette the - * components already use is mapped onto them so every zinc/violet/amber/red/emerald class - * resolves here. Truth colors keep their meaning: - * violet = selected/candidate (--al-accent), amber = unresolved (--al-warn), - * red = real failed check (--al-fail), emerald = Sparta-verified only (--al-pass). + * Display face. Founder ruling: Nineties Headliner loads at runtime from the viewer's machine + * via local() and is NEVER committed to this public repo. Anton (next/font) is the fallback + * when the face is not installed. + */ +@font-face { + font-family: "Nineties Headliner"; + src: local("Nineties Headliner"), local("NinetiesHeadliner-Regular"); + font-display: swap; +} + +/* + * Align dark theme, retuned to the v6 design. Values are the Liminal design-system tokens v6 + * uses (liminal-prototype design-system/tokens/design-tokens.css), named in the comments. + * The Tailwind palette the components already use is mapped onto these tokens so every + * zinc/violet/amber/red/emerald class resolves here. Truth colors keep their meaning: + * violet = selected/candidate (--al-accent, the Liminal magenta), + * amber = unresolved (--al-warn), red = real failed check (--al-fail), + * emerald = Sparta-verified only (--al-pass). */ :root { color-scheme: dark; - --al-bg: #0b0b0e; - --al-rail: #09090b; - --al-surface: #111115; - --al-surface-sunk: #0d0d10; - --al-surface-raised: #18181d; - --al-line: #2a2a31; - --al-line-strong: #3a3a43; - - --al-ink: #f2f0ec; - --al-ink-2: #d6d2ca; - --al-ink-3: #aaa59d; - --al-ink-4: #8e8a83; - - --al-accent: #c46bff; - --al-accent-bg: rgb(196 107 255 / 0.1); - --al-warn: #f5b342; - --al-warn-bg: rgb(245 179 66 / 0.09); - --al-fail: #ff5c6c; - --al-fail-bg: rgb(255 92 108 / 0.09); - --al-pass: #3ddc84; - --al-pass-bg: rgb(61 220 132 / 0.08); + /* Near-black page with layered frame surfaces. */ + --al-bg: #0a0a0b; /* --bg */ + --al-rail: #08080a; /* --rail-bg */ + --al-surface: #0e0e11; /* --frame-bg: cards */ + --al-surface-sunk: #0c0c0f; /* --frame-bg-2: insets, inputs, grey pills */ + --al-surface-raised: #15161a; /* --frame-bg-3: hover, chips */ + --al-line: #232327; /* --card-border */ + --al-line-strong: #2a2a2f; /* --card-border-2: secondary buttons, options */ + + /* Ink: --text / --text-mid / (one step between) / --text-dim. */ + --al-ink: #f4f2ee; + --al-ink-2: #c9c5bd; + --al-ink-3: #a6a29a; + --al-ink-4: #8a8780; + + --al-accent: #cc24f5; /* --liminal (orchid-500) */ + --al-accent-bg: rgb(204 36 245 / 0.1); /* --liminal-bg */ + --al-warn: #fdc237; /* --watch (amber-orn-500) */ + --al-warn-bg: rgb(253 194 55 / 0.08); + --al-fail: #ed214f; /* --alarm (stability-500) */ + --al-fail-bg: rgb(237 33 79 / 0.08); + --al-pass: #31e682; /* --ui-state-success (connection-500) */ + --al-pass-bg: rgb(49 230 130 / 0.08); --al-info: #6fa8ff; --al-info-bg: rgb(111 168 255 / 0.08); - --al-focus: #9b7bff; + --al-focus: #8e66fb; /* --ui-focus-ring (clarity-500) */ --al-ease: cubic-bezier(0.4, 0, 0.2, 1); - --al-sans: var(--font-space-grotesk), system-ui, sans-serif; - --al-mono: var(--font-space-mono), ui-monospace, monospace; - --al-display: var(--font-anton), Impact, sans-serif; + --al-sans: var(--font-space-grotesk), "Space Grotesk", system-ui, sans-serif; + --al-mono: var(--font-space-mono), "Space Mono", ui-monospace, monospace; + --al-display: "Nineties Headliner", var(--font-anton), Impact, sans-serif; } @theme inline { @@ -103,8 +118,8 @@ body { background: var(--al-bg); color: var(--al-ink); font-family: var(--al-sans); - font-size: 16px; - line-height: 1.4; + font-size: 15px; + line-height: 1.45; } :focus-visible { @@ -133,11 +148,11 @@ body { animation: al-in 0.3s var(--al-ease); } -/* Mono uppercase section label. */ +/* Mono uppercase section label (v6 .lbl). */ .al-lbl { font-family: var(--al-mono); - font-size: 13px; + font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--al-ink-3); + color: var(--al-ink-4); } diff --git a/components/BriefDrawer.tsx b/components/BriefDrawer.tsx index b66f3df0..87f1e6e3 100644 --- a/components/BriefDrawer.tsx +++ b/components/BriefDrawer.tsx @@ -1,13 +1,27 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { companyMeasureId, conflictMeasureId, labelsFor, poss } from "@/lib/labels"; import type { BriefOption } from "@/lib/types"; import type { ViewProps } from "./props"; -import { Badge, Button, OWNER_LABEL, Section, StateBadge, fmtTime } from "./ui"; +import { comparablePair, currentGoal, priorVersion } from "./select"; +import { Button, OWNER_LABEL, STATE_LABEL, fmtTime } from "./ui"; +import { CARD, Check, Chip, Collapsible, Lbl, Pill, type PillTone } from "./v6/kit"; -const RANK_LABEL: Record = { P0: "1st", P1: "2nd", P2: "3rd" }; +// v6 "Ways forward". Northstar shows its brief as stored (ranked options, what could prove it wrong, cited evidence). +// Custom scenarios have no generated options, so the ranked list is replaced by an honest empty state. -export function BriefDrawer(props: ViewProps & { onClose: () => void }) { - const { s, me, onClose, mark } = props; +const HINT: Record = { + P0: "P0 is the change to propose. It can be tried on practice tickets before anyone signs off.", + P1: "P1 is a constraint on any plan, not a proposal by itself.", + P2: "P2 is Product's own call. No proposal needed; it becomes part of what you offer.", +}; + +const truthTone = (state: string): PillTone => (state === "UNKNOWN" ? "amber" : state === "PROPOSED" || state === "INFERRED" ? "violet" : "grey"); + +/** `fromGoals`: opened over the Goals view, so closing returns there and the back link says so. */ +export function BriefDrawer(props: ViewProps & { onClose: () => void; fromGoals?: boolean }) { + const { s, me, onClose, mark, fromGoals } = props; + const L = labelsFor(s); const b = s.brief; const [choice, setChoice] = useState("P0"); const marked = useRef(false); @@ -28,150 +42,190 @@ export function BriefDrawer(props: ViewProps & { onClose: () => void }) { const alike = b.alike_status === "retrieved" && b.alike_ref ? b.alike_ref : null; const isProduct = me.actor === "product"; + const accepted = s.acceptances.length > 0; + + // Work completed ≠ goal achieved, from state: the owning team's measure (before any approval) and the company requirement. + const sec = currentGoal(s, "security"); + const secBefore = accepted && sec ? (priorVersion(s, sec) ?? sec) : sec; + const oldMeasure = secBefore?.measures.find((m) => m.id === conflictMeasureId(s))?.text; + const requirement = currentGoal(s, "ceo")?.measures.find((m) => m.id === companyMeasureId(s))?.text; + const { baseline } = comparablePair(s); + const basePassed = baseline?.checks.filter((c) => c.passed).length ?? 0; + + const propose = () => { + onClose(); + props.go("proposals"); + }; return ( -
-
-
-
What to do next
-
+
+
+ + Suggestion · you choose +
-
-

- {b.headline} -

- -
-
    - {b.evidence_refs.map((e) => ( -
  • - -
    {e.label}
    -
  • - ))} -
+
+
+ + Ways forward · {L.team.product} → {L.team.security} dependency + +

+ {L.northstar ? b.headline : `${L.person.ceo} changed what ${L.team.product} needs. ${poss(L.team.security)} measure hasn't changed.`} +

+
-
- {alike ? ( -
-
- - - Earlier team decision (saved by Alike, our AI-agent memory tool) - -
-

{alike.summary}

-

- Saved at {fmtTime(alike.recorded_at)} (time as reported by the AI session that saved it), then found again by a new AI session. -

-
-
How it changed a new AI session's choice of plan
-

The new plan was written before this decision was saved. Alike passed the decision along to a new AI session — it didn't write the plan.

-
-
-
Before
-
{alike.plan_before}
-
-
- → -
-
-
After
-
{alike.plan_after}
-
-
-
-
- ) : ( -
- {b.alike_status === "pending" ? "Pending" : "Not available"} - Earlier team decision (saved by Alike): not found, so nothing here relies on it. -
- )} +
+
+ Work completed +
+ {oldMeasure ?? "Not shared"} + {L.northstar && baseline && ( + + The current plan reports done; {basePassed} of {baseline.checks.length} checks passed. + + )} +
-
+ + ≠ + +
+ Goal achieved +
+ {requirement ?? "Not shared"} + {accepted ? `${L.team.security} has since approved an updated goal.` : "Not met by the current measure."} +
+
+
-
-

The order is only a suggestion. Whoever owns the goal decides.

-
+ {L.northstar ? ( +
{b.priorities.map((o) => { const on = choice === o.rank; return ( -
- -
-
    - {b.disconfirmers.map((d) => ( -
  • {d}
  • - ))} -
-
+ ) : ( +
+ No ranked options in this scenario + + Align doesn't write options for you here. {L.team.product} writes the change as a proposal; {L.team.security} decides. + +
+ )} -
-
    - {b.unknowns.map((u) => ( -
  • - {u} -
  • - ))} -
-
+ {L.northstar && ( +
+ +
+ {b.disconfirmers.map((d) => ( + + {d} + + ))} + {b.unknowns.map((u) => ( + + {u} {STATE_LABEL.UNKNOWN} + + ))} +
+
+ +
+ {b.evidence_refs.map((e) => ( +
+ + + {STATE_LABEL[e.state] ?? e.state} + + + {e.label} +
+ ))} +
+
+ {alike ? ( + <> +
Earlier team decision (saved by Alike, our AI-agent memory tool)
+

{alike.summary}

+

+ Saved at {fmtTime(alike.recorded_at)} (time as reported by the AI session that saved it), then found again by a new AI session. Alike passed + the decision along; it didn't write the plan. +

+
+
+ Before +
{alike.plan_before}
+
+ + → + +
+ After +
{alike.plan_after}
+
+
+ + ) : ( + + + {b.alike_status === "pending" ? "Pending" : "Not available"} + + Earlier team decision (saved by Alike): not found, so nothing here relies on it. + + )} +
+
+
+ )}
- + + {L.northstar ? HINT[choice] : `Not sandbox-tested. ${L.team.product} can still propose the change; ${L.team.security} approves it knowing that.`} + + {L.northstar && ( + + )} {isProduct ? ( - ) : ( - Proposals from Product to Security show up under Proposals. + )}
diff --git a/components/DecisionView.tsx b/components/DecisionView.tsx index 946e235c..5d8f614e 100644 --- a/components/DecisionView.tsx +++ b/components/DecisionView.tsx @@ -1,15 +1,18 @@ "use client"; import { useEffect, type ReactNode } from "react"; -import { AGREED_STATUS, CONDITIONS_UNCONFIRMED, companyMeasureId, conditionsUnconfirmedNote, decisionMeasureId, labelsFor, poss, switchHint, type Labels } from "@/lib/labels"; -import type { SimulationRun } from "@/lib/types"; +import { CONDITIONS_UNCONFIRMED, companyMeasureId, conditionsUnconfirmedNote, decisionMeasureId, labelsFor, poss, switchHint, type Labels } from "@/lib/labels"; +import type { Acceptance, SimulationRun } from "@/lib/types"; import type { ViewProps } from "./props"; import { AuthorityStrip } from "./AuthorityStrip"; -import { agreementStatus, launchLine, launchState } from "./launch"; +import { STALE_APPROVAL_NOTE, agreementStatus, approvedStale, launchLine, launchState } from "./launch"; import { ProofStrip } from "./ProofStrip"; import { accepted, activeProposal, comparablePair, currentGoal } from "./select"; import { nextStepHint } from "./nextStep"; import { depRelation } from "./TeamLens"; -import { Badge, Button, OWNER_LABEL, StateBadge, fmtTime } from "./ui"; +import { SpartaProof } from "./SpartaProof"; +import { Badge, Button, OWNER_LABEL, STATE_LABEL, StateBadge, fmtTime } from "./ui"; +import { stillOnOldPlan } from "./v6/OldPlanWork"; +import { AuditTable, Card, CopyText, Legend, MeasuredTile, RecordHeader, RecordTabs, Tile, TimerCard, TruthColumns, useRecordTab } from "./v6/record"; function passed(r?: SimulationRun) { return r ? r.checks.filter((c) => c.passed).length : 0; @@ -99,39 +102,7 @@ export function DecisionView(props: ViewProps) { if (!L.northstar) return ; // 1 · Agreement reached - if (acc && rel?.status === "ACCEPTED") { - const goal = s.goals.find((g) => g.id === acc.new_goal_id); - return ( -
-
{L.northstar ? "Agreement reached" : AGREED_STATUS}} /> -
-

- Security's updated goal ({acc.accepted_version}) is now the shared plan for both teams. -

-

- Handling beta customers' issues moves to the top of Security's list. The previous version is kept. - {acc.ratified_at ? ` Approved at ${fmtTime(acc.ratified_at)}.` : ""} -

-
- - {goal && ( -
-
Security's new target
-

{goal.measures.find((m) => m.id === "S-M3")?.text ?? goal.objective}

-
- )} - -
- - -
-
- ); - } + if (acc && rel?.status === "ACCEPTED") return ; // 2 · A proposal is in progress if (open && prop) { @@ -262,6 +233,100 @@ export function DecisionView(props: ViewProps) { ); } +function MoveWork({ props }: { props: ViewProps }) { + const n = stillOnOldPlan(props.s).length; // the same count the Work screen's headline gives + return ( + + + + ); +} + +/** v6 record, Northstar: one status pill, three truth columns, who decided (owner first), measured + verified tiles. */ +function NorthstarRecord({ props, L, acc }: { props: ViewProps; L: Labels; acc: Acceptance }) { + const { s } = props; + const [tab, setTab] = useRecordTab(); + const goal = s.goals.find((g) => g.id === acc.new_goal_id); + const target = goal?.measures.find((m) => m.id === decisionMeasureId(s, acc))?.text ?? goal?.objective ?? ""; + const runs = s.simulations.filter((r) => acc.evidence_version.includes(r.id)); + const candidate = + runs.find((r) => r.plan_id === acc.plan_ref.plan_id && r.plan_version === acc.plan_ref.plan_version && r.plan_hash === acc.plan_ref.plan_hash) ?? + runs.find((r) => r.plan_id === acc.plan_ref.plan_id); + const verified = acc.test_status === "TESTED" && candidate?.verdict === "PASS"; + const stale = approvedStale(s, acc); + const status = "Agreement reached"; + const title = `Security’s updated goal (${acc.accepted_version}) is now the shared plan for both teams.`; + const productDid = acc.acknowledged_via === "authored_in_proposal" ? "proposed; wrote what it will do into its proposal" : "proposed; accepted Security's changes"; + const copy = [ + `${title} · ${status}`, + `Decided by: ${OWNER_LABEL.security} (approved ${acc.accepted_version} at ${fmtTime(acc.ratified_at)}) and ${OWNER_LABEL.product} (${productDid} at ${fmtTime(acc.acknowledged_at)})`, + `Approved: ${target}`, + "Only proposed: none", + "Never tested: company beta readiness", + ...(stale ? [STALE_APPROVAL_NOTE] : []), + `Evidence: ${runs.map((r) => `${r.plan_id}@${r.plan_version} ${r.scenario} ${r.verdict} ${passed(r)}/${r.checks.length}`).join(" · ") || "none attached"}`, + ].join("\n"); + return ( +
+ props.go("decisions")}> +

Handling beta customers' issues moves to the top of Security's list. The previous version is kept.

+ {stale &&

{STALE_APPROVAL_NOTE}

} +
+ + {tab === "summary" && ( + <> + {target}} + proposed={No open conditions} + untested={Company beta readiness. Sparta checked ticket behaviour only.} + /> + +
+ + {OWNER_LABEL.security}, owner: approved its goal ({acc.accepted_version}) + + {fmtTime(acc.ratified_at)} +
+
+ + {OWNER_LABEL.product}: {productDid} + + {fmtTime(acc.acknowledged_at)} +
+
+
+ + + {candidate ? `${passed(candidate)} / ${candidate.checks.length}` : "No run"} + + {verified ? "ticket behaviour only" : `Test status: ${STATE_LABEL[acc.test_status] ?? acc.test_status}`} + + +
+
+ + + +
+ + )} + {tab === "receipts" && ( + <> + + + + props.openDrawer("scenario")} /> + + + + )} + {tab === "legend" && } +
+ ); +} + // ---- Custom scenario: the same decision path, built only from the operator's own setup. ---- // No sandbox runs and no agents exist here, so nothing below mentions tests passing or agents acting. @@ -284,72 +349,7 @@ function CustomDecision({ props, L }: { props: ViewProps; L: Labels }) { const plan = s.plans.find((p) => p.plan_id === s.scenario_config?.plan_id && p.plan_version === s.scenario_config?.plan_version); // 1 · Agreement reached - if (acc && rel?.status === "ACCEPTED") { - const goal = s.goals.find((g) => g.id === acc.new_goal_id); - const newTarget = goal?.measures.find((m) => m.id === decisionMeasureId(s, acc))?.text ?? goal?.objective ?? ""; - // What the owning team's approval depends on: its conditions field only. Its reason for asking for changes is shown - // separately, never as a condition. One condition per line; Align stores them as one text and never checks them. - const lastCounter = s.proposals.find((p) => p.id === acc.proposal_id)?.counter_versions.at(-1); - const conditions = lastCounter?.conditions?.trim() ?? ""; - const reason = lastCounter?.rationale?.trim() ?? ""; - const conditionLines = conditions.split("\n").map((c) => c.trim()).filter(Boolean); - return ( -
- {/* One status wording (lib/labels.ts via agreementStatus); open conditions are marked once, in their box. */} -
{agreementStatus(acc)}} /> -
-

- {launchState(acc) === "approved" - ? `${poss(owning)} updated goal (${acc.accepted_version}) is now the shared plan for both teams.` - : `Both teams agreed on ${poss(owning)} updated goal (${acc.accepted_version}). Launch is pending until leadership decides.`} -

-

- The previous version is kept.{acc.ratified_at ? ` Approved by ${owner} at ${fmtTime(acc.ratified_at)}.` : ""} -

-

{launchLine(acc, L)}

-
- {conditions && ( -
-
-

Conditions

- {CONDITIONS_UNCONFIRMED} -
- {conditionLines.length > 1 ? ( -
    - {conditionLines.map((c, i) => ( -
  1. {c}
  2. - ))} -
- ) : ( -

{conditions}

- )} -

- {owning} asked for {conditionLines.length > 1 ? "these" : "this"} when it asked for changes. {conditionsUnconfirmedNote(conditionLines.length)} -

-
- )} - {reason && ( -

- Why {owning} asked for changes: {reason} -

- )} - {/* The approved target shows once, at the top of the old-plan panel (and in the header card). */} -
    -
  • - Decision: recorded -
  • -
  • - Work done: not verified yet -
  • -
  • - Outcome: not measured yet -
  • -
- - -
- ); - } + if (acc && rel?.status === "ACCEPTED") return ; // 2 · A proposal is in progress if (open && prop) { @@ -479,23 +479,137 @@ function CustomDecision({ props, L }: { props: ViewProps; L: Labels }) { ); } +/** + * v6 record, custom scenario. One status pill (agreementStatus: lib/labels.ts wording, reflecting leadership's launch + * decision), amber while conditions are open. Conditions are marked once, in the Only proposed column. Who decided + * appears once: the ProofStrip "Who approved" tile. No sandbox claims: this scenario has no runs. + */ +function CustomRecord({ props, L, acc }: { props: ViewProps; L: Labels; acc: Acceptance }) { + const { s, me } = props; + const [tab, setTab] = useRecordTab(); + const owning = L.team.security; + const owner = L.person.security; + const goal = s.goals.find((g) => g.id === acc.new_goal_id); + const newTarget = goal?.measures.find((m) => m.id === decisionMeasureId(s, acc))?.text ?? goal?.objective ?? ""; + // What the owning team's approval depends on: its conditions field only. Its reason for asking for changes is shown + // separately, never as a condition. One condition per line; Align stores them as one text and never checks them. + const lastCounter = s.proposals.find((p) => p.id === acc.proposal_id)?.counter_versions.at(-1); + const conditions = lastCounter?.conditions?.trim() ?? ""; + const reason = lastCounter?.rationale?.trim() ?? ""; + const conditionLines = conditions.split("\n").map((c) => c.trim()).filter(Boolean); + const status = agreementStatus(acc); + const title = + launchState(acc) === "approved" + ? `${poss(owning)} updated goal (${acc.accepted_version}) is now the shared plan for both teams.` + : `Both teams agreed on ${poss(owning)} updated goal (${acc.accepted_version}).`; + const copy = [ + `${title} · ${status}`, + launchLine(acc, L), + `Decided by: ${owner} (approved ${acc.accepted_version} at ${fmtTime(acc.ratified_at)}) and ${L.person.product} (at ${fmtTime(acc.acknowledged_at)})`, + `Approved: ${newTarget}`, + `Only proposed: ${conditions ? `${conditionLines.join("; ")} (${CONDITIONS_UNCONFIRMED})` : "none"}`, + ...(reason ? [`Why ${owning} asked for changes: ${reason}`] : []), + "Never tested: no sandbox test in this scenario", + ].join("\n"); + return ( +
+ props.go("decisions")}> +

+ The previous version is kept. +

+

{launchLine(acc, L)}

+
+ + {tab === "summary" && ( + <> + {newTarget}} + proposed={ + conditions ? ( +
+ + {CONDITIONS_UNCONFIRMED} + + {conditionLines.length > 1 ? ( +
    + {conditionLines.map((c, i) => ( +
  1. {c}
  2. + ))} +
+ ) : ( +

{conditions}

+ )} +

+ {owning} asked for {conditionLines.length > 1 ? "these" : "this"} when it asked for changes. {conditionsUnconfirmedNote(conditionLines.length)} +

+
+ ) : ( + No open conditions + ) + } + untested={ +
    +
  • + Decision: recorded +
  • +
  • + Work done: not verified yet +
  • +
  • + Outcome: not measured yet +
  • +
+ } + /> + {reason && ( +

+ Why {owning} asked for changes: {reason} +

+ )} + +
+ +
+
+ + +
+ + + )} + {tab === "receipts" && ( + <> + + + {L.sandbox ? ( + acc.evidence_version.includes(r.id))} onOpenTest={() => props.openDrawer("scenario")} /> + ) : ( +

No sandbox runs in this scenario

+ )} +
+ + + )} + {tab === "legend" && } +
+ ); +} + /** * Work still on the old plan, always expanded: the owning team's items tied to the goal that changed, then the - * asking team's own items to check against what it committed to. The approved revision shows once, above both. + * asking team's own items to check against what it committed to. The approved target shows once, in the Approved column. */ function OldPlanWork({ s, L, owningIds, askingIds, - revision, viewer, }: { s: ViewProps["s"]; L: Labels; owningIds: string[]; askingIds: string[]; - revision: string; viewer: ViewProps["me"]["actor"]; }) { // "your" only when the asking team itself is looking; everyone else reads whose commitment it is. @@ -507,10 +621,6 @@ function OldPlanWork({ return (
Work still on the old plan
-
- Approved revision - {revision} -
{owning.length === 0 && asking.length === 0 ? (

No work items were tied to this goal

) : ( diff --git a/components/DecisionsView.tsx b/components/DecisionsView.tsx new file mode 100644 index 00000000..1ad8c5bb --- /dev/null +++ b/components/DecisionsView.tsx @@ -0,0 +1,110 @@ +"use client"; +import { useState } from "react"; +import { customStateLabel, labelsFor, poss } from "@/lib/labels"; +import type { ViewProps } from "./props"; +import { agreementStatus, recordFacts } from "./launch"; +import { STATE_LABEL, fmtTime } from "./ui"; +import { Pill, type PillTone } from "./v6/record"; + +type Filter = "all" | "open" | "accepted"; + +interface Row { + key: string; + kind: "open" | "accepted"; + pill: string; + tone: PillTone; + title: string; + sub: string; + at: string; + open: () => void; +} + +/** + * v6 Decisions: every proposal still in play (Open = not ratified, not rejected) and every acceptance, newest first. + * An accepted row carries the one agreement status (agreementStatus, lib/labels.ts wording), amber while its + * conditions are open. A row opens the record (accepted) or the proposal (open). + */ +export function DecisionsView(props: ViewProps) { + const { s } = props; + const L = labelsFor(s); + const [filter, setFilter] = useState("all"); + const owning = L.team.security; + + const openRows: Row[] = s.proposals + .filter((p) => !["RATIFIED", "REJECTED"].includes(p.state)) + .map((p) => { + const latest = p.counter_versions.at(-1); + return { + key: `p-${p.id}`, + kind: "open", + pill: "Open", + tone: "violet", + title: `Change ${poss(owning)} goal`, + sub: `${customStateLabel(p.state, L) ?? STATE_LABEL[p.state] ?? p.state} · ${latest?.proposed_measure_text ?? p.proposed_measure_text}`, + at: latest?.at ?? p.created_at, + open: () => { + props.select({ kind: "proposal", id: p.id }); + props.go("proposals"); + }, + }; + }); + + const accRows: Row[] = s.acceptances.map((a) => { + const { target, conditions } = recordFacts(s, a); + const n = conditions ? conditions.split("\n").filter((c) => c.trim()).length : 0; + return { + key: `a-${a.proposal_id}-${a.accepted_version}`, + kind: "accepted", + // Northstar keeps its own wording; custom states the agreement and leadership's launch call one way. + pill: L.northstar ? "Agreement reached" : agreementStatus(a), + tone: n ? "amber" : "white", + title: `${poss(owning)} goal ${a.accepted_version}`, + sub: `Approved by ${L.person.security}${n ? ` · ${n} condition${n === 1 ? "" : "s"}` : ""}${target ? ` · ${target}` : ""}`, + at: a.ratified_at, + open: () => props.go("decision"), + }; + }); + + const rows = [...openRows, ...accRows] + .filter((r) => filter === "all" || r.kind === filter) + .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); + + const seg = (f: Filter, label: string) => ( + + ); + + return ( +
+
+

Decisions

+ + {seg("all", "All")} + {seg("open", "Open")} + {seg("accepted", "Accepted")} + +
+ {rows.map((r) => ( + + ))} + {rows.length === 0 &&
Nothing here yet.
} +
+ ); +} diff --git a/components/EvidenceView.tsx b/components/EvidenceView.tsx index 3a749de3..11b6d5a8 100644 --- a/components/EvidenceView.tsx +++ b/components/EvidenceView.tsx @@ -7,6 +7,7 @@ import { tally } from "./select"; import { Badge, ForEngineers, OWNER_LABEL, STATE_LABEL, Section, StateBadge, fmtDuration, fmtTime } from "./ui"; import { checkWords } from "./SpartaProof"; import { AuthorityStrip } from "./AuthorityStrip"; +import { CARD, H1 } from "./v6/kit"; /* ------------------------------------------------------------------ */ /* Shared evidence helpers (also used by ScenarioDrawer) */ @@ -193,11 +194,16 @@ export function EvidenceView(props: ViewProps) { return (
-

Evidence

-

- What was decided, the practice test runs, who did what, and how long it took to agree. These results aren't signed, but anyone can re-run them with the - command in the README. -

+

+ What was decided, the practice test runs, who did what, and how long it took to agree. These results aren't signed, but anyone can re-run them + with the command in the README. + + } + > + Evidence +

@@ -211,7 +217,7 @@ export function EvidenceView(props: ViewProps) { } function Card({ children, className = "" }: { children: ReactNode; className?: string }) { - return
{children}
; + return
{children}
; } function ChainStep({ title, who, at, children, tone = "neutral" }: { title: string; who?: string; at?: string | null; children: ReactNode; tone?: "neutral" | "amber" | "violet" }) { diff --git a/components/GoalsView.tsx b/components/GoalsView.tsx index 05a48790..24591ec6 100644 --- a/components/GoalsView.tsx +++ b/components/GoalsView.tsx @@ -1,240 +1,148 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import type { Goal, Measure } from "@/lib/types"; +import { companyMeasureId, conflictMeasureId, labelsFor, poss, type Labels } from "@/lib/labels"; +import type { StateView } from "@/lib/types"; +import { agreementStatus } from "./launch"; import type { ViewProps } from "./props"; import { ProofStrip } from "./ProofStrip"; -import { comparablePair, currentGoal } from "./select"; -import { EditTag, MissingGoal, RING, TeamLens, VersionTag, depRelation, isSel, workFor } from "./TeamLens"; -import { Badge, Button, STATE_LABEL, StateBadge } from "./ui"; +import { activeProposal, changedToday, currentGoal, priorVersion } from "./select"; +import { MissingGoal, depRelation, workFor } from "./TeamLens"; +import { Button, STATE_LABEL } from "./ui"; +import { Chip, H1, Lbl, NODE, Pill, Row, Seg, nodeState, type PillTone } from "./v6/kit"; -type Lens = "company" | "team" | "me"; -const LENS_LABEL: Record = { company: "Company", team: "Teams", me: "My goal" }; -const LINE = "border-zinc-300"; +// v6 "Goals": the company node, then the two team nodes with the dependency between them. The lens dims what +// doesn't connect to the chosen team; the strip under the tree explains the selected node and offers one next step. -function MeasureRow({ m, changed, conflict }: { m: Measure; changed?: boolean; conflict?: boolean }) { - return ( -
  • -
    - {m.text} -
    -
    - {changed && Changed today} - {conflict && Clashes with the CEO's new requirement} - {m.inherited_from && From the CEO's goal} - {m.verifiable_by === "sparta" && Can be checked in a practice test} -
    -
  • - ); +type Lens = "all" | "product" | "security"; +type Sel = "ceo" | "product" | "security" | "dep"; + +/** Where the dependency stands, from state only: agreed, a proposal in flight, or unresolved. */ +function depStage(s: StateView): "accepted" | "pending" | "open" { + if (s.acceptances.length) return "accepted"; + const p = activeProposal(s); + if (p && p.state !== "RATIFIED") return "pending"; + return "open"; } -function CeoCard({ props, g, only }: { props: ViewProps; g: Goal; only?: string }) { - const onClick = () => { - props.select({ kind: "goal", id: g.id }); - props.mark("ceo_change_viewed"); - }; - return ( - - ); +function depPill(s: StateView, L: Labels): { tone: PillTone; text: string } { + const st = depStage(s); + if (st === "accepted") return { tone: "white", text: L.northstar ? STATE_LABEL.ACCEPTED : agreementStatus(s.acceptances.at(-1)) }; + if (st === "pending") return { tone: "violet", text: STATE_LABEL.PROPOSED }; + return { tone: "amber", text: STATE_LABEL[depRelation(s)?.status ?? "UNKNOWN"] ?? STATE_LABEL.UNKNOWN }; } -function TeamCard({ props, owner, allWork }: { props: ViewProps; owner: "product" | "security"; allWork?: boolean }) { - const { s, me, selection, select } = props; +function TeamNode({ props, L, owner, sel, dim, onPick }: { props: ViewProps; L: Labels; owner: "product" | "security"; sel: boolean; dim: boolean; onPick: () => void }) { + const { s, me } = props; const g = currentGoal(s, owner); - if (!g) return ; - const unresolved = depRelation(s)?.status === "UNKNOWN"; - const work = workFor(s, g).slice(0, allWork ? undefined : 3); + if (!g) return ; + const prior = priorVersion(s, g); + const conflictId = conflictMeasureId(s); + const open = depStage(s) === "open"; + const work = workFor(s, g).slice(0, 3); + const updated = owner === "security" && s.acceptances.length > 0; return ( -
    - -
    -
    Work in priority order
    +
    + {/* The whole card selects the node: one empty button over it (no list or block content inside a button). */} + - - ))} - + work.map((w, i) => ( + + {w.task} + + )) ) : ( -

    No work linked to this goal yet.

    +

    No work linked to this goal yet.

    )}
    -
    - ); -} - -function DependencyButton({ props }: { props: ViewProps }) { - const rel = depRelation(props.s); - if (!rel) return
    ; - const open = rel.status === "UNKNOWN"; - return ( - <> -
    - - - ); -} - -function Leg({ side, label }: { side: "left" | "right"; label: string }) { - return ( -
    -
    -
    - ▼ - {label} -
    - ); -} - -/** Elbow connectors from the CEO card to both team cards, aligned to the team-row grid. */ -function InheritConnectors({ props }: { props: ViewProps }) { - const label = (id: string) => props.s.relations.find((r) => r.id === id)?.label ?? "follows"; - return ( -
    -
    - -
    -
    -
    -
    ); } -function Banner({ props }: { props: ViewProps }) { - const { s } = props; +function selInfo(props: ViewProps, L: Labels, sel: Sel) { + const { s, me, go, openDrawer } = props; + const st = depStage(s); const acc = s.acceptances.at(-1); - if (acc) - return ( -
    - Approved - Agreement reached: Security approved its updated goal ({acc.accepted_version}), and Product accepted Security's changes. -
    - ); - if (depRelation(s)?.status !== "UNKNOWN") return null; - const oldMeasure = currentGoal(s, "security")?.measures.find((m) => m.id === "S-M1"); - const kr2 = currentGoal(s, "ceo")?.measures.find((m) => m.id === "KR2"); - const { baseline } = comparablePair(s); - const passed = baseline?.checks.filter((c) => c.passed).length ?? 0; - return ( -
    -
    - What Security's AI agents aim for now - {oldMeasure?.text ?? "Unknown: Security hasn't shared its target"} - {baseline && ( - - - Current plan: oldest tickets first · {passed} of {baseline.checks.length} independent checks passed in the practice test (Sparta sandbox) - - )} -
    - - ≠ - -
    - {s.pushes.at(-1) ? `Company goal ${s.pushes.at(-1)!.version}` : "The CEO"} now requires - - {kr2?.text ?? "Unknown"} - -
    - -
    - ); -} + const ceo = currentGoal(s, "ceo"); + const kr = ceo?.measures.find((m) => m.id === companyMeasureId(s)); + const sec = currentGoal(s, "security"); + const oldMeasure = sec?.measures.find((m) => m.id === conflictMeasureId(s)); + const pill = depPill(s, L); + // The one next step per stage. Ways forward is Northstar's brief; custom has no generated options, so it goes to Proposals. + const forward = L.northstar + ? { cta: "See ways forward", run: () => openDrawer("brief") } + : { cta: me.actor === "product" ? "Open proposals" : "See proposals", run: () => go("proposals") }; + const record = L.northstar ? { cta: "Open record", run: () => go("evidence") } : { cta: "Open proposals", run: () => go("proposals") }; + const next = st === "accepted" ? record : st === "pending" ? { cta: "Open proposal", run: () => go("proposals") } : forward; -function Ladder({ props }: { props: ViewProps }) { - const ceo = currentGoal(props.s, "ceo"); - return ( -
    -
    {ceo ? : }
    - -
    - -
    - -
    - -
    -
    - ); -} - -function MeLens({ props }: { props: ViewProps }) { - const actor = props.me.actor; - const ceo = currentGoal(props.s, "ceo"); - if (!actor) return ; - if (actor === "leadership") - return
    {ceo ? : }
    ; - return ( -
    -
    The CEO's new requirement your goal depends on
    - {ceo ? m.id === "KR2") ? "KR2" : undefined} /> : } -
    -
    -
    - -
    - ); + if (sel === "ceo") + return { + tone: (ceo?.changed ? "amber" : "grey") as PillTone, + text: ceo?.changed ? (changedToday(ceo) ? "changed today" : "changed") : (ceo?.version ?? "—"), + title: kr?.text ?? ceo?.objective ?? `No current goal has been shared for ${L.team.ceo}.`, + sub: `Set by ${L.person.ceo}. Read-only for both teams; ${L.team.security} inherits it and ${L.team.product} depends on it.`, + ...next, + }; + if (sel === "product") { + const g = currentGoal(s, "product"); + return { + tone: "grey" as PillTone, + text: g?.version ?? "—", + title: g ? `${L.team.product} owns: ${g.objective}` : `No current goal has been shared for ${L.team.product}.`, + sub: `Depends on ${L.team.security}: ${depRelation(s)?.label ?? "no dependency recorded"}.`, + cta: "Open dependency", + run: undefined, + }; + } + if (sel === "security") + return { + tone: (st === "accepted" ? "white" : "amber") as PillTone, + text: st === "accepted" ? `${acc?.accepted_version ?? sec?.version} ${L.northstar ? STATE_LABEL.ACCEPTED.toLowerCase() : ""}`.trim() : "in conflict", + title: st === "accepted" ? `${poss(L.team.security)} goal ${sec?.version}: ${sec?.objective}` : `${L.team.security} is measured on: ${oldMeasure?.text ?? "not shared"}`, + sub: st === "accepted" ? `Approved by ${L.person.security}. The earlier version is kept as superseded.` : `That measure doesn't cover what ${L.person.ceo} now requires.`, + ...(st === "accepted" ? record : next), + }; + return { + tone: pill.tone, + text: pill.text, + title: depRelation(s)?.label ?? `${L.team.product} depends on ${L.team.security}`, + sub: + st === "accepted" + ? `${depPill(s, L).text}: both owners signed off.` + : st === "pending" + ? `A proposal is in progress between ${L.team.product} and ${L.person.security}.` + : `Work can complete while the goal is still not met: ${poss(L.team.security)} measure hasn't changed.`, + ...next, + }; } export function GoalsView(props: ViewProps) { - const [lens, setLens] = useState("company"); - const accepted = props.s.acceptances.length > 0; - const { mark } = props; + const { s, mark, select } = props; + const L = labelsFor(s); + const [lens, setLens] = useState("all"); + const [sel, setSel] = useState("dep"); + const accepted = s.acceptances.length > 0; const sawAccepted = useRef(false); useEffect(() => { if (accepted && !sawAccepted.current) { @@ -243,33 +151,147 @@ export function GoalsView(props: ViewProps) { } }, [accepted, mark]); + const ceo = currentGoal(s, "ceo"); + const rel = depRelation(s); + const pill = depPill(s, L); + const st = depStage(s); + const krId = companyMeasureId(s); + const dimP = lens === "security"; + const dimS = lens === "product"; + + const pick = (next: Sel) => { + setSel(next); + if (next === "ceo" && ceo) { + select({ kind: "goal", id: ceo.id }); + mark("ceo_change_viewed"); + } else if (next === "dep" && rel) select({ kind: "relation", id: rel.id }); + else if (next === "product" || next === "security") { + const g = currentGoal(s, next); + if (g) select({ kind: "goal", id: g.id }); + } + }; + const setLensAndSel = (l: Lens) => { + setLens(l); + if (l !== "all") pick(l); + }; + + /** Which team inherits a company measure, for the chip arrow ("KR2 · … → Security"). */ + const inheritor = (mId: string) => + (["product", "security"] as const).find((o) => currentGoal(s, o)?.measures.some((m) => m.inherited_from === mId)) ?? + (mId === krId ? "security" : undefined); // the requirement in conflict is, by construction, the owning team's + + const info = selInfo(props, L, sel); + const line = st === "accepted" ? "border-solid border-zinc-700" : st === "pending" ? "border-dashed border-violet-500" : "border-dashed border-amber-500"; + const depBorder = st === "accepted" ? "border-[var(--al-ink)]" : st === "pending" ? "border-violet-500" : "border-amber-500"; + return ( -
    -
    -

    - {depRelation(props.s)?.status === "UNKNOWN" ? "Your launch depends on a Security priority that hasn’t changed." : "How the goals connect"} -

    -
    - {(["company", "team", "me"] as const).map((l) => ( +
    +
    +

    Goals

    + + + +
    + +
    + {ceo ? ( + + ) : ( + + )} +
    + + {/* Connectors: from the company node down to both teams; each leg dims with its team. */} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + pick("product")} /> +
    +
    + {rel ? ( - ))} + ) : ( + No dependency recorded + )}
    + pick("security")} />
    - - - {lens === "company" && } - {lens === "team" && } - {lens === "me" && } +
    + {info.text} +
    + {info.title} +
    {info.sub}
    +
    + + + +
    - {accepted && } + {accepted && }
    ); } diff --git a/components/Home.tsx b/components/Home.tsx index 2cf6f4e1..8755f3e8 100644 --- a/components/Home.tsx +++ b/components/Home.tsx @@ -10,26 +10,27 @@ import { ScenarioDrawer } from "@/components/ScenarioDrawer"; import { TeamLens } from "@/components/TeamLens"; import { AgentPane } from "@/components/AgentPane"; import { DecisionView } from "@/components/DecisionView"; +import { DecisionsView } from "@/components/DecisionsView"; +import { YourTurnView, waitingOnMe } from "@/components/YourTurnView"; import { OrgView } from "@/components/OrgView"; import { WorkMap } from "@/components/WorkMap"; +import { ScenarioStudio } from "@/components/v6/ScenarioStudio"; import { agreementStatus } from "@/components/launch"; -import { accepted, activeProposal, currentGoal } from "@/components/select"; +import { accepted, changedToday, currentGoal } from "@/components/select"; import { Badge, Button } from "@/components/ui"; import { api, SESSION_ENDED, useAlign, type Selection } from "@/components/useAlign"; -import { ROLE_SWITCH, companyMeasureId, conflictMeasureId, decisionMeasureId, dependencyId, labelsFor, type Labels } from "@/lib/labels"; +import { ROLE_SWITCH, companyMeasureId, conflictMeasureId, decisionMeasureId, dependencyId, labelsFor, poss, type Labels } from "@/lib/labels"; -type NavItem = { id: View; label: string; sub: string; icon: string }; +// v6 rail (LIM-2424). One nav for teams and leadership; leadership also keeps its Company goal entry (OrgView). +// `also` lists the views a nav item stays highlighted for (the record under Decisions, a review under Your turn). +type NavItem = { id: View; label: string; also?: View[]; leadershipOnly?: boolean }; const NAV: NavItem[] = [ - { id: "decision", label: "Next decision", sub: "What needs you", icon: "◆" }, - { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, - { id: "proposals", label: "Proposals", sub: "Changes in progress", icon: "✉" }, - { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, -]; -const NAV_LEADERSHIP: NavItem[] = [ - { id: "company", label: "Company goal", sub: "Set and send", icon: "◆" }, - { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, - { id: "work", label: "Work", sub: "Where AI agent work goes", icon: "⌁" }, - { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, + { id: "turn", label: "Your turn", also: ["proposals"] }, + { id: "company", label: "Company goal", leadershipOnly: true }, + { id: "goals", label: "Goals", also: ["team"] }, + { id: "studio", label: "Scenario Studio", also: ["evidence"] }, + { id: "decisions", label: "Decisions", also: ["decision", "today"] }, + { id: "work", label: "Work" }, ]; const TEAM_OF: Record = { product: "Product", security: "Security", leadership: "Leadership" }; const initials = (name: string) => @@ -45,8 +46,6 @@ const ROLES = [ { id: "security", name: "Alex / Security Lead", blurb: "Owns how customer issues get handled" }, ] as const; type Role = (typeof ROLES)[number]["id"]; -// Custom scenarios show only the decision path in Build A; these views stay Northstar-only. -const CUSTOM_HIDDEN: View[] = ["goals", "evidence", "work"]; const CUSTOM_BLURB: Record = { leadership: "sets the company goal", product: "asks for the change", @@ -310,7 +309,7 @@ function NorthstarLogin({ onDone, L, elsewhere, initialRole }: { onDone: () => v export default function Home({ workspace }: { workspace?: string }) { const { state, me, error, setError, loadError, meError, loadMe, act, mark, refresh, ended, elsewhere } = useAlign(workspace); const [nextRole, setNextRole] = useState(null); // the team chosen in Switch team, until it signs in - const [view, setView] = useState("decision"); + const [view, setView] = useState("turn"); const landed = useRef(null); // the role the landing view was set for; null when signed out const [drawer, setDrawer] = useState(null); const [selection, setSelection] = useState(null); @@ -344,7 +343,7 @@ export default function Home({ workspace }: { workspace?: string }) { if (actor === landed.current) return; landed.current = actor; // eslint-disable-next-line react-hooks/set-state-in-effect - if (actor) setView(actor === "leadership" ? "company" : "decision"); + if (actor) setView("turn"); }, [me]); if (!me) @@ -394,6 +393,7 @@ export default function Home({ workspace }: { workspace?: string }) { const props: ViewProps = { s: state, me, + workspace: workspace ?? "northstar", selection, select: setSelection, go: (v) => { @@ -406,20 +406,26 @@ export default function Home({ workspace }: { workspace?: string }) { attached, setAttached, }; - const pendingForMe = state.proposals.filter((p) => - me.actor === "security" ? ["PENDING_SECURITY", "ACKNOWLEDGED_BY_PRODUCT"].includes(p.state) : p.state === "COUNTERED", - ).length; + const turnCount = waitingOnMe(state, me.actor, () => undefined).length; const rel = state.relations.find((r) => r.id === dependencyId(state)); const unresolved = rel?.status === "UNKNOWN"; const lastPush = state.pushes?.at(-1); const ceo = currentGoal(state, "ceo"); - const nav = (me.actor === "leadership" ? NAV_LEADERSHIP : NAV) - .filter((n) => !custom || !CUSTOM_HIDDEN.includes(n.id)) - .map((n) => (custom && n.id === "company" ? { ...n, sub: "Shared with both teams" } : n)); - const openProposal = activeProposal(state); - const proposalOpen = openProposal && !["RATIFIED", "REJECTED"].includes(openProposal.state); - const decisionCount = pendingForMe + (me.actor === "product" && unresolved && !proposalOpen ? 1 : 0); - const counts: Partial> = { decision: decisionCount, proposals: pendingForMe }; + // Scenario Studio compares sandbox runs; custom scenarios have no sandbox, so the rail leaves it out there (S3). + const nav = NAV.filter((n) => (!n.leadershipOnly || me.actor === "leadership") && (n.id !== "studio" || !custom)); + const proposalOpen = state.proposals.some((p) => !["RATIFIED", "REJECTED"].includes(p.state)); + const counts: Partial> = { turn: turnCount, goals: unresolved ? 1 : 0 }; + // + New conflict: leadership sets one up (/setup on this server; a workspace's leadership starts a new workspace + // at /new, since /setup refuses workspace sessions); the asking team opens the composer. Disabled, with the reason, + // wherever the server would refuse the proposal (createProposal: asking team only, one open proposal at a time). + const newConflict: { href?: string; go?: View; why?: string } = + me.actor === "leadership" + ? { href: workspace ? "/new" : "/setup" } + : me.actor !== "product" + ? { why: `Only ${L.team.product} proposes a change. You review it here when it arrives.` } + : proposalOpen + ? { why: "A proposal is already open. Finish it first." } + : { go: "proposals" }; const myGoal = me.actor === "product" || me.actor === "security" ? currentGoal(state, me.actor) : undefined; const kr2 = ceo?.measures.find((m) => m.id === companyMeasureId(state)); // Custom: the goal's current target. An approval swaps the owner's measure, not its objective. @@ -431,6 +437,7 @@ export default function Home({ workspace }: { workspace?: string }) { : undefined; // Custom scenarios have no live agent, so the rail always shows the Inspector. const focus = !custom && (view === "decision" || view === "today"); + const myGoalLine = me.actor === "leadership" ? "owns the company goal" : myGoal ? `owns ${poss(L.team[me.actor])} goal ${myGoal.version}` : ""; const alikeOk = state.brief?.alike_status === "retrieved"; const ops = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("ops"); @@ -454,7 +461,7 @@ export default function Home({ workspace }: { workspace?: string }) { await act("/api/reset", { confirm: "reset" }); setSelection(null); setAttached(null); - setView(me?.actor === "leadership" ? "company" : "decision"); + setView("turn"); } return ( @@ -466,26 +473,36 @@ export default function Home({ workspace }: { workspace?: string }) { Align by Liminal
    -
    )} +
    + + {custom ? teamInitials(L.role[me.actor]) : initials(me.name ?? "")} + +
    +
    {custom ? L.person[me.actor] : me.name}
    +
    {myGoalLine}
    +
    +
    {/* Top bar */} @@ -616,7 +642,7 @@ export default function Home({ workspace }: { workspace?: string }) {
    Company goal {state.timeframe} - {ceo.changed && Changed today} + {ceo.changed && {changedToday(ceo) ? "Changed today" : "Changed"}}
    {ceo.objective}
    @@ -642,14 +668,18 @@ export default function Home({ workspace }: { workspace?: string }) {
    )} + {/* v6 routes: nav id → view. "decision" is the record route; "proposals" is the review a Your turn card opens. */} + {view === "turn" && } + {view === "goals" && } + {view === "studio" && L.sandbox && } + {view === "evidence" && L.sandbox && } + {view === "decisions" && } + {view === "work" && } {view === "decision" && } {view === "today" && } {view === "company" && } - {view === "work" && !custom && } - {view === "goals" && !custom && } {view === "team" && } {view === "proposals" && } - {view === "evidence" && !custom && } {drawer && !custom && (
    @@ -659,7 +689,7 @@ export default function Home({ workspace }: { workspace?: string }) { role="dialog" aria-modal="true" > - {drawer === "brief" && } + {drawer === "brief" && } {drawer === "scenario" && }
    diff --git a/components/Inspector.tsx b/components/Inspector.tsx index cfcbd131..95d3261c 100644 --- a/components/Inspector.tsx +++ b/components/Inspector.tsx @@ -4,7 +4,7 @@ import type { Goal, GoalRelation, Proposal, SimulationRun, StateView, WorkItem } import type { ViewProps } from "./props"; import { Badge, Button, OWNER_LABEL, Section, StateBadge, fmtTime } from "./ui"; import { reviewConflictLabel, teamName } from "./TeamLens"; -import { accepted, comparablePair, currentGoal, priorVersion } from "./select"; +import { accepted, changedToday, comparablePair, currentGoal, priorVersion } from "./select"; import { companyMeasureId, conflictMeasureId, dependencyId, labelsFor, poss, type Labels } from "@/lib/labels"; import { agreementStatus, nextWorkLine } from "./launch"; @@ -60,7 +60,7 @@ function GoalPanel({ p, g }: { p: ViewProps; g: Goal }) { {g.version} - {g.changed && Changed today} + {g.changed && {changedToday(g) ? "Changed today" : "Changed"}} {!isCurrent && Replaced by a newer version}

    {g.objective}

    @@ -608,7 +608,7 @@ function CustomGoal({ p, L, g }: { p: ViewProps; L: Labels; g: Goal }) { {g.version} - {g.changed && Changed today} + {g.changed && {changedToday(g) ? "Changed today" : "Changed"}} {!isCurrent && Replaced by a newer version}

    {g.objective}

    diff --git a/components/ProposalsView.tsx b/components/ProposalsView.tsx index 1083954e..0a9eaaea 100644 --- a/components/ProposalsView.tsx +++ b/components/ProposalsView.tsx @@ -1,121 +1,25 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { CONDITIONS_UNCONFIRMED, companyMeasureId, conflictMeasureId, labelsFor, poss, type Labels } from "@/lib/labels"; -import { agreementStatus } from "./launch"; +import { CONDITIONS_UNCONFIRMED, companyMeasureId, conditionsUnconfirmedNote, conflictMeasureId, labelsFor, poss, type Labels } from "@/lib/labels"; import type { Proposal, SimulationRun, StateView, TestStatus } from "@/lib/types"; import type { ViewProps } from "./props"; -import { activeProposal, currentGoal } from "./select"; +import { activeProposal, changedToday, currentGoal, priorVersion } from "./select"; import { Badge, Button, OWNER_LABEL, STATE_LABEL, Section, StateBadge, fmtTime } from "./ui"; +import { api, type Me } from "./useAlign"; import { nextStepHint } from "./nextStep"; import { ProofStrip } from "./ProofStrip"; import { SpartaProof } from "./SpartaProof"; +import { STALE_APPROVAL_NOTE, agreementStatus, approvedStale, launchLine } from "./launch"; +import { Chip, Collapsible, InkPill, Lbl, Msg, NumberedLines, Old, Option, conditionLines } from "./v6/thread"; -/** Split free text into short bullet lines: one per sentence, and "Lead: a, b, and c" becomes a lead line with sub-items. */ -function toBullets(text: string): { lead: string; items: string[] }[] { - return text - .split(/(?<=[.!?])\s+/) - .map((s) => s.trim().replace(/\.$/, "")) - .filter(Boolean) - .map((s) => { - const i = s.indexOf(": "); - if (i < 0) return { lead: s, items: [] }; - const items = s - .slice(i + 2) - .split(/,\s*(?:and\s+)?|\s+and\s+(?=[a-z])/) - .map((x) => x.trim()) - .filter(Boolean); - return items.length > 1 ? { lead: s.slice(0, i), items } : { lead: s, items: [] }; - }); -} - -function Bullets({ text, marker = "●" }: { text: string; marker?: string }) { - const lines = toBullets(text); - if (!lines.length) return

    —

    ; - return ( -
      - {lines.map((l, i) => ( -
    • - - {marker} - -
      - {l.lead} - {l.items.length > 0 && ( -
        - {l.items.map((it) => ( -
      • - - ◆ - - {it.charAt(0).toUpperCase() + it.slice(1)} -
      • - ))} -
      - )} -
      -
    • - ))} -
    - ); -} +/** Mirrors lib/flow.ts MAX_CONDITIONS (the server refuses longer conditions). */ +const MAX_CONDITIONS = 1000; /** Who speaks for a slot or role. Northstar keeps OWNER_LABEL; custom scenarios use the operator's names. */ function whoFor(L: Labels) { return (k: string) => (L.northstar ? (OWNER_LABEL[k] ?? k) : ((L.person as Record)[k] ?? k)); } -/** Where the proposal is in its four steps. Security may approve straight away, which skips step 3. */ -function Stepper({ p, L }: { p: Proposal; L: Labels }) { - const countered = p.counter_versions.length > 0; - const ask = L.team.product; - const own = L.team.security; - const steps = L.northstar - ? [ - { label: "Product proposes", done: true }, - { label: "Security reviews", done: p.state !== "PENDING_SECURITY" && p.state !== "DRAFT" }, - { - label: countered ? "Product accepts Security's changes" : "Product confirms its part", - done: p.state === "ACKNOWLEDGED_BY_PRODUCT" || p.state === "RATIFIED", - skipped: p.state === "RATIFIED" && !countered, - }, - { label: p.state === "REJECTED" ? "Security declined" : "Security approves its goal", done: p.state === "RATIFIED" || p.state === "REJECTED" }, - ] - : [ - { label: `${ask} proposes`, done: true }, - { label: `${own} reviews`, done: p.state !== "PENDING_SECURITY" && p.state !== "DRAFT" }, - { - label: countered ? `${ask} accepts ${poss(own)} changes` : `${ask} confirms its part`, - done: p.state === "ACKNOWLEDGED_BY_PRODUCT" || p.state === "RATIFIED", - skipped: p.state === "RATIFIED" && !countered, - }, - { label: p.state === "REJECTED" ? `${own} declined` : `${own} approves its goal`, done: p.state === "RATIFIED" || p.state === "REJECTED" }, - ]; - const current = steps.findIndex((s) => !s.done); - return ( -
      - {steps.map((st, i) => { - const now = i === current; - const cls = now - ? "bg-violet-50 ring-violet-400 text-violet-900" - : st.done - ? "bg-white ring-zinc-300 text-zinc-900" - : "bg-zinc-50 ring-zinc-200 text-zinc-500"; - return ( -
    1. - - {st.done ? "●" : now ? "◆" : "○"} - - - {i + 1}. {st.label} - {"skipped" in st && st.skipped && (not needed)} - -
    2. - ); - })} -
    - ); -} - /** Text the counter added to the previous version, when it only appended (e.g. "and its private notes stay untouched too"). */ function addedText(prev: string, next: string): string | null { const base = prev.replace(/[.\s]+$/, ""); @@ -443,7 +347,7 @@ function Composer(props: ViewProps & { busy: boolean; run: Run; onSent: () => vo
    - Set by CEO · view only · CEO goal ({ceo?.version ?? "—"}) {ceo?.changed && changed today} + Set by CEO · view only · CEO goal ({ceo?.version ?? "—"}) {ceo?.changed && {changedToday(ceo) ? "changed today" : "changed"}}

    {kr2?.text ?? "The CEO's new requirement isn't available right now."}

    @@ -552,62 +456,20 @@ function Composer(props: ViewProps & { busy: boolean; run: Run; onSent: () => vo ); } -// --------------------------------------------------------------------------------------------- - -interface TimelineRow { - key: string; - actor: string; - action: string; - at: string; -} - -function timelineFor(s: StateView, p: Proposal): TimelineRow[] { - const fromAudit = s.audit.filter((a) => a.target === p.id); - if (fromAudit.length) return fromAudit.map((a) => ({ key: a.id, actor: a.actor, action: a.action, at: a.at })); - const rows: TimelineRow[] = [{ key: "c", actor: p.proposing_owner, action: "propose", at: p.created_at }]; - p.counter_versions.forEach((c) => rows.push({ key: c.version, actor: c.by, action: "counter", at: c.at })); - if (p.product_ack_at) rows.push({ key: "ack", actor: "product", action: "acknowledge", at: p.product_ack_at }); - const acc = s.acceptances.find((a) => a.proposal_id === p.id); - if (acc) rows.push({ key: "rat", actor: "security", action: "ratify", at: acc.ratified_at }); - return rows; -} - -const ACTION_LABEL: Record = { - propose: "proposed the change", - counter: "asked for changes", - acknowledge: "accepted Security's changes and confirmed what Product will do", - ratify: "approved the change to its goal", - reject: "declined the proposal", -}; -/** Custom countered banner: quotes what the owner changed (the added words, else the new target), then why. */ -function CounterEdit({ p, by }: { p: Proposal; by: string }) { - const last = p.counter_versions.at(-1); - if (!last) return null; - const prev = p.counter_versions.length > 1 ? p.counter_versions[p.counter_versions.length - 2].proposed_measure_text : p.proposed_measure_text; - // Drop the joining punctuation the diff keeps (", " or ". ") so the quote starts at the added words. - const added = addedText(prev, last.proposed_measure_text)?.replace(/^[,.\s]+/, "") || null; - return ( -
    -

    - {by} {added ? "added" : "changed the new target to"}: “{added ?? last.proposed_measure_text}” -

    - {last.rationale &&

    Why: {last.rationale}

    } - {last.conditions && ( -

    - Conditions ({CONDITIONS_UNCONFIRMED.toLowerCase()}): {last.conditions} -

    - )} -

    The change is highlighted below.

    -
    - ); +// --------------------------------------------------------------------------------------------- +// Review: the proposal as a message thread (v6). Each version of the target is shown once, in the bubble that +// introduced it; the owner answers below. Every action here is the same call and payload as before v6. + +/** Plain run name for a test pill: which plan, which run, and the verdict with its checks. */ +function runPill(r: SimulationRun, proposedPlan: string): { text: string; tone: "green" | "red" } { + const side = r.plan_id === proposedPlan ? "New plan" : "Current plan"; + const kind = r.scenario === "normal" ? "" : " (outage test)"; + const n = r.checks.length ? ` ${r.checks.filter((c) => c.passed).length}/${r.checks.length}` : ""; + return { text: `${side}${kind}: ${r.verdict === "PASS" ? "passed" : "failed"}${n}`, tone: r.verdict === "PASS" ? "green" : "red" }; } -function customActionLabel(action: string, L: Labels): string | undefined { - if (action === "acknowledge") return `accepted ${poss(L.team.security)} changes and confirmed what ${L.team.product} will do`; - if (action === "ratify_untested") return "approved the change to its goal without a sandbox test"; - return ACTION_LABEL[action]; -} +type Answer = "accept" | "acceptIf" | "counter" | "decline"; function Detail(props: ViewProps & { p: Proposal; busy: boolean; run: Run }) { const { s, me, p, busy } = props; @@ -625,13 +487,20 @@ function Detail(props: ViewProps & { p: Proposal; busy: boolean; run: Run }) { const acc = s.acceptances.find((a) => a.proposal_id === p.id) ?? null; const canDecide = p.state === "PENDING_SECURITY" || p.state === "ACKNOWLEDGED_BY_PRODUCT"; const [ratifyWhy, setRatifyWhy] = useState(L.northstar ? "Beta customers' issues now come first, and the critical security ticket stays off-limits." : ""); - // Custom: an untested plan is approved only with this explicit acknowledgement (sent as accept_untested). - const needsAck = !L.northstar && p.test_status !== "TESTED"; + // An untested custom plan, or stale evidence in any scenario, is approved only with this explicit acknowledgement + // (sent as accept_untested). The server refuses either approval without it. + const needsAck = (!L.northstar && p.test_status !== "TESTED") || p.test_status === "STALE"; const [ackUntested, setAckUntested] = useState(false); // Custom: what the owning team made its approval depend on: its conditions field only, as on the decision record. const lastCounter = p.counter_versions.at(-1); const conditions = L.northstar ? "" : (lastCounter?.conditions?.trim() ?? ""); const hint = nextStepHint(p, me.actor, L); + const [answer, setAnswer] = useState("accept"); + + const ceo = currentGoal(s, "ceo"); + const ceoPrev = priorVersion(s, ceo); + const companyNow = ceo?.measures.find((m) => m.id === companyMeasureId(s))?.text; + const companyBefore = ceoPrev?.measures.find((m) => m.id === companyMeasureId(s))?.text; // Seeing the ratified record counts as "accepted version seen" for this session (server dedupes per actor). const marked = useRef(null); @@ -643,95 +512,245 @@ function Detail(props: ViewProps & { p: Proposal; busy: boolean; run: Run }) { } }, [p.state, p.id, mark]); + const footer = "flex flex-wrap items-center gap-3 border-t border-zinc-200 pt-4"; + return ( -
    -
    +
    +
    +
    + + +
    {L.northstar ? ( -

    Proposed change to Security's goal ({p.target_goal_version})

    +

    Proposed change to Security's goal ({p.target_goal_version})

    ) : ( -

    +

    Proposed change to {poss(own)} goal ({p.target_goal_version})

    )} -
    - - -
    -

    +

    From {who(p.proposing_owner)} to {who(p.recipient_owner)} · sent {fmtTime(p.created_at)}

    -
    - -
    - {/* Role-specific action / status area */} - {isProduct && p.state === "COUNTERED" && ( - -
    +
      + proposal · {p.target_goal_version}} at={fmtTime(p.created_at)} me={me.actor === p.proposing_owner}> +

      + Current + {oldMeasure} +

      +

      + Proposed + {p.proposed_measure_text} +

      + {p.rationale && ( +

      + Why: {p.rationale} +

      + )} +

      + In return: {p.reciprocal_commitment || "—"} +

      +
      + {L.sandbox && runs.length > 0 + ? runs.map((r) => { + const pill = runPill(r, p.plan_ref.plan_id); + return ( + + {pill.text} + + ); + }) + : } +
      +
      + + {p.counter_versions.map((c, i) => { + const prev = i === 0 ? p.proposed_measure_text : p.counter_versions[i - 1].proposed_measure_text; + const added = addedText(prev, c.proposed_measure_text); + const same = c.proposed_measure_text.trim() === prev.trim(); + const lines = L.northstar ? [] : conditionLines(c.conditions); + return ( + {lines.length && same ? "accept if" : "counter"} · {c.version}} + at={fmtTime(c.at)} + tone="counter" + me={me.actor === c.by} + > + {same ? ( +

      Kept the target as proposed.

      + ) : added ? ( +

      + …{added} +

      + ) : ( +

      + Revised + {c.proposed_measure_text} +

      + )} + {c.rationale &&

      Why: {c.rationale}

      } + {L.northstar && c.changes_plan && Changes how the agent works: needs a new test} + {lines.length > 0 && ( +
      +
      + Conditions + {CONDITIONS_UNCONFIRMED} +
      + +

      {conditionsUnconfirmedNote(lines.length)}

      +
      + )} +
      + ); + })} + + {p.product_ack_at && lastCounter && ( + acknowledged {lastCounter.version}} at={fmtTime(p.product_ack_at)} me={me.actor === p.proposing_owner}> +

      + {L.northstar + ? "Accepted Security's changes and confirmed what Product will do." + : `Accepted ${poss(own)} changes and confirmed what ${ask} will do.`} +

      +
      + )} + + {p.state === "RATIFIED" && acc && ( + ratified · goal {acc.accepted_version}} at={fmtTime(acc.ratified_at)} tone="ratified" me={me.actor === acc.ratified_by}> +
      + {L.northstar ? ( + Approved: Security's goal ({acc.accepted_version}) + ) : ( + + {agreementStatus(acc)}: {poss(own)} goal ({acc.accepted_version}) + + )} + +
      + {acc.rationale &&

      “{acc.rationale}”

      } +

      + {target?.version ? `${target.version} kept as superseded. ` : ""} + {acc.acknowledged_via === "authored_in_proposal" + ? `${who(acc.acknowledged_by)} committed to its part in the proposal.` + : `${who(acc.acknowledged_by)} accepted the changes at ${fmtTime(acc.acknowledged_at)}.`} +

      + {!L.northstar && acc.test_status !== "TESTED" && ( +

      Approved without a sandbox test: the plan was not run before approval.

      + )} + {L.northstar && approvedStale(s, acc) &&

      {STALE_APPROVAL_NOTE}

      } + {L.northstar && ( +
        +
      1. + 1 Security's AI agent switches to the{" "} + {planName(acc.plan_ref.plan_id).toLowerCase()} +
      2. +
      3. + 2 Product tags beta issues as they come in +
      4. +
      5. + 3 Product publishes what's in the beta +
      6. +
      + )} +

      + {L.northstar + ? "Both teams agreeing doesn't prove the beta launch will work. A passed test only covers the practice tickets." + : launchLine(acc, L)} +

      - {L.northstar ? "Security asked for changes" : `${own} asked for changes`} {L.northstar ? ( -

      - {who("security")} edited the new target - {p.counter_versions.at(-1)?.rationale ? `: "${p.counter_versions.at(-1)?.rationale}"` : "."} The change is highlighted below. -

      + ) : ( - + )}
      - +
      + )} + + {p.state === "REJECTED" && ( + declined} tone="declined" me={me.actor === p.recipient_owner}> + {L.northstar ? ( +

      {OWNER_LABEL.security} declined this proposal. Security's goal stays as it was.

      + ) : ( +

      + {L.person.security} declined this proposal. {poss(own)} goal stays as it was. +

      + )} +
      + )} +
    + + {p.state === "RATIFIED" && L.sandbox && } + +
    + + {L.northstar ? ( +
    + {runs.length ? props.openDrawer("scenario")} /> : } + {missing.length > 0 && runs.length > 0 && } +
    + ) : ( + + )} +
    + +
    +

    + {L.northstar ? "Set by CEO" : `Set by ${L.person.ceo}`} · company goal ({ceo?.version ?? "—"}) +

    + {companyBefore && companyBefore !== companyNow && ( +

    + {companyBefore} +

    + )} +

    + {companyNow ?? (L.northstar ? "The CEO's new requirement isn't available right now." : "The new requirement isn't available right now.")} +

    - - )} - {isProduct && p.state === "PENDING_SECURITY" && ( - -

    - Waiting for {who("security")} -

    - {hint &&

    {hint}

    } -
    - )} - {isProduct && p.state === "ACKNOWLEDGED_BY_PRODUCT" && ( - - You accepted the changes · waiting for {who("security")} to approve - {hint &&

    {hint}

    } -
    - )} - {isProduct && - (p.state === "PENDING_SECURITY" || p.state === "ACKNOWLEDGED_BY_PRODUCT" || p.state === "COUNTERED") && - (L.northstar ? ( -

    Only Security can approve a change to Security's goal.

    - ) : ( -

    - Only {own} can approve a change to {poss(own)} goal. -

    - ))} +
    +
    - {isSecurity && p.state === "COUNTERED" && ( - - {L.northstar ? "Waiting for Product to accept your changes" : `Waiting for ${ask} to accept your changes`} - {hint &&

    {hint}

    } -
    - )} + {/* Role-gated answer and footers: each control is one the server accepts from this role in this state. */} {isSecurity && canDecide && ( - -

    Your decision

    -
    -
    +
    +

    + Your decision +

    + {p.state === "ACKNOWLEDGED_BY_PRODUCT" && ( +

    + {who(p.proposing_owner)} accepted your changes. Approve to make them {L.northstar ? "Security's" : poss(own)} goal. +

    + )} +
    +
    + + {answer === "accept" && ( +
    {conditions && ( -

    - You asked for: {conditions} -

    +
    +
    + You asked for: + {CONDITIONS_UNCONFIRMED} +
    + +
    )} {needsAck && ( -
    - -
    - - )} - - {p.state === "REJECTED" && ( - - - {L.northstar ? ( -

    {OWNER_LABEL.security} declined this proposal. Security's goal stays as it was.

    - ) : ( -

    - {L.person.security} declined this proposal. {poss(own)} goal stays as it was. -

    )} -
    - )} - - {p.state === "RATIFIED" && acc && !L.northstar && ( -
    - -
    - - {agreementStatus(acc)}: {poss(own)} goal ({acc.accepted_version}) - - -
    -
      -
    • - ● - - {who(acc.ratified_by)} approved the new goal at {fmtTime(acc.ratified_at)} - {acc.rationale ? `: "${acc.rationale}"` : ""} - -
    • -
    • - ● - - {who(acc.acknowledged_by)}{" "} - {acc.acknowledged_via === "authored_in_proposal" ? "committed to its part in the proposal" : `accepted ${poss(own)} changes`} at{" "} - {fmtTime(acc.acknowledged_at)} - -
    • - {acc.test_status !== "TESTED" && ( -
    • - ● - Approved without a sandbox test: the plan was not run before approval. -
    • - )} -
    -

    Both teams agreeing doesn't prove the company requirement will be met.

    -
    - -
    -
    -
    - )} - {p.state === "RATIFIED" && acc && L.northstar && ( -
    - -
    - Approved: Security's goal ({acc.accepted_version}) - -
    -
      -
    • - ● - - {OWNER_LABEL[acc.ratified_by]} approved the new goal at {fmtTime(acc.ratified_at)} - {acc.rationale ? `: "${acc.rationale}"` : ""} - -
    • -
    • - ● - - {OWNER_LABEL[acc.acknowledged_by]}{" "} - {acc.acknowledged_via === "authored_in_proposal" ? "committed to its part in the proposal" : "accepted Security's changes"} at{" "} - {fmtTime(acc.acknowledged_at)} - -
    • -
    -

    What happens next

    -
      -
    1. - 1 Security's AI agent switches to the{" "} - {planName(acc.plan_ref.plan_id).toLowerCase()} -
    2. -
    3. - 2 Product tags beta issues as they come in -
    4. -
    5. - 3 Product publishes what's in the beta -
    6. -
    -

    - Both teams agreeing doesn't prove the beta launch will work. A passed test only covers the practice tickets. -

    -
    - + {answer === "acceptIf" && !L.northstar && ( + + )} + {answer === "counter" && } + {answer === "decline" && ( +
    +

    + {L.northstar ? "Security's goal stays as it was." : `${poss(own)} goal stays as it was.`} {who(p.proposing_owner)} sees that you declined. +

    +
    - -
    + )} + +

    + Agreement between two teams is not approval to launch.{" "} + {L.northstar ? "Launch approval stays with the CEO." : `${L.person.leadership} decides the launch.`} +

    +
    )} - {p.state === "RATIFIED" && L.sandbox && } -
    -
    -
    -
    Before
    -

    {oldMeasure}

    - {L.northstar &&

    Counts tickets closed. Beta issues wait in line behind older ones.

    } -
    -
    - → -
    -
    -
    - - {p.state === "RATIFIED" ? "Now agreed" : "Proposed"} - - {p.counter_versions.length > 0 && - (L.northstar ? Includes Security's edit : Includes {poss(own)} edit)} - {L.northstar && p.counter_versions.at(-1)?.changes_plan && Changes how the agent works: needs a new test} -
    -
    - -
    -
    + {isSecurity && p.state === "COUNTERED" && ( +
    + {L.northstar ? "Waiting for Product to accept your changes" : `Waiting for ${ask} to accept your changes`} + {hint &&

    {hint}

    }
    + )} - {p.counter_versions.length > 0 && ( -
      -
    1. - {who(p.proposing_owner)} proposed the first version at {fmtTime(p.created_at)} -
    2. - {p.counter_versions.map((c, i) => { - const prev = i === 0 ? p.proposed_measure_text : p.counter_versions[i - 1].proposed_measure_text; - const added = addedText(prev, c.proposed_measure_text); - return ( -
    3. - {who(c.by)} edited it at {fmtTime(c.at)} - {added ? ( - <> - , adding: {added} - - ) : ( - <>: new wording above - )} - {c.rationale &&
      Why: {c.rationale}
      } -
    4. - ); - })} -
    - )} -
    - -
    -
    - -
    -
    - -
    -
    + {isProduct && p.state === "COUNTERED" && ( +
    + + {who(p.recipient_owner)} asked for changes. Accept them to send the proposal back for final approval. + + +
    + )} - {L.northstar ? ( -
    - {runs.length ? ( - props.openDrawer("scenario")} /> + {isProduct && (p.state === "PENDING_SECURITY" || p.state === "ACKNOWLEDGED_BY_PRODUCT") && ( +
    + {p.state === "PENDING_SECURITY" ? ( + Waiting for {who("security")} ) : ( - + You accepted the changes · waiting for {who("security")} to approve )} - {missing.length > 0 && runs.length > 0 && } -
    - ) : ( - + + {L.northstar ? "Only Security can approve a change to Security's goal." : `Only ${own} can approve a change to ${poss(own)} goal.`} + + + {hint &&

    {hint}

    } +
    )} - -
    -
      - {timelineFor(s, p).map((t) => ( -
    1. - {fmtTime(t.at)} · {who(t.actor)}{" "} - {(L.northstar ? ACTION_LABEL[t.action] : customActionLabel(t.action, L)) ?? t.action} -
    2. - ))} -
    -
    ); } +/** The refusal demo: a non-owner tries the real accept endpoint. The server answers 403 and nothing changes; the + * button then says so. It never goes through props.run, so no global error banner and no state refresh. + * Guards, so a stale tab can never really approve: it first re-reads who this browser is signed in as (the same + * /api/session the header uses) and refuses here, with no request, unless that is still the waiting non-owner in this + * workspace; the request then carries the page's workspace exactly as act() does, so the server's stale-tab check + * (409 for another workspace) applies too. */ +function TryApprove({ p, owner, workspace }: { p: Proposal; owner: string; workspace: string }) { + const [out, setOut] = useState<{ kind: "refused" | "other"; text: string } | null>(null); + const [trying, setTrying] = useState(false); + const attempt = async () => { + setTrying(true); + try { + const now = await api("/api/session"); + const waiting = p.state === "PENDING_SECURITY" || p.state === "ACKNOWLEDGED_BY_PRODUCT"; + if (!waiting || now.actor !== p.proposing_owner || (now.workspace ?? "northstar") !== workspace) { + setOut({ kind: "other", text: "This tab is out of date: you are signed in as someone else now. Reload it." }); + return; + } + await api(`/api/proposals/${p.id}/accept`, { body: { rationale: "" }, workspace }); + setOut({ kind: "other", text: "The server accepted this. It should have refused it." }); + } catch (e) { + const status = (e as { status?: number }).status; + setOut(status === 403 ? { kind: "refused", text: `Handed back: only ${owner} can approve this` } : { kind: "other", text: (e as Error).message }); + } finally { + setTrying(false); + } + }; + return ( + + + {out && ( + + {out.text} + + )} + + ); +} + +/** Accept if…: the existing counter, with the target unchanged and the conditions field written one per line. */ +function AcceptIfForm({ p, latestText, busy, run, ask }: { p: Proposal; latestText: string; busy: boolean; run: Run; ask: string }) { + const [items, setItems] = useState([]); + const [draft, setDraft] = useState(""); + const [why, setWhy] = useState(""); + const joined = [...items, draft.trim()].filter(Boolean).join("\n"); + const tooLong = joined.length > MAX_CONDITIONS; + const add = () => { + const t = draft.trim(); + if (!t) return; + setItems((xs) => [...xs, t]); + setDraft(""); + }; + return ( +
    { + e.preventDefault(); + if (busy || !joined || tooLong) return; + void run(`/api/proposals/${p.id}/counter`, { proposed_measure_text: latestText, rationale: why, conditions: joined }); + }} + > +
    + I'll accept if + {items.length > 0 && ( +
      + {items.map((c, i) => ( +
    1. + {i + 1} + {c} + +
    2. + ))} +
    + )} +
    + + +
    + {tooLong && Keep the conditions to {MAX_CONDITIONS} characters or fewer.} +
    + +
    + + The target stays as proposed. {ask} accepts the conditions, then you give the final approval. +
    +
    + ); +} + function CounterForm({ p, latestText, busy, run, L }: { p: Proposal; latestText: string; busy: boolean; run: Run; L: Labels }) { const [text, setText] = useState( !L.northstar ? latestText : latestText.includes(COUNTER_SUFFIX.trim()) ? latestText : latestText + COUNTER_SUFFIX, @@ -960,6 +963,7 @@ function CounterForm({ p, latestText, busy, run, L }: { p: Proposal; latestText: const [why, setWhy] = useState(L.northstar ? "Keep the critical security ticket off-limits." : ""); const [changesPlan, setChangesPlan] = useState(false); const [conditions, setConditions] = useState(""); + const lines = conditionLines(conditions); return (
    { @@ -969,11 +973,11 @@ function CounterForm({ p, latestText, busy, run, L }: { p: Proposal; latestText: if (L.northstar) void run(`/api/proposals/${p.id}/counter`, { proposed_measure_text: text, rationale: why, changes_plan: changesPlan }); else void run(`/api/proposals/${p.id}/counter`, { proposed_measure_text: text, rationale: why, ...(conditions.trim() ? { conditions } : {}) }); }} - className="space-y-2" + className="al-in space-y-3" >