From dbe6035279ceda0f08eb97c782f5aa5fa4aca6d3 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Thu, 22 Oct 2026 17:06:29 -0700 Subject: [PATCH 01/21] LIM-2424 v6 W1: retune theme tokens and UI primitives to the v6 design Map the --al-* tokens onto the Liminal design-system values v6 uses: near-black page (#0a0a0b), layered frame surfaces, v6 ink levels, Liminal magenta accent (#cc24f5), and the canon watch/alarm/success colours. Truth-colour meanings are unchanged. The display stack becomes a local() Nineties Headliner @font-face with Anton as the fallback; no font files are added. Badges become tone-filled pills (border + tinted bg), buttons follow v6 .btn p/s/g, labels use v6 .lbl sizing. Exported names, props and text are unchanged. --- app/globals.css | 85 ++++++++++++++++++++++++++++------------------- components/ui.tsx | 31 +++++++++-------- 2 files changed, 67 insertions(+), 49 deletions(-) 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/ui.tsx b/components/ui.tsx index 4499bc12..5033f59d 100644 --- a/components/ui.tsx +++ b/components/ui.tsx @@ -4,21 +4,21 @@ import { customStateLabel, type Labels } from "@/lib/labels"; export type Tone = "violet" | "amber" | "neutral" | "red" | "green" | "blue"; -// Outline pills: mono, rounded, colored border + text, no fill. +// v6 pills: rounded, tone text + tone border + tinted tone fill. Neutral is the v6 grey pill. const TONES: Record = { - violet: "text-violet-700 border-violet-700", - amber: "text-amber-700 border-amber-700", - neutral: "text-zinc-700 border-zinc-300", - red: "text-red-700 border-red-700", - green: "text-emerald-700 border-emerald-700", - blue: "text-sky-800 border-sky-800", + violet: "text-violet-700 border-violet-700 bg-violet-50", + amber: "text-amber-700 border-amber-700 bg-amber-50", + neutral: "text-zinc-700 border-zinc-200 bg-zinc-50", + red: "text-red-700 border-red-700 bg-red-50", + green: "text-emerald-700 border-emerald-700 bg-emerald-50", + blue: "text-sky-800 border-sky-800 bg-sky-50", }; const ICONS: Record = { violet: "◆", amber: "▲", neutral: "●", red: "✕", green: "✓", blue: "ⓘ" }; export function Badge({ tone, children, icon = true }: { tone: Tone; children: ReactNode; icon?: boolean }) { return ( - + {icon && {ICONS[tone]}} {children} @@ -79,13 +79,16 @@ export function Button({ type?: "button" | "submit"; }) { const k = { - primary: "border-transparent bg-violet-700 text-black hover:brightness-110", - secondary: "border-zinc-300 text-zinc-900 hover:border-zinc-500 hover:bg-zinc-100", - danger: "border-red-700 text-red-700 hover:bg-red-50", - ghost: "border-transparent text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900", + // v6 .btn.p: Liminal magenta fill, near-black text. + primary: "border-transparent bg-violet-700 text-black hover:brightness-112", + // v6 .btn.s: outlined on card-border-2, hover lifts the surface. + secondary: "border-zinc-300 bg-transparent text-zinc-900 hover:bg-zinc-100", + danger: "border-red-700 bg-transparent text-red-700 hover:bg-red-50", + // v6 .btn.g: no border, mid ink, brightens on hover. + ghost: "border-transparent bg-transparent text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900", }[kind]; return ( - ); @@ -104,7 +107,7 @@ export function Section({ title, children, right }: { title: string; children: R } export function Mono({ children }: { children: ReactNode }) { - return {children}; + return {children}; } export const OWNER_LABEL: Record = { From 2570822896d211705c6b99e84c6a348719c13632 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Thu, 22 Oct 2026 17:08:25 -0700 Subject: [PATCH 02/21] LIM-2424 v6 W3: proposal review as a message thread Rebuild the proposal detail as the v6 thread: proposer bubble (current struck grey, proposed, why, in return, test pills), one bubble per counter version (amber, conditions as a numbered list marked not confirmed yet), the acknowledged bubble, and the ratified bubble. Each target version is shown once; the stepper, before/after block, repeated why/in-return sections and timeline are removed. Evidence and What changed upstream are collapsible. The owner answers with Accept / Accept if... (custom only) / Counter / Decline. Accept if... is the existing counter with the target unchanged and the single conditions field written one per line. Same API calls and payloads as before, except approval of STALE evidence now sends accept_untested after the explicit tick (the server refused it otherwise). Role-gated footers offer only actions the server accepts in each state; no withdraw, no try-to-ratify-anyway. test-ui-loop picks Counter before filling the renamed counter field. --- components/ProposalsView.tsx | 800 +++++++++++++++++------------------ components/v6/thread.tsx | 149 +++++++ scripts/test-ui-loop.mjs | 3 +- 3 files changed, 530 insertions(+), 422 deletions(-) create mode 100644 components/v6/thread.tsx diff --git a/components/ProposalsView.tsx b/components/ProposalsView.tsx index b9e446f4..6f98a2b1 100644 --- a/components/ProposalsView.tsx +++ b/components/ProposalsView.tsx @@ -1,120 +1,24 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { AGREED_STATUS, CONDITIONS_UNCONFIRMED, companyMeasureId, conflictMeasureId, labelsFor, poss, type Labels } from "@/lib/labels"; +import { AGREED_STATUS, 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, currentGoal, priorVersion } from "./select"; import { Badge, Button, OWNER_LABEL, STATE_LABEL, Section, StateBadge, fmtTime } from "./ui"; import { nextStepHint } from "./nextStep"; import { ProofStrip } from "./ProofStrip"; import { SpartaProof } from "./SpartaProof"; +import { 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]+$/, ""); @@ -551,62 +455,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; @@ -624,13 +486,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); @@ -642,95 +511,244 @@ 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}) + ) : ( + + {AGREED_STATUS}: {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 && ( +
      +
    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 && ( -
- -
- - {AGREED_STATUS}: {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. - ))} -
-
); } +/** 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, @@ -959,6 +915,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 (
{ @@ -968,11 +925,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" >