diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css new file mode 100644 index 000000000..e4f005056 --- /dev/null +++ b/packages/ui/src/amicode/amicode.css @@ -0,0 +1,577 @@ +/* AMICODE surfaces — the in-chat receipt card (card.tsx), the entity-view + * dialog body (entity-view.tsx), the problem-header rail (entity-rail.tsx), the + * question card (ask-card.tsx) and the run window (run-window.tsx). One shared + * visual language: a 1px hairline with a 3px accent rail, the H-mark as the + * signature (and, while working, the spinner), state carried in form as well as + * words. All colors come from the v2 theme tokens; only failure earns the one + * red rail. Namespaced by [data-component="amicode-*"] so nothing leaks. + * + * Imported from styles/index.css (components layer). Interaction/animation/ + * pseudo-element rules live here because inline styles can't express them. */ + +@keyframes amc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.32; } +} +@keyframes amc-blip { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); } + 50% { opacity: 0.45; box-shadow: 0 0 0 4px color-mix(in srgb, var(--v2-icon-icon-accent) 12%, transparent); } +} +@keyframes amc-shimmer { + 100% { transform: translateX(100%); } +} + +/* ---- shared signature (mark + wordmark) ---------------------------------- */ +.amc-mark { + width: 13px; + height: 13px; + flex-shrink: 0; + color: var(--v2-icon-icon-accent); + fill: currentColor; +} +@media (prefers-reduced-motion: no-preference) { + .amc-mark.is-running { animation: amc-pulse 1.2s ease-in-out infinite; } +} +.amc-sig { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.amc-wordmark { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.11em; + color: var(--v2-text-text-accent); +} +.amc-rule { + width: 1px; + align-self: stretch; + margin: 2px 10px; + background: var(--v2-border-border-base); + flex-shrink: 0; +} + +/* ============================================================ + THE CHIP — [data-component="amicode-card"] + ============================================================ */ +[data-component="amicode-card"] { + -webkit-appearance: none; + appearance: none; + font: inherit; + display: inline-flex; + align-items: center; + max-width: 100%; + min-width: 0; + text-align: left; + /* No side rail — identity comes from the accent-tinted wash + hairline and + the bright H-mark. A faint gold border reads as "AMICO" without a bar. */ + border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 22%, var(--v2-border-border-base)); + border-radius: 9px; + background: color-mix(in srgb, var(--v2-icon-icon-accent) 6%, var(--v2-background-bg-layer-01)); + padding: 5px 9px 5px 10px; + font-size: 12.5px; + line-height: 17px; + color: var(--v2-text-text-base); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); + transition: + background 0.16s ease, + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.12s ease; +} +[data-component="amicode-card"][data-clickable="true"] { + cursor: pointer; +} +[data-component="amicode-card"][data-clickable="true"]:hover { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 11%, var(--v2-background-bg-layer-01)); + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 42%, var(--v2-border-border-base)); + box-shadow: + var(--v2-elevation-raised, 0 1px 2px rgba(0, 0, 0, 0.25)), + 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 8%, transparent); +} +[data-component="amicode-card"][data-clickable="true"]:active { + transform: translateY(0.5px); +} +[data-component="amicode-card"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 2px; +} +[data-component="amicode-card"][data-state="error"] { + border-color: color-mix(in srgb, var(--v2-state-fg-danger) 38%, var(--v2-border-border-base)); + background: color-mix(in srgb, var(--v2-state-fg-danger) 6%, var(--v2-background-bg-layer-01)); +} +[data-component="amicode-card"][data-state="error"] .amc-mark, +[data-component="amicode-card"][data-state="error"] .amc-wordmark { + color: var(--v2-state-fg-danger); +} +[data-component="amicode-card"] .amc-mark { + width: 13px; + height: 13px; +} + +[data-component="amicode-card"] .amc-body { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + overflow: hidden; +} +[data-component="amicode-card"] .amc-label { + font-weight: 640; + color: var(--v2-text-text-base); + white-space: nowrap; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-detail { + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +[data-component="amicode-card"][data-state="running"] .amc-detail { + color: var(--v2-text-text-accent); +} +[data-component="amicode-card"][data-state="error"] .amc-detail { + color: var(--v2-state-fg-danger); +} + +/* structured diff pieces */ +[data-component="amicode-card"] .amc-diff { + display: inline-flex; + align-items: baseline; + gap: 4px; + white-space: nowrap; +} +[data-component="amicode-card"] .amc-diff + .amc-diff { + padding-left: 7px; + margin-left: 2px; + border-left: 1px solid var(--v2-border-border-muted); +} +[data-component="amicode-card"] .amc-diff .k { color: var(--v2-text-text-muted); } +[data-component="amicode-card"] .amc-diff .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11.5px; + color: var(--v2-text-text-base); +} +[data-component="amicode-card"] .amc-diff .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-card"] .amc-diff .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 1px; +} + +[data-component="amicode-card"] .amc-trail { + display: inline-flex; + align-items: center; + gap: 7px; + margin-left: 12px; + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-tick { + width: 12px; + height: 12px; + color: var(--v2-state-fg-success); + flex-shrink: 0; +} +[data-component="amicode-card"] .amc-livedot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--v2-icon-icon-accent); +} +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-card"][data-state="running"] .amc-livedot { animation: amc-blip 1.2s ease-in-out infinite; } +} +[data-component="amicode-card"] .amc-chev { + color: var(--v2-text-text-faint); + font-size: 15px; + line-height: 1; + opacity: 0; + transform: translateX(-3px); + transition: opacity 0.16s ease, transform 0.16s ease, color 0.16s ease; +} +[data-component="amicode-card"][data-clickable="true"]:hover .amc-chev, +[data-component="amicode-card"][data-clickable="true"]:focus-visible .amc-chev { + opacity: 1; + transform: translateX(0); + color: var(--v2-text-text-muted); +} +/* keyboard/touch: no hover — keep the affordance visible so it never hides */ +@media (hover: none) { + [data-component="amicode-card"][data-clickable="true"] .amc-chev { opacity: 0.6; transform: none; } +} + +/* ============================================================ + ENTITY VIEW — [data-component="amicode-entity-view"] (dialog body) + ============================================================ */ +[data-component="amicode-entity-view"] { + display: flex; + flex-direction: column; + padding: 4px 4px 0; + color: var(--v2-text-text-base); +} + +[data-component="amicode-entity-view"] .amc-ev-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 2px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 600; + padding: 3px 7px; + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-tier[data-tier="free"] { + background: var(--v2-state-bg-warning); + color: var(--v2-state-fg-warning); +} + +[data-component="amicode-entity-view"] .amc-ev-formula { + margin: 8px 0; + padding: 14px 16px; + border-radius: 10px; + background: var(--v2-background-bg-layer-01); + border: 1px solid var(--v2-border-border-muted); + overflow-x: auto; +} +[data-component="amicode-entity-view"] .amc-ev-formula-label { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--v2-text-text-faint); + margin-bottom: 10px; +} + +[data-component="amicode-entity-view"] .amc-ev-sec { + display: flex; + align-items: center; + gap: 10px; + margin: 20px 4px 8px; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 9px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-sec::after { + content: ""; + flex: 1; + height: 1px; + background: var(--v2-border-border-muted); +} + +[data-component="amicode-entity-view"] .amc-ev-group { + margin: 8px 4px 2px; + font-size: 11px; + font-weight: 600; + color: var(--v2-text-text-muted); +} + +[data-component="amicode-entity-view"] .amc-field { + display: grid; + grid-template-columns: 160px 1fr auto; + align-items: baseline; + gap: 14px; + padding: 7px 8px 7px 4px; + border-radius: 8px; + transition: background 0.12s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover { background: var(--v2-background-bg-layer-01); } +[data-component="amicode-entity-view"] .amc-field .amc-fk { min-width: 0; } +[data-component="amicode-entity-view"] .amc-field .amc-fk .name { + font-size: 13px; + font-weight: 560; + color: var(--v2-text-text-base); +} +[data-component="amicode-entity-view"] .amc-field .amc-fk .raw { + display: block; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-faint); + margin-top: 1px; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 13px; + color: var(--v2-text-text-base); + text-align: right; + font-variant-numeric: tabular-nums; + word-break: break-word; + min-width: 0; +} +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed { color: var(--v2-text-text-accent); } +[data-component="amicode-entity-view"] .amc-field .amc-fv.changed::before { + content: "•"; + color: var(--v2-icon-icon-accent); + padding-right: 6px; + font-size: 10px; + vertical-align: middle; +} + +[data-component="amicode-entity-view"] .amc-edit { + -webkit-appearance: none; + appearance: none; + justify-self: end; + font: inherit; + font-size: 11px; + color: var(--v2-text-text-muted); + background: transparent; + border: 1px solid var(--v2-border-border-muted); + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + opacity: 0.55; + transition: opacity 0.14s ease, color 0.14s ease, border-color 0.14s ease, background 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit { opacity: 1; } +[data-component="amicode-entity-view"] .amc-edit:hover { + color: var(--v2-text-text-accent); + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 45%, transparent); + background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent); +} +[data-component="amicode-entity-view"] .amc-edit:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 1px; + opacity: 1; +} +[data-component="amicode-entity-view"] .amc-edit .amc-edit-label { + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.18s ease, opacity 0.14s ease; +} +[data-component="amicode-entity-view"] .amc-field:hover .amc-edit .amc-edit-label, +[data-component="amicode-entity-view"] .amc-edit:focus-visible .amc-edit-label { + max-width: 90px; + opacity: 1; +} + +[data-component="amicode-entity-view"] .amc-timeline { + display: flex; + flex-direction: column; + margin-left: 6px; + padding-left: 2px; + border-left: 1.5px solid var(--v2-border-border-muted); +} +[data-component="amicode-entity-view"] .amc-event { + position: relative; + padding: 9px 10px 9px 20px; + border-radius: 8px; +} +[data-component="amicode-entity-view"] .amc-event::before { + content: ""; + position: absolute; + left: -5px; + top: 15px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--v2-background-bg-base); + border: 1.5px solid var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored] { + background: color-mix(in srgb, var(--v2-icon-icon-accent) 9%, transparent); +} +[data-component="amicode-entity-view"] .amc-event[data-anchored]::before { + background: var(--v2-icon-icon-accent); + border-color: var(--v2-icon-icon-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-icon-icon-accent) 18%, transparent); +} +[data-component="amicode-entity-view"] .amc-event .erow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-component="amicode-entity-view"] .amc-event .seq { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 10px; + color: var(--v2-text-text-muted); + background: var(--v2-background-bg-layer-02); + border-radius: 5px; + padding: 1px 6px; +} +[data-component="amicode-entity-view"] .amc-event .src { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 11px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-event .when { + font-size: 11px; + color: var(--v2-text-text-faint); + margin-left: auto; +} +[data-component="amicode-entity-view"] .amc-event .summary { + font-size: 13px; + color: var(--v2-text-text-base); + margin-top: 3px; +} +[data-component="amicode-entity-view"] .amc-event .summary .k { color: var(--v2-text-text-muted); } +[data-component="amicode-entity-view"] .amc-event .summary .v { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: 12px; +} +[data-component="amicode-entity-view"] .amc-event .summary .v.from { color: var(--v2-text-text-faint); } +[data-component="amicode-entity-view"] .amc-event .summary .arw { + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--v2-text-text-faint); + padding: 0 4px; +} + +[data-component="amicode-entity-view"] .amc-ev-foot { + display: flex; + align-items: center; + gap: 9px; + margin-top: 18px; + padding: 12px 4px 6px; + border-top: 1px solid var(--v2-border-border-muted); + font-size: 12px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-foot .amc-mark { + width: 13px; + height: 13px; + color: var(--v2-text-text-faint); +} +[data-component="amicode-entity-view"] .amc-ev-foot b { + color: var(--v2-text-text-base); + font-weight: 600; +} + +[data-component="amicode-entity-view"] .amc-ev-empty { + font-size: 13px; + color: var(--v2-text-text-muted); + padding: 4px; +} + +/* error + skeleton states */ +[data-component="amicode-entity-view"] .amc-ev-error { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} +[data-component="amicode-entity-view"] .amc-ev-error .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--v2-state-fg-danger); +} +[data-component="amicode-entity-view"] .amc-ev-error .msg { + flex: 1; + font-size: 14px; + color: var(--v2-text-text-muted); +} +[data-component="amicode-entity-view"] .amc-ev-retry { + -webkit-appearance: none; + appearance: none; + font: inherit; + font-size: 12px; + color: var(--v2-text-text-base); + background: transparent; + border: 1px solid var(--v2-border-border-strong); + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; + flex-shrink: 0; +} +[data-component="amicode-entity-view"] .amc-ev-retry:hover { background: var(--v2-background-bg-layer-02); } +[data-component="amicode-entity-view"] .amc-ev-retry:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 1px; +} + +[data-component="amicode-entity-view"] .amc-sk { + height: 14px; + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + position: relative; + overflow: hidden; + margin: 12px 0; +} +[data-component="amicode-entity-view"] .amc-sk.w60 { width: 60%; } +[data-component="amicode-entity-view"] .amc-sk.w40 { width: 40%; } +@media (prefers-reduced-motion: no-preference) { + [data-component="amicode-entity-view"] .amc-sk::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--v2-text-text-base) 8%, transparent), transparent); + animation: amc-shimmer 1.4s infinite; + } +} + +/* ============================================================ + SHARED CARD SURFACES — rail, ask, run window + ============================================================ */ +[data-component="amicode-entity-rail"], +[data-component="amicode-ask-card"], +[data-component="amicode-run-window"] { + /* Same rail-free treatment as the receipt card: gold-tinted hairline + a + whisper of accent wash, so the whole AMICO family shares one identity. */ + border: 1px solid color-mix(in srgb, var(--v2-icon-icon-accent) 20%, var(--v2-border-border-base)); + border-radius: 9px; + background: color-mix(in srgb, var(--v2-icon-icon-accent) 5%, var(--v2-background-bg-layer-01)); + box-shadow: var(--v2-elevation-elements, 0 0.5px 0.5px rgba(0, 0, 0, 0.2)); +} + +/* rail chip hover */ +[data-component="amicode-entity-rail"] [data-slot="amicode-rail-chip"] { + border-radius: 5px; + padding: 1px 5px; + margin: -1px -1px; + transition: background 0.14s ease; +} +[data-component="amicode-entity-rail"] button[data-slot="amicode-rail-chip"]:hover { + background: var(--v2-background-bg-layer-02); +} +[data-component="amicode-entity-rail"] [data-slot="amicode-rail-problem"]:hover { + color: var(--v2-text-text-accent) !important; +} +[data-component="amicode-entity-rail"] button:focus-visible, +[data-component="amicode-ask-card"] button:focus-visible, +[data-component="amicode-run-window"] button:focus-visible { + outline: 2px solid var(--v2-border-border-focus, var(--v2-icon-icon-accent)); + outline-offset: 2px; +} + +/* ask-card option hover */ +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled) { + transition: background 0.14s ease, border-color 0.14s ease, transform 0.12s ease; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):hover { + border-color: var(--v2-icon-icon-accent) !important; + background: var(--v2-background-bg-layer-03) !important; +} +[data-component="amicode-ask-card"] [data-slot="amicode-ask-option"]:not(:disabled):active { + transform: translateY(0.5px); +} + +/* run window: subtle hover to signal it opens the Run entity */ +[data-component="amicode-run-window"] { + transition: background 0.16s ease, border-color 0.16s ease; +} +[data-component="amicode-run-window"]:hover { + border-color: color-mix(in srgb, var(--v2-icon-icon-accent) 40%, var(--v2-border-border-base)); + background: color-mix(in srgb, var(--v2-icon-icon-accent) 8%, var(--v2-background-bg-layer-01)); +} diff --git a/packages/ui/src/amicode/ask-card.tsx b/packages/ui/src/amicode/ask-card.tsx index 501379d6d..71b224a6c 100644 --- a/packages/ui/src/amicode/ask-card.tsx +++ b/packages/ui/src/amicode/ask-card.tsx @@ -1,6 +1,7 @@ import { For, Show, createSignal } from "solid-js" import { amicodeAskBridge } from "./ask-bridge" import { answeredOption, hasUserReplyAfter, type AskInput } from "./ask" +import { AmicoMark } from "./spinner" import { useData } from "../context/data" // AMICODE: question card for amicode_ask tool parts — question text + one @@ -43,24 +44,15 @@ export function AmicodeAskCard(props: { ask: AskInput; messageID?: string; sessi "flex-direction": "column", gap: "8px", "min-width": "0", - border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "8px 12px", "font-size": "12px", "line-height": "16px", }} > -
- - AMICO +
+ + + AMICO · Question diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index c392996a2..1a021051d 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -1,8 +1,9 @@ -import { Match, Show, Switch, createMemo } from "solid-js" +import { For, Match, Show, Switch, createMemo } from "solid-js" import { amicodeStage } from "./stage" import { parseAskInput } from "./ask" import { AmicodeAskCard } from "./ask-card" -import { parseDiffSentinel, receiptText } from "./receipt" +import { parseDiffSentinel, receiptParts } from "./receipt" +import { AmicoMark } from "./spinner" import { openAmicodeEntity } from "./ui-bridge" import { RunWindow } from "./run-window" @@ -31,74 +32,150 @@ function runRefFromOutput(output: unknown): { run: string; lab?: string } | unde // is agent-directed text and is deliberately NOT rendered — durable state // lives in the problem rail (./entity-rail.tsx). Receipt click opens the // entity view through the ui-bridge (no-op until the rail registers it). +// +// The card carries four states, differentiated in FORM, not just words: the +// H-mark is the spinner while working (running), settles under a green check +// when done with no diff, becomes a structured diff receipt when there is one, +// and turns its rail red on failure. It is a real + ) } diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index c3c4e2b86..3af14de19 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -1,6 +1,7 @@ import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" import { hasUserReplyAfter } from "./ask" import { registerAmicodeAskBridge } from "./ask-bridge" +import { AmicoMark } from "./spinner" import { registerAmicodeUiBridge } from "./ui-bridge" import { type ProblemView, @@ -147,25 +148,15 @@ export function AmicodeEntityRail(props: { "min-width": "0", "max-height": "76px", "overflow-y": "auto", - border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "6px 10px", "font-size": "11px", "line-height": "16px", "white-space": "nowrap", }} > - - AMICO + + + AMICO = { "\\hat H = \\tfrac{\\Omega(t)}{2}\\sum_i \\sigma_x^{(i)} - \\Delta(t)\\sum_i \\hat n_i + \\sum_{i, +): DiffPiece[] { + const { changes } = receiptParts({ problem: "", entity, action, diff: diff ?? {} }) + return changes.map((change) => + change.kind === "elision" + ? { key: "…" } + : change.kind === "set" + ? { key: humanizeKey(change.key), to: change.to } + : { key: humanizeKey(change.key), from: change.from, to: change.to }, + ) +} + export function AmicodeEntityView(props: { view: ProblemView | undefined // undefined → loading skeleton kind: string @@ -37,6 +64,41 @@ export function AmicodeEntityView(props: { ? props.anchorSeq : undefined, ) + const changedKeys = createMemo(() => { + const seq = anchored() + const set = new Set() + if (seq === undefined) return set + const event = history().find((candidate) => candidate.seq === seq) + if (event?.diff) + for (const key of Object.keys(event.diff)) { + set.add(key) + const bare = key.split(".").pop() + if (bare) set.add(bare) + } + return set + }) + const isChanged = (key: string) => { + const set = changedKeys() + return set.has(key) || set.has(key.split(".").pop() ?? key) + } + // Rows with a group-header flag when a nested group first appears (entityRows + // flattens each object's children contiguously, so a group is one run). + const fieldRows = createMemo(() => { + let prevGroup: string | undefined + return rows().map((row) => { + const group = fieldGroup(row.key) + const showGroupHeader = group !== undefined && group !== prevGroup + prevGroup = group + return { + key: row.key, + value: row.value, + name: humanizeKey(row.key), + groupLabel: group ? humanizeKey(group) : undefined, + showGroupHeader, + } + }) + }) + const latestTs = createMemo(() => history()[0]?.ts) const runTier = createMemo(() => { if (props.kind !== "run" || !props.view) return undefined const refs = props.view.runs @@ -52,175 +114,151 @@ export function AmicodeEntityView(props: { }) return ( -
+
+
+
+
+
+
+
} > {(view) => ( -
- - {view().error} - -
} > - - {(tier) => ( -
- - {tier() === "free" ? "free · unvetted" : tier()} - -
- )} + +
+ + {(tier) => ( + + {tier() === "free" ? "free · unvetted" : tier()} + + )} + + {(ts) => Updated {ts()}} +
+ {(html) => ( -
+
+
Hamiltonian
+
+
)} + 0} - fallback={ -
- — -
- } + when={fieldRows().length > 0} + fallback={
No fields recorded yet.
} > +
Details
- + {(row) => ( -
- - {row.key} - - - {row.value} - - -
+ <> + +
{row.groupLabel}
+
+
+ + {row.name} + {row.key} + + + {row.value} + + +
+ )}
+ 0}> -
- - History - -
- - {(event) => ( -
{ - if (anchored() === event.seq) queueMicrotask(() => el.scrollIntoView({ block: "nearest" })) - }} - > -
- - #{event.seq} - - - {(source) => ( - - {source()} - +
History
+
+ + {(event) => ( +
{ + if (anchored() === event.seq) queueMicrotask(() => el.scrollIntoView({ block: "nearest" })) + }} + > +
+ #{event.seq} + + {(source) => {source()}} + + {(ts) => {ts()}} +
+
+ 0} + fallback={{event.action ? humanizeKey(event.action) : "—"}} + > + + {(piece, index) => ( + <> + 0}> + + + {piece.key} + + {piece.from} + + + + {piece.to} + + )} - - - - {event.ts} - - -
- - {receiptText({ - problem: "", - entity: event.entity, - action: event.action, - diff: (event.diff ?? {}) as Record, - })} - + +
- )} -
-
+
+ )} +
+ +
+ + + Read-only. Changes are made by asking AMICO in chat — ✎ drafts the message for you. + +
)} diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 6008021f8..64bb2a281 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -132,7 +132,7 @@ const CARD: JSX.CSSProperties = { background: "var(--v2-background-bg-layer-01)", padding: "14px 16px", } -const HERO_CARD: JSX.CSSProperties = { ...CARD, "border-left": "3px solid var(--v2-icon-icon-accent)" } +const HERO_CARD: JSX.CSSProperties = { ...CARD } const EYEBROW: JSX.CSSProperties = { "font-size": "10px", "font-weight": "700", diff --git a/packages/ui/src/amicode/onboarding-wizard.tsx b/packages/ui/src/amicode/onboarding-wizard.tsx index cc8b7c298..c07a02b6e 100644 --- a/packages/ui/src/amicode/onboarding-wizard.tsx +++ b/packages/ui/src/amicode/onboarding-wizard.tsx @@ -216,7 +216,6 @@ export function AmicodeOnboardingWizard(props: { "overflow-y": "auto", "border-radius": "16px", border: "1px solid var(--v2-border-border-base)", - "border-top": "4px solid var(--v2-icon-icon-accent)", background: "var(--v2-background-bg-base)", padding: "28px 32px 22px", display: "flex", diff --git a/packages/ui/src/amicode/problem.ts b/packages/ui/src/amicode/problem.ts index eebc7e2ec..0ae8f64c5 100644 --- a/packages/ui/src/amicode/problem.ts +++ b/packages/ui/src/amicode/problem.ts @@ -277,6 +277,22 @@ export function railState(current: ProblemView | undefined, lastGood: ProblemVie export type EntityRow = { key: string; value: string } +/** Field/label humanizer for the entity view: last dotted segment, underscores + * to spaces, sentence case — `params.drive_max` → "Drive max", `problem_type` + * → "Problem type". Presentation only; the raw key is still shown alongside. */ +export function humanizeKey(key: string): string { + const raw = (key.split(".").pop() ?? key).replaceAll("_", " ").trim() + if (!raw) return key + return raw.charAt(0).toUpperCase() + raw.slice(1) +} + +/** The dotted prefix of a flattened row key (the group it belongs to), or + * undefined for a top-level scalar. `params.drive_max` → "params". */ +export function fieldGroup(key: string): string | undefined { + const dot = key.indexOf(".") + return dot === -1 ? undefined : key.slice(0, dot) +} + /** Stable field rows: scalars in object order, one level of object nesting * flattened to dotted keys, undefined skipped. */ export function entityRows(entity: Record): EntityRow[] { diff --git a/packages/ui/src/amicode/receipt.ts b/packages/ui/src/amicode/receipt.ts index 532f13881..257041dd2 100644 --- a/packages/ui/src/amicode/receipt.ts +++ b/packages/ui/src/amicode/receipt.ts @@ -61,18 +61,39 @@ function short(value: unknown): string { return JSON.stringify(value) } +// A receipt's body, broken into typed pieces so the UI can render each change +// as a discrete unit (dimmed old value · arrow · new value) instead of a flat +// string. `receiptText` below joins these back into the exact one-line form the +// legacy chip and the tests expect — the structured and string renders never +// diverge because they come from the same source. +export type ReceiptChange = + | { kind: "elision" } + | { kind: "set"; key: string; to: string } + | { kind: "change"; key: string; from: string; to: string } +export type ReceiptParts = { label: string; changes: ReceiptChange[]; action: string } + +export function receiptParts(sentinel: DiffSentinel): ReceiptParts { + const changes: ReceiptChange[] = Object.entries(sentinel.diff).map(([key, entry]) => { + if (key === "…") return { kind: "elision" } + const bare = key.split(".").pop() ?? key + if (entry.from === null || entry.from === undefined) return { kind: "set", key: bare, to: short(entry.to) } + return { kind: "change", key: bare, from: short(entry.from), to: short(entry.to) } + }) + return { label: entityLabel(sentinel.entity), changes, action: sentinel.action } +} + /** `System · levels 3→4 · omega 4.8` — dotted diff keys render bare (last * segment); creates (from null) render value-only; the spec-A elision key * `…` renders as a bare ellipsis. Empty diff → the action. One line, always. */ export function receiptText(sentinel: DiffSentinel): string { - const parts = Object.entries(sentinel.diff).map(([key, entry]) => { - if (key === "…") return "…" - const bare = key.split(".").pop() ?? key - if (entry.from === null || entry.from === undefined) return `${bare} ${short(entry.to)}` - return `${bare} ${short(entry.from)}→${short(entry.to)}` + const { label, changes, action } = receiptParts(sentinel) + const parts = changes.map((change) => { + if (change.kind === "elision") return "…" + if (change.kind === "set") return `${change.key} ${change.to}` + return `${change.key} ${change.from}→${change.to}` }) - const body = parts.length > 0 ? parts.join(" · ") : sentinel.action - return `${entityLabel(sentinel.entity)} · ${body}` + const body = parts.length > 0 ? parts.join(" · ") : action + return `${label} · ${body}` } /** For any raw-output display path: drop a trailing sentinel line. NOTE: the diff --git a/packages/ui/src/amicode/run-window.tsx b/packages/ui/src/amicode/run-window.tsx index 4573c146e..c4cda0792 100644 --- a/packages/ui/src/amicode/run-window.tsx +++ b/packages/ui/src/amicode/run-window.tsx @@ -1,4 +1,5 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { AmicoMark } from "./spinner" import { fetchAmicodeRunSeries, openAmicodeEntity } from "./ui-bridge" import { type RunSeries, type RunSeriesView, elapsedLabel, headlineMetric, parseRunSeriesResponse } from "./run-series" @@ -189,10 +190,6 @@ export function RunWindow(props: { run: string; lab?: string }) { "flex-direction": "column", gap: "6px", "min-width": "0", - border: "1px solid var(--v2-border-border-base)", - "border-left": "3px solid var(--v2-icon-icon-accent)", - "border-radius": "6px", - background: "var(--v2-background-bg-layer-01)", padding: "8px 12px", "font-size": "12px", "line-height": "16px", @@ -200,9 +197,10 @@ export function RunWindow(props: { run: string; lab?: string }) { }} > {/* header: AMICO · Run · status · iter · metric · elapsed */} -
- - AMICO +
+ + + AMICO · Run diff --git a/packages/ui/src/amicode/spinner.tsx b/packages/ui/src/amicode/spinner.tsx index a836b2d1f..b807021f6 100644 --- a/packages/ui/src/amicode/spinner.tsx +++ b/packages/ui/src/amicode/spinner.tsx @@ -21,6 +21,25 @@ import { MARK_PATH } from "../components/logo" const reducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches +// Static/animated brand mark for the amicode surfaces (receipt card, entity +// view, rail, ask, run window). Same H-glyph as AmicoSpinner; color comes from +// the `.amc-mark` class (accent) via currentColor, and `running` toggles the +// pulse through `.amc-mark.is-running` (CSS owns the prefers-reduced-motion +// guard here — see amicode.css — rather than the matchMedia guard below). +export function AmicoMark(props: { class?: string; running?: boolean }) { + return ( + + ) +} + export function AmicoSpinner(props: { class?: string classList?: ComponentProps<"div">["classList"] diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 1b17f6c2b..d5a8d22f5 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -35,6 +35,7 @@ @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); +@import "../amicode/amicode.css" layer(components); @import "../components/message-nav.css" layer(components); @import "../components/popover.css" layer(components); @import "../components/progress.css" layer(components);