Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 71a340f31a fix: sync automation.meta.json hubRef to v1.1.14
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:29:51 +00:00
MervinPraisonandClaude Opus 5.5 47a057e312 fix(ci): github-automation-template v1.1.14 (fork PR isolation)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:58:52 +01:00
Mervin PraisonandClaude Opus 5.5 83a1e10c4a fix(ci): require write access to invoke @claude (template v1.1.13) (#306)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:30:36 +01:00
f7d4eb8a13 chore: bump github-automation-template hub_ref to v1.1.11 (#305)
Picks up the 403 label fix in the hub callable workflows.

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-30 08:38:05 +01:00
Mervin Praison f254fefe95 Merge pull request #304 from MervinPraison/claude/issue-293-20260923
spike: evaluate BeautifulUI primitives in dev environment (#293)
2026-09-24 04:27:06 +01:00
Mervin Praison e22537fa38 Merge pull request #302 from MervinPraison/claude/issue-295-20260923
ci: add frontend build gate for BeautifulUI dashboard
2026-09-24 04:26:45 +01:00
MervinPraisonandClaude Opus 4.8 ec31577a38 spike: evaluate BeautifulUI primitives in dev environment (fixes #293)
Isolated, dev-only spike proving StreamingText, ThinkingState and
ApprovalCard build in src/frontend (React 19 + Vite + Tailwind v4) and
render against mock, PraisonAIUI-shaped agent event streams.

- Vendored 3 primitives + deps (button, glide-menu, foundation.css) under
  src/frontend/src/spike/beautifui/, imports rewritten to @/spike/* so
  nothing collides with the shipped components or the templates/frontend
  bundle (per issue non-goals).
- mockEvents.ts uses the real SSEEvent contract (run_content deltas,
  reasoning_step/tool_call_*, approval_request → /api/approvals).
- spike.html + SpikePage.tsx: `npm run dev` → /spike.html.
- Findings in src/frontend/src/spike/README.md: zero new runtime npm deps
  (liveline/glimm not pulled; @central-icons-react avoided — inline SVG),
  fixed an upstream unbalanced-brace bug in foundation.css, dropped the
  shadow-plugin dep, documented bundle size / reduced-motion / dark-mode.

Upstream pin: slev12397/beautiful-ui@44a274e598

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:52:48 +00:00
MervinPraisonandClaude Opus 4.8 166091bc8e ci: add frontend build gate for BeautifulUI dashboard (fixes #295)
Adds a `frontend` job to CI that runs `npm ci && npm run build` for
src/frontend on every push/PR to main, failing on TypeScript/Vite build
errors. Uses Node 22 with npm caching keyed on the frontend lockfile.

Also documents the Node version, lockfile, and build/ship path in
src/frontend/README.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:50:39 +00:00
21 changed files with 2123 additions and 19 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"stack": "claude",
"profile": "sdk",
"mode": "hub",
"hubRef": "v1.1.8",
"hubRef": "v1.1.14",
"templateVersion": "1.1.7",
"installedAt": "2026-09-07T10:02:11Z"
}
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
pr_number: ${{ github.event.inputs.pr_number || '' }}
+24
View File
@@ -36,6 +36,30 @@ jobs:
- name: Integration smoke tests
run: pytest tests/integration/test_agentic_roundtrip.py tests/test_feature_sdk_backends.py -q --no-cov --timeout=120
frontend:
name: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: src/frontend
# `npm run build` is the dashboard ship path: dist/ is synced to
# src/praisonaiui/templates/frontend/ (see src/frontend/README.md).
- name: Build frontend
run: npm run build
working-directory: src/frontend
docs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
+4 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]0
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]4
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -87,9 +87,9 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]0
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]4
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]0
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]4
secrets: inherit
with:
hub_ref: v1.1.10
hub_ref: v1.1.14
+23 -2
View File
@@ -1,6 +1,27 @@
# React + TypeScript + Vite
# PraisonAIUI Frontend (React + TypeScript + Vite)
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
The BeautifulUI dashboard source. Built with React 19 + Vite 7 + Tailwind v4.
## Build & CI
- **Node.js:** 22 (matches the `frontend` job in `.github/workflows/ci.yml`)
- **Lockfile:** `src/frontend/package-lock.json` — install with `npm ci`
```bash
cd src/frontend
npm ci
npm run build # SPA build → dist/ (dashboard ship path)
npm run build:lib # library build for the @praisonaiui/react package
npm run lint # eslint
```
`npm run build` output in `dist/` is synced to `src/praisonaiui/templates/frontend/`
(the committed bundle served by `aiui build`). The CI `frontend` job runs
`npm ci && npm run build` on PRs to `main` and fails on TypeScript/Vite errors.
## Vite template notes
This project uses a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<!-- Spike #293 dev harness. Open with `npm run dev` → http://localhost:5173/spike.html -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BeautifulUI spike · PraisonAIUI #293</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/spike/main.tsx"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
# Spike #293 — BeautifulUI primitives in PraisonAIUI
Time-boxed evaluation proving BeautifulUI's AI-native primitives can build inside
PraisonAIUI's React 19 + Vite + Tailwind v4 toolchain and render against **mock,
PraisonAIUI-shaped** agent event streams.
**This folder is a spike. It is intentionally isolated from the shipped bundle:**
nothing here is imported by `src/main.tsx`, and none of it is synced into
`src/praisonaiui/templates/frontend`. Removing `src/frontend/src/spike/` and
`src/frontend/spike.html` fully reverts the spike.
## How to run
```bash
cd src/frontend
npm install
npm run dev # then open http://localhost:5173/spike.html
```
`SpikePage.tsx` drives the three primitives from `mockEvents.ts`, which uses the
real `SSEEvent` contract from `src/types.ts` (`run_content` deltas,
`reasoning_step` / `tool_call_started` / `tool_call_completed`, and an
`approval_request` payload shaped like a row from `GET /api/approvals`).
## What was vendored (upstream pin)
- Registry index: `https://www.beautifului.dev/r/registry.json`
- Component JSON: `https://www.beautifului.dev/r/<name>.json`
- Upstream repo: `slev12397/beautiful-ui` @ **`44a274e598395ab61e7c96c26fda2758780253b7`** (2026-09-22)
- License: **MIT** (upstream repo)
| Primitive | Registry item | Source | Registry deps | npm deps |
|---|---|---|---|---|
| StreamingText | `streaming-text` | `beautifui/primitives/StreamingText.tsx` (10.3 KB) | foundation | **none** (`react` only) |
| ThinkingState | `thinking-state` | `beautifui/primitives/ThinkingState.tsx` (11.3 KB) | foundation | **none** (`react` only) |
| ApprovalCard | `approval-card` | `beautifui/primitives/ApprovalCard.tsx` (16.5 KB) | foundation, button, glide-menu | **none** (`react` only) |
| Button (dep) | `button` | `beautifui/atoms/Button.tsx` (2.0 KB) | foundation | `class-variance-authority` *(already a PraisonAIUI dep)* |
| GlideMenu (dep) | `glide-menu` | `beautifui/primitives/GlideMenu.tsx` (1.9 KB) | foundation | **none** (`react` only) |
| foundation | `foundation` | `beautifui/foundation.css` (17.5 KB) | — | `shadow-plugin` *(see deviation)* |
### npm packages pulled by the registry & licence check
For the three chosen primitives (+ their two registry deps) the registry pulls
**zero new runtime npm packages**. The only npm dependency any of them declares is
`class-variance-authority` (via `button`), which PraisonAIUI already ships.
- `liveline`, `glimm` — **not pulled.** These are referenced only by chart-style
components (InsightCards) that we did not adopt. No licence review needed for this spike.
- `@central-icons-react` — **avoided.** All three primitives render icons as
**inline SVG** (no icon package at all), so no `lucide-react` substitution was
even required. This is the cleanest possible outcome for the icon concern.
## Deviations from a raw `shadcn add`
1. **`shadow-plugin` removed.** `foundation.css` imports `shadow-plugin/unprefixed`
(an extra Tailwind plugin dependency). To keep the spike lightweight we replaced
it with a small local `@theme` block defining the `--shadow-*` scale. Visual
shadows are approximate but faithful.
2. **Unbalanced-brace fix (upstream bug).** The flattened `foundation.css` emits a
**stray extra `}`** around `.records-footer-hint` (a `@media` block that lost its
opening line in the registry export). This makes Tailwind v4 fail with
`Missing opening {`. We removed the stray brace. **This is a real upstream blocker
worth reporting to `beautiful-ui`.**
3. **Imports rewritten** from `@/components/*` to `@/spike/beautifui/*` so the spike
stays isolated and cannot collide with the existing shadcn `components/ui/button.tsx`.
4. **`"use client"` directives dropped** (Vite SPA, not RSC).
## Build result
`vite build` of the spike entry (`spike.html`) succeeds:
```
✓ 39 modules transformed
spike.css 97.82 kB │ gzip: 17.48 kB (Tailwind + foundation)
spike.js 247.94 kB │ gzip: 78.52 kB (React runtime + 3 primitives + 2 deps)
```
- **Bundle size:** the primitives are cheap — self-contained TSX with no extra npm
deps. The JS weight is dominated by the React runtime; the incremental cost of the
three primitives is on the order of ~40 KB raw / low-teens KB gzip. The CSS is
full Tailwind + the ~17 KB foundation token/keyframe layer.
- **Motion / `prefers-reduced-motion`:** foundation ships two
`@media (prefers-reduced-motion: reduce)` guards (global animation kill-switch +
a StreamText-specific one). Motion is handled responsibly out of the box.
- **Dark mode parity:** foundation defines its **own** token namespace (`--page`,
`--canvas`, `--ink`, `--accent`, …) with its own `.dark` block, *separate* from
PraisonAIUI's shadcn tokens (`--background`/`--foreground` via `hsl(var(--…))`).
Good news: **no token collision** with the dashboard. Trade-off: the primitives do
**not** automatically inherit `chat.css`/YAML-driven theme colours — they'd need a
token bridge (map `--ink`→`--foreground`, `--surface`→`--card`, `--accent`→`--primary`)
before they visually match the shipped chat surface in both themes.
## Assessment
**Fit score: 7/10** for a spike, with caveats for production migration.
**Blockers / gaps:**
- Upstream `foundation.css` brace bug (fixed here; report upstream).
- No automatic theme inheritance — needs a token bridge to match `chat.css` dark/light.
- Primitives embed **demo content** (ice-cream sales copy, fake sources). They accept
props (`content`, `rows`, `questions`, `onSubmitted`, …) so they *can* be driven by
real events — proven in `SpikePage` — but the built-in defaults must be replaced.
- PraisonAIUI already has native equivalents: `chat/ThinkingSteps.tsx`,
`chat/ToolCallDisplay.tsx`, streaming in `chat/ChatMessages.tsx`. Adopting
BeautifulUI should be weighed against enhancing these rather than duplicating them.
**Recommended primitive order for migration (lowest risk → highest):**
1. **StreamingText** — pure token-stream UX, `react`-only, maps 1:1 to `run_content`.
2. **ThinkingState** — reasoning/tool trace, `react`-only, maps to
`reasoning_step` + `tool_call_*`; overlaps existing `ThinkingSteps.tsx`.
3. **ApprovalCard** — highest value (HITL) but most surface: pulls `button` +
`glide-menu` and needs wiring to `POST /api/approvals/{id}/resolve`.
+90
View File
@@ -0,0 +1,90 @@
/* Spike #293 — BeautifulUI primitives driven by PraisonAIUI-shaped mock events.
*
* Dev-only harness. Run with `npm run dev` and open /spike.html. This page is
* NOT imported by the shipped library entry (main.tsx) and is NOT synced into
* src/praisonaiui/templates/frontend — per the issue's non-goals it must not
* touch the production bundle.
*/
import { useEffect, useState } from "react";
import StreamingText from "@/spike/beautifui/primitives/StreamingText";
import ThinkingState from "@/spike/beautifui/primitives/ThinkingState";
import ApprovalCard from "@/spike/beautifui/primitives/ApprovalCard";
import {
streamingTimeline,
tokensFromDeltas,
rowsFromReasoning,
REASONING_STREAM,
APPROVAL_REQUEST,
} from "@/spike/mockEvents";
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 40 }}>
<h2 style={{ font: "600 13px/1.4 ui-monospace, monospace", opacity: 0.6, marginBottom: 12 }}>{title}</h2>
{children}
</section>
);
}
export default function SpikePage() {
const [dark, setDark] = useState(false);
const [log, setLog] = useState<string[]>([]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
// Emulate the SSE timeline so we exercise real delta pacing (logs only —
// the primitives below are fed the fully-resolved token/row arrays).
useEffect(() => {
const frames = streamingTimeline();
const timers = frames.map((f) =>
setTimeout(() => setLog((l) => [...l.slice(-4), `${f.event.type}: ${f.event.token ?? ""}`]), f.at),
);
return () => timers.forEach(clearTimeout);
}, []);
const tokens = tokensFromDeltas(streamingTimeline());
const rows = rowsFromReasoning(REASONING_STREAM);
return (
<div style={{ maxWidth: 720, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<div>
<h1 style={{ font: "700 20px/1.2 system-ui" }}>BeautifulUI spike</h1>
<p style={{ opacity: 0.6, fontSize: 13 }}>Issue #293 · mock PraisonAIUI event streams</p>
</div>
<button onClick={() => setDark((d) => !d)} style={{ padding: "6px 12px", borderRadius: 8 }}>
{dark ? "☀ light" : "🌙 dark"}
</button>
</header>
<Panel title="StreamingText ← run_content deltas">
<StreamingText content={tokens} loop={false} sources={[]} followUps={[]} />
</Panel>
<Panel title="ThinkingState ← reasoning_step / tool_call_*">
<ThinkingState rows={rows} active="Thinking" done={`Ran ${rows.length} steps`} />
</Panel>
<Panel title="ApprovalCard ← approval_request (/api/approvals)">
<ApprovalCard
questions={APPROVAL_REQUEST.questions}
onSubmitted={(answers) =>
// In production this maps to POST /api/approvals/{id}/resolve.
setLog((l) => [
...l.slice(-4),
`resolve ${APPROVAL_REQUEST.id}: ${JSON.stringify(answers)}`,
])
}
/>
</Panel>
<Panel title="event log (last 5)">
<pre style={{ font: "12px/1.5 ui-monospace, monospace", opacity: 0.7, whiteSpace: "pre-wrap" }}>
{log.join("\n") || "…"}
</pre>
</Panel>
</div>
);
}
@@ -0,0 +1,48 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/button.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
/* Pill-shaped by default — the app's core button style. Explicit symmetric
* padding (not a fixed height) so the top/bottom spacing is always equal. */
export const buttonVariants = cva(
`inline-flex items-center justify-center font-medium select-none
transition-[transform,background-color,opacity] duration-150 ease-out
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
{
variants: {
variant: {
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
/* transparent until hovered — for dense toolbars/action rows */
quiet: "text-ink hover:bg-hover",
},
size: {
/* compact toolbar pill — fixed height, lighter weight */
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
/* canonical action pill — 27px tall, roomy sides */
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
},
},
defaultVariants: { variant: "secondary", size: "md" },
},
);
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
export function Button({
variant,
size,
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+659
View File
@@ -0,0 +1,659 @@
/* Vendored from BeautifulUI foundation: https://www.beautifului.dev/r/foundation.json
* Spike #293. shadow-plugin dependency neutralized (see note below). License: MIT. */
@import "tailwindcss";
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
* below build on those values so the whole app picks up the smooth shadows. */
/* @import "shadow-plugin/unprefixed"; -- removed for spike */
/* SPIKE deviation (#293): upstream imports `shadow-plugin/unprefixed` (extra npm dep).
* To keep the spike lightweight we define the --shadow-* scale locally instead. */
@theme inline {
--shadow-2xs: 0 1px 0 0 oklch(0 0 0 / 0.03);
--shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05);
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 1px -1px oklch(0 0 0 / 0.04);
--shadow-md: 0 2px 4px -1px oklch(0 0 0 / 0.07), 0 1px 2px -1px oklch(0 0 0 / 0.05);
--shadow-lg: 0 8px 16px -4px oklch(0 0 0 / 0.10), 0 2px 4px -2px oklch(0 0 0 / 0.06);
--shadow-xl: 0 16px 32px -8px oklch(0 0 0 / 0.12);
--shadow-2xl: 0 24px 48px -12px oklch(0 0 0 / 0.18);
}
@custom-variant dark (&:where(.dark, .dark *));
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
* spacing utilities, and shared keyframes below are needed by EVERY
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
* one primitive each. */
/* ──────────────────────────────────────────────────────────
* Beautiful UI — design tokens
* Cool near-white canvas, white cards, hairline borders, layered
* single-digit-opacity shadows, a neutral ink ramp, and semantic
* color used sparingly as a condiment.
* ────────────────────────────────────────────────────────── */
:root {
/* surfaces — cool, blue-tinted neutrals */
--page: oklch(0.985 0.001 286.376);
--canvas: oklch(0.961 0.002 247.84);
--surface: oklch(1 0 0);
--inset: oklch(0.979 0.002 247.839);
--hover: oklch(0.97 0.002 247.839);
--hover-2: oklch(0.933 0.003 247.86);
/* ink ramp */
--ink: oklch(0.247 0.006 258.361);
--ink-2: oklch(0.506 0.01 264.477);
--ink-3: oklch(0.695 0.009 264.505);
/* borders — solid and crisp, not alpha */
--line: oklch(0.946 0.003 264.542);
--line-strong: oklch(0.912 0.005 258.326);
--line-soft: oklch(0.966 0.002 264.542);
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
--field: oklch(0.961 0.001 286.375);
--stripe: oklch(0.405 0 0 / 0.075);
--stripe-bg: oklch(0.97 0 0);
/* accent */
--accent: oklch(0.626 0.205 254.947);
--accent-ink: oklch(0.556 0.187 255.617);
--accent-tint: oklch(0.96 0.019 252.878);
/* semantic */
--green: oklch(0.603 0.155 150.883);
--green-tint: oklch(0.958 0.017 159.118);
--orange: oklch(0.689 0.179 49.902);
--orange-tint: oklch(0.964 0.021 67.581);
--red: oklch(0.621 0.192 23.042);
--red-tint: oklch(0.956 0.017 17.462);
/* chart overlays — neutral system colors with enough contrast on either theme */
--tooltip-bg: oklch(0.272 0.008 264.435);
--tooltip-fg: oklch(0.976 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.356 0.007 264.474);
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
* consistently across the app. */
--shadow-hairline: 0 0 0 1px var(--line);
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
}
.dark {
--page: oklch(0.209 0.004 264.477);
--canvas: oklch(0.231 0.004 264.487);
--surface: oklch(0.26 0.006 271.191);
--inset: oklch(0.243 0.004 264.492);
--hover: oklch(0.289 0.006 271.22);
--hover-2: oklch(0.318 0.007 274.747);
--ink: oklch(0.964 0.002 247.839);
--ink-2: oklch(0.731 0.008 260.731);
--ink-3: oklch(0.541 0.01 264.484);
--line: oklch(0.308 0.006 258.354);
--line-strong: oklch(0.356 0.007 264.474);
--line-soft: oklch(0.278 0.006 258.354);
--field: oklch(0.293 0.006 271.223);
--stripe: oklch(1 0 0 / 0.055);
--stripe-bg: oklch(0.226 0.004 264.485);
--accent: oklch(0.68 0.173 253.301);
--accent-ink: oklch(0.788 0.113 248.33);
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
--green: oklch(0.705 0.154 153.814);
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
--orange: oklch(0.746 0.156 55.642);
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
--red: oklch(0.666 0.18 21.433);
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
--tooltip-bg: oklch(0.182 0.004 264.459);
--tooltip-fg: oklch(0.964 0.002 247.839);
--tooltip-muted: oklch(0.731 0.008 260.731);
--tooltip-border: oklch(0.308 0.006 258.354);
--shadow-hairline: 0 0 0 1px var(--line);
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
* paints over the surface behind the element, so a low-alpha white edge
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
* graduated by how far the surface floats. */
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card:
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
0 2px 6px oklch(0 0 0 / 0.2);
--shadow-raised:
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
--shadow-overlay:
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
}
@theme inline {
/* colors */
--color-page: var(--page);
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-inset: var(--inset);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ink-3: var(--ink-3);
--color-hover: var(--hover);
--color-hover-2: var(--hover-2);
--color-field: var(--field);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-accent-tint: var(--accent-tint);
--color-green: var(--green);
--color-green-tint: var(--green-tint);
--color-orange: var(--orange);
--color-orange-tint: var(--orange-tint);
--color-red: var(--red);
--color-red-tint: var(--red-tint);
/* shadows */
--shadow-hairline: var(--shadow-hairline);
--shadow-btn: var(--shadow-btn);
--shadow-inset-field: var(--shadow-inset-field);
--shadow-card: var(--shadow-card);
--shadow-raised: var(--shadow-raised);
--shadow-overlay: var(--shadow-overlay);
/* type */
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
--radius-chip: 6px;
--radius-control: 8px;
--radius-card: 10px;
--radius-window: 14px;
/* easing — strong curves; built-ins are too weak */
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── base ─────────────────────────────────────────────── */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 7px,
var(--stripe) 7px,
var(--stripe) 8px
);
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
background-attachment: fixed;
color: var(--ink);
font-feature-settings: "cv11", "ss01";
font-size: 14px;
letter-spacing: -0.01em;
line-height: 1.5;
}
::selection {
background: var(--accent-tint);
color: var(--ink);
}
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
/* underlines clear descenders */
a {
text-underline-position: from-font;
text-decoration-skip-ink: auto;
}
/* theme switches swap every token at once — freeze transitions so the flip
* is one clean repaint instead of hundreds of mismatched color fades */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
@media (hover: none) {
.records-row:hover > .records-cell {
background: transparent;
}
.records-row:hover > td.is-colsel {
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
}
.records-header-button:hover {
background: transparent;
color: var(--ink-2);
}
}
/* Keep the component nav scrollable but hide its scrollbar. */
.component-nav-scroll {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge */
}
.component-nav-scroll::-webkit-scrollbar {
display: none; /* WebKit */
}
@media (min-width: 64rem) {
.component-nav-scroll {
-webkit-mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
oklch(0 0 0) 0,
oklch(0 0 0) calc(100% - 6rem),
transparent 100%
);
}
}
/* Mobile demo gestures should operate controls and visualizations rather than
* selecting surrounding labels. Text entry and code remain intentionally
* selectable. */
@media (hover: none) and (pointer: coarse) {
.primitive-showcase {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
.primitive-showcase :is(a, button, [role="button"]) {
-webkit-tap-highlight-color: transparent;
}
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
outline: none;
}
.primitive-showcase :is(img, svg, canvas) {
-webkit-user-drag: none;
}
.insight-chart-stage {
touch-action: pan-y;
}
}
/* Tailwind v4 resets buttons to cursor:default — anything
* clickable must invite the click */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
label:has(input),
a {
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[contenteditable]:focus-visible {
outline: none;
}
.animated-underline {
position: relative;
display: inline-block;
}
.animated-underline::after {
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 1px;
content: "";
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
transition: transform 280ms var(--ease-link);
}
a:hover .animated-underline::after,
a:focus-visible .animated-underline::after {
transform: scaleX(1);
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamingText / ContextCards
* Favicon source avatars.
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.source-avatar {
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
}
.dark .source-avatar {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
}
/* ── primitive spacing system ─────────────────────────────
* Shared optical offsets for small demo cards. Use these
* instead of hand-tuning 10/12/14px per component.
*/
.primitive-card-pad {
padding: 12px;
}
.primitive-card-bar {
padding: 10px 12px;
}
.primitive-card-footer {
padding: 10px;
}
.primitive-table-cell {
padding: 10px 12px;
}
.primitive-icon-button {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
}
/* ── shared keyframes ─────────────────────────────────── */
@keyframes shimmer-text {
from {
background-position: 150% center;
}
to {
background-position: -50% center;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.home-reveal {
will-change: opacity, transform, filter;
}
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
* Every rail glyph remains centered at x=26 in both states. */
.records-footer-hint {
display: none;
}
/* SPIKE fix (#293): upstream foundation.css emits a stray extra `}` here — a
* flattened @media block that lost its opening line. Removed so Tailwind v4
* can parse the stylesheet. See spike report. */
/* ── filter table electric status colors ─────────────────── */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — FilterTable
* Electric status-pill colors.
* Copy this block only alongside FilterTable; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
border: 1px solid transparent;
}
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — InsightCards
* Liveline chart tooltip + cursor.
* Copy this block only alongside InsightCards; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.insight-chart-stage {
overflow: hidden;
}
.insight-chart-cursor {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
width: 1px;
background: var(--ink);
opacity: 0.26;
pointer-events: none;
}
.insight-chart-tooltip-anchor {
position: absolute;
z-index: 5;
top: 8px;
transform: translateX(-50%);
pointer-events: none;
}
.insight-chart-tooltip {
display: flex;
align-items: center;
gap: 12px;
padding: 5px 9px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--tooltip-fg);
background: var(--tooltip-bg);
box-shadow: var(--shadow-overlay);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.insight-chart-tooltip-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.insight-chart-tooltip-dot {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
}
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
* so both components stay consistent across light and dark. */
.filter-status-todo {
--tag-base: oklch(0.757 0.153 66.401);
}
.filter-status-progress {
--tag-base: oklch(0.671 0.118 219.351);
}
.filter-status-done {
--tag-base: oklch(0.652 0.131 162.865);
}
.filter-status-todo,
.filter-status-progress,
.filter-status-done {
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
/* Dark mode — a richer, saturated same-hue fill
* with bright same-hue text and no visible border (light mode keeps the soft
* tinted pastels above). Applies to both the records grid and status pills. */
.dark .records-tag,
.dark .filter-status-todo,
.dark .filter-status-progress,
.dark .filter-status-done {
color: color-mix(in srgb, var(--tag-base) 86%, white);
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes eq-bounce {
0%,
100% {
transform: scaleY(0.35);
}
50% {
transform: scaleY(1);
}
}
/* ── StreamText primitive ─────────────────────────────────
* Fast character reveal: the newest few characters carry a
* soft, masked blur so the leading edge resolves smoothly as
* text flows in. The caret holds solid while streaming, then
* blinks once the string settles. */
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — StreamText
* The streaming caret + tail.
* Copy this block only alongside StreamText; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.stream-tail {
filter: blur(1.6px);
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
}
.stream-caret {
display: inline-block;
width: 2px;
height: 1.05em;
margin-left: 1.5px;
translate: 0 -0.5px;
border-radius: 1px;
background: var(--ink);
vertical-align: text-bottom;
animation: caret-blink 1s step-end infinite;
}
.stream-caret.is-streaming {
animation: none;
}
@keyframes caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-caret {
animation: none;
}
.stream-tail {
filter: none;
-webkit-mask-image: none;
mask-image: none;
}
}
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* pixel-grid loader — brighten plateau, staggered per cell */
@keyframes pixel-on {
0%, 100% {
opacity: 0.15;
}
18%, 42% {
opacity: 1;
}
62% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,420 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/approval-card.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { Button } from "@/spike/beautifui/atoms/Button";
import GlideMenu from "@/spike/beautifui/primitives/GlideMenu";
/* ─────────────────────────────────────────────────────────
* APPROVAL CARD (human-in-the-loop)
* One question at a time. The stack slides vertically as you
* move between questions (the card's height animates to fit),
* the step counter rolls like an odometer, and the footer uses
* pill actions — a quiet Skip and a dark Continue with a ⏎.
* Single-choice answers auto-advance; multi-select waits.
* ───────────────────────────────────────────────────────── */
export type ApprovalQuestion = {
q: string;
type: "radio" | "check";
options: string[];
};
const QUESTIONS: ApprovalQuestion[] = [
{
q: "How many flavors should we launch?",
type: "radio",
options: ["Three (core line)", "Five (full case)", "Just one hero"],
},
{
q: "Which mix-ins should we stock?",
type: "check",
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
},
{
q: "Which market do we enter first?",
type: "radio",
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
},
];
export type ApprovalLabels = {
skip: string;
continue: string;
send: string;
customPlaceholder: string;
sentMessage: string;
};
const DEFAULT_LABELS: ApprovalLabels = {
skip: "Skip",
continue: "Continue",
send: "Send",
customPlaceholder: "Something else…",
sentMessage: "Answers sent",
};
const ROLL_MS = 400;
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
/* odometer digits — each character that changes rolls up (or down) */
function RollingDigits({ value }: { value: string }) {
const prevRef = useRef(value);
const [oldVal, setOldVal] = useState(value);
const [newVal, setNewVal] = useState(value);
const [rolling, setRolling] = useState(false);
const [shifted, setShifted] = useState(false);
const [dir, setDir] = useState<"up" | "down">("up");
useEffect(() => {
if (prevRef.current === value) return;
const from = prevRef.current;
prevRef.current = value;
const fromN = parseInt(from, 10);
const toN = parseInt(value, 10);
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
setOldVal(from);
setNewVal(value);
setRolling(true);
setShifted(false);
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setShifted(true));
});
const done = setTimeout(() => {
setRolling(false);
setOldVal(value);
setShifted(false);
}, ROLL_MS);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
clearTimeout(done);
};
}, [value]);
const chars = rolling ? newVal : oldVal;
return (
<>
{Array.from({ length: chars.length }, (_, i) => {
const o = oldVal[i] ?? "";
const n = chars[i] ?? "";
if (!rolling || o === n) {
return <span key={`${i}-${n}`}>{n}</span>;
}
const top = dir === "down" ? n : o;
const bottom = dir === "down" ? o : n;
const restY = dir === "down" ? "0" : "-1em";
const startY = dir === "down" ? "-1em" : "0";
return (
<span
key={`${i}-${o}-${n}-${dir}`}
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
>
<span
style={{
display: "flex",
flexDirection: "column",
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
transform: `translateY(${shifted ? restY : startY})`,
}}
>
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
</span>
</span>
);
})}
</>
);
}
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{path}
</svg>
);
}
export default function ApprovalCard({
questions = QUESTIONS,
labels,
onSubmitted,
onAnswerChange,
resettable = true,
}: {
questions?: ApprovalQuestion[];
labels?: Partial<ApprovalLabels>;
onSubmitted?: (answers: Record<number, number[]>) => void;
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
resettable?: boolean;
variant?: string;
} = {}) {
const t = { ...DEFAULT_LABELS, ...labels };
const [qi, setQi] = useState(0);
const [answers, setAnswers] = useState<Record<number, number[]>>({});
const [custom, setCustom] = useState<Record<number, string>>({});
const [sent, setSent] = useState(false);
const [open, setOpen] = useState(true);
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
const measured = useRef(false);
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
const [trackY, setTrackY] = useState(0);
const [animate, setAnimate] = useState(false);
// Until the first question is measured, render only the active one so the
// initial (and SSR) height is Q1's height — not all questions stacked, which
// would flash to full height and then shrink on mount.
const [ready, setReady] = useState(false);
const last = qi === questions.length - 1;
const selected = answers[qi] ?? [];
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
const sync = (withAnim: boolean) => {
const item = questionRefs.current[qi];
if (!item) return;
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setViewportH(item.offsetHeight);
setTrackY(item.offsetTop);
setAnimate(withAnim && !reduce);
};
useLayoutEffect(() => {
const withAnim = measured.current;
measured.current = true;
sync(withAnim);
setReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi, answers, custom, open, sent]);
useEffect(() => {
const id = requestAnimationFrame(() => sync(measured.current));
return () => cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qi]);
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
const goTo = (next: number) => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setQi(Math.min(Math.max(next, 0), questions.length - 1));
};
const send = () => {
if (advanceTimer.current) clearTimeout(advanceTimer.current);
setSent(true);
onSubmitted?.(answers);
};
const advance = () => {
if (last) send();
else goTo(qi + 1);
};
const toggle = (index: number) => {
const type = questions[qi].type;
setAnswers((current) => {
const picked = current[qi] ?? [];
const next = type === "radio"
? [index]
: picked.includes(index)
? picked.filter((item) => item !== index)
: [...picked, index];
onAnswerChange?.(qi, next);
return { ...current, [qi]: next };
});
if (type === "radio") {
setCustom((current) => ({ ...current, [qi]: "" }));
if (advanceTimer.current) clearTimeout(advanceTimer.current);
advanceTimer.current = setTimeout(() => {
if (last) send();
else setQi((current) => Math.min(questions.length - 1, current + 1));
}, 480);
}
};
const reset = () => {
setQi(0);
setAnswers({});
setCustom({});
setSent(false);
setOpen(true);
measured.current = false;
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
Open approval
</button>
);
}
if (sent) {
return (
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
</span>
{t.sentMessage}
</span>
{resettable && (
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
Start over
</button>
)}
</div>
);
}
return (
<div className="w-full max-w-80">
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
<button
type="button"
aria-label="Dismiss"
onClick={() => setOpen(false)}
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
</button>
<div className="primitive-card-pad">
{/* the question itself is the heading */}
<div
className="overflow-hidden"
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
aria-live="polite"
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 26,
transform: `translate3d(0, ${-trackY}px, 0)`,
transition: animate ? `transform ${SLIDE}` : undefined,
willChange: "transform",
}}
>
{questions.map((question, qIdx) => {
const active = qIdx === qi;
// Before the first measure, mount only the active question so the
// card opens at its real height instead of flashing to full height.
if (!ready && !active) return null;
const picked = answers[qIdx] ?? [];
const questionStyle: CSSProperties = {
opacity: active ? 1 : 0,
transition: animate ? `opacity ${SLIDE}` : undefined,
pointerEvents: active ? undefined : "none",
};
return (
<div
key={qIdx}
ref={(el) => { questionRefs.current[qIdx] = el; }}
aria-hidden={active ? undefined : true}
style={questionStyle}
>
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
{question.options.map((option, i) => {
const on = picked.includes(i);
return (
<button
key={option}
type="button"
data-menu-row
aria-pressed={on}
tabIndex={active ? 0 : -1}
onClick={() => { if (active) toggle(i); }}
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
>
<span
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
>
{question.type === "radio" ? (
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
)}
</span>
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
{option}
</span>
</button>
);
})}
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
<input
value={custom[qIdx] ?? ""}
tabIndex={active ? 0 : -1}
onChange={(event) => {
if (!active) return;
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
}}
onKeyDown={(event) => {
if (event.key === "Enter" && hasAnswer) {
event.preventDefault();
advance();
}
}}
placeholder={t.customPlaceholder}
aria-label="Custom answer"
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
/>
</label>
</GlideMenu>
</div>
);
})}
</div>
</div>
</div>
{/* footer — step nav (rolling counter) + pill actions */}
<div className="primitive-card-footer flex items-center justify-between gap-3">
<div className="flex items-center gap-1 text-ink-3">
<button
type="button"
aria-label="Previous question"
disabled={qi <= 0}
onClick={() => goTo(qi - 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
</button>
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
</span>
<button
type="button"
aria-label="Next question"
disabled={last}
onClick={() => goTo(qi + 1)}
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
>
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
</button>
</div>
<div className="-mr-0.5 flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
{t.skip}
</Button>
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
{last ? t.send : t.continue}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/glide-menu.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useRef, useState, type ReactNode } from "react";
type GlideMenuProps = {
children: ReactNode;
className?: string;
highlightClassName?: string;
rowSelector?: string;
};
/** A single hover layer that glides between interactive menu rows. */
export default function GlideMenu({
children,
className = "",
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
rowSelector = "[data-menu-row]",
}: GlideMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
const [visible, setVisible] = useState(false);
const moveTo = (target: EventTarget | null) => {
const container = ref.current;
if (!(target instanceof Element) || !container) return;
const row = target.closest(rowSelector);
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
setVisible(true);
};
return (
<div
ref={ref}
onMouseOver={(event) => moveTo(event.target)}
onMouseLeave={() => setVisible(false)}
onFocusCapture={(event) => moveTo(event.target)}
onBlurCapture={(event) => {
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
}}
className={`group/glide-menu relative ${className}`}
>
<span
aria-hidden
className={`pointer-events-none absolute ${highlightClassName}`}
style={{
top: box?.top ?? 0,
height: box?.height ?? 0,
opacity: box && visible ? 1 : 0,
transition:
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
}}
/>
{children}
</div>
);
}
@@ -0,0 +1,253 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/streaming-text.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useState } from "react";
/* ─────────────────────────────────────────────────────────
* STREAMING TEXT
* Words resolve out of blur, inline citations appear in
* context, then actions and follow-up prompts become usable.
* ───────────────────────────────────────────────────────── */
const WORD_MS = 55;
const HOLD_MS = 3400;
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
export type StreamingToken = { text: string; cite?: boolean };
const TOKENS: StreamingToken[] = [
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
.split(" ")
.map((text) => ({ text })),
{ text: "", cite: true },
..."Stone-fruit flavors are trending in the same range."
.split(" ")
.map((text) => ({ text })),
];
const FOLLOW_UPS = [
"Which flavors sell best in winter",
"Compare gelato and soft serve margins",
];
const SOURCE_IMAGES = {
scoop:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
trends:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
market:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
};
/* one cited source rendered as an inline chip and in the sources list */
export type StreamingSource = { name: string; domain: string; href: string; image: string };
const SOURCES: StreamingSource[] = [
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
];
function sourceImage(source: StreamingSource) {
return source.image;
}
function SourceChip({ source }: { source?: StreamingSource }) {
if (!source) return null;
return (
<a
href={source.href}
target="_blank"
rel="noreferrer"
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
transition-colors duration-150 hover:bg-hover hover:text-ink"
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
>
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
<span>{source.domain}</span>
</a>
);
}
const ACTION_ICONS: React.ReactNode[] = [
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
];
export type StreamingLabels = {
/** label on the collapsed sources toggle */
sources: string;
/** heading above the follow-up prompts */
followUps: string;
};
const DEFAULT_LABELS: StreamingLabels = {
sources: "10 sources",
followUps: "Follow-ups",
};
export default function StreamingText({
content = TOKENS,
sources = SOURCES,
followUps = FOLLOW_UPS,
labels,
loop = true,
fill = false,
onDone,
onFollowUp,
}: {
variant?: string;
/** the streamed tokens; `cite` tokens render an inline source chip */
content?: StreamingToken[];
/** cited sources shown in the chip, avatar stack, and expanded list */
sources?: StreamingSource[];
/** follow-up prompt suggestions shown once the stream completes */
followUps?: string[];
/** prominent copy strings */
labels?: Partial<StreamingLabels>;
/** restart the stream after a hold; turn off when embedding in a real thread */
loop?: boolean;
/** fill the parent width instead of the gallery's fixed measure */
fill?: boolean;
onDone?: () => void;
/** fired when a follow-up prompt is chosen */
onFollowUp?: (text: string, index: number) => void;
} = {}) {
const l = { ...DEFAULT_LABELS, ...labels };
const [count, setCount] = useState(0);
const [sourcesOpen, setSourcesOpen] = useState(false);
const done = count >= content.length;
useEffect(() => {
if (done && !loop) {
onDone?.();
return;
}
const t = setTimeout(
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
done ? HOLD_MS : WORD_MS,
);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, done, loop]);
return (
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
<p className="text-[13px] leading-relaxed text-ink">
{content.slice(0, count).map((token, i) =>
token.cite ? (
<SourceChip key={i} source={sources[0]} />
) : (
<span key={i} className="inline">
{token.text}{" "}
</span>
),
)}
{!done && (
<span
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
style={{ animation: "fade-in 150ms ease-out both" }}
/>
)}
</p>
{/* action icons row */}
<div
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
{ACTION_ICONS.map((icon, i) => (
<button
key={i}
type="button"
aria-label="Action"
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{icon}
</svg>
</button>
))}
<button
type="button"
aria-expanded={sourcesOpen}
onClick={() => setSourcesOpen((current) => !current)}
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
>
<span className="flex -space-x-1">
{sources.map((source) => (
<img
key={source.domain}
src={sourceImage(source)}
alt=""
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
/>
))}
</span>
<span className="text-[12px] text-ink-2">{l.sources}</span>
</button>
</div>
<div
className="grid transition-[grid-template-rows,opacity] duration-300"
style={{
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
opacity: done && sourcesOpen ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
{sources.map((source) => (
<a
key={source.domain}
href={source.href}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
>
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
<span className="animated-underline">{source.name}</span>
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
</a>
))}
</div>
</div>
</div>
{/* follow-ups */}
<div
className="mt-2.5 transition-opacity duration-400"
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
>
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
<div className="mt-0.5 flex flex-col">
{followUps.map((text, i) => (
<button
key={text}
onClick={() => onFollowUp?.(text, i)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
duration-100 hover:bg-hover-2"
style={
done
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
: { opacity: 0 }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{text}
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,302 @@
/* Vendored from BeautifulUI registry: https://www.beautifului.dev/r/thinking-state.json
* Spike #293 — DO NOT ship in templates/frontend. Imports rewritten to @/spike/beautifui/*.
* License: MIT (upstream https://github.com/slev12397/beautiful-ui). */
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
/* ─────────────────────────────────────────────────────────
* THINKING — expandable agent trace, four variants
*
* Steps step list with spinner → muted checks
* Reasoning prose reasoning that expands, then settles
* Search web-search trace: query + sources read
* Coding tool trace: files read, edits, commands
*
* The trace runs once, settles, and remains expandable.
* ───────────────────────────────────────────────────────── */
const STAGES = [800, 600, 1800, 2600, 1600];
function useSequence(steps: number[]) {
const [stage, setStage] = useState(0);
useEffect(() => {
if (stage >= steps.length - 1) return;
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
return () => clearTimeout(t);
}, [stage, steps]);
return stage;
}
type Row = {
primary: string;
secondary?: string;
mono?: boolean;
add?: number;
del?: number;
href?: string;
};
const VARIANTS: Record<
string,
{ active: string; done: string; rows: Row[]; query?: string }
> = {
Steps: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Reading flavor briefs" },
{ primary: "Scanning supplier lists" },
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
{ primary: "Writing the scoop report" },
],
},
Reasoning: {
active: "Thinking",
done: "Thought for 4 seconds",
rows: [
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
],
},
Search: {
active: "Searching the web",
done: "Searched the web",
query: "best waffle cone supplier",
rows: [
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
],
},
Coding: {
active: "Running tools",
done: "Ran 3 tools",
rows: [
{ primary: "Read", secondary: "flavors.ts", mono: true },
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
{ primary: "Run", secondary: "npm run freeze", mono: true },
],
},
};
function Dot({ tone }: { tone: string }) {
return (
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="9" />
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
</span>
);
}
const TONES = ["bg-accent", "bg-orange", "bg-green"];
export default function ThinkingState({
variant = "Steps",
onSettled,
rows,
active,
done,
icon,
}: {
variant?: string;
onSettled?: () => void;
/** override the built-in trace content (keeps the primitive reusable) */
rows?: Row[];
active?: string;
done?: string;
/** override the header glyph (defaults to the sparkle) */
icon?: ReactNode;
}) {
const stage = useSequence(STAGES);
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
const [selectedTool, setSelectedTool] = useState<string | null>(null);
const base = VARIANTS[variant] ?? VARIANTS.Steps;
const v = {
...base,
rows: rows ?? base.rows,
active: active ?? base.active,
done: done ?? base.done,
};
const autoExpanded = stage >= 1 && stage < 4;
const expanded = manualExpanded ?? autoExpanded;
const working = stage < 3;
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
const traceRef = useRef<HTMLDivElement>(null);
const [lineHeight, setLineHeight] = useState(0);
useLayoutEffect(() => {
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
}, [visible, expanded, variant, stage]);
/* let embedders sequence content after the trace settles */
const settledRef = useRef(false);
useEffect(() => {
if (working || settledRef.current) return;
settledRef.current = true;
onSettled?.();
}, [working, onSettled]);
return (
<div
key={variant}
className="flex w-full max-w-95 flex-col"
style={{
minHeight: working || expanded ? 176 : undefined,
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
}}
>
{/* header — shared across variants */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
transition-colors duration-100 hover:bg-hover-2"
>
{icon ? (
<span className="flex shrink-0 transition-colors duration-200" style={{ color: working ? "var(--ink-2)" : "var(--ink-3)" }}>
{icon}
</span>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
</svg>
)}
<span role="status" className="contents">
{working ? (
<span
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
backgroundSize: "200% 100%",
animation: "shimmer-text 1.4s linear infinite",
}}
>
{v.active}
</span>
) : (
<span
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
style={{ animation: "fade-in 350ms ease-out both" }}
>
{v.done}
</span>
)}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
className="transition-transform duration-300"
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* expandable trace */}
<div
className="grid transition-[grid-template-rows,opacity] duration-400"
style={{
gridTemplateRows: expanded ? "1fr" : "0fr",
opacity: expanded ? 1 : 0,
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<div className="overflow-hidden">
<div className="relative mt-1 ml-[5px] pl-4">
<span
aria-hidden
className="absolute left-[3px] w-px bg-line"
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
/>
<div ref={traceRef} className="flex flex-col gap-1 py-1">
{v.query && (
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<span className="text-[12.5px] text-ink-2">{v.query}</span>
</div>
)}
{v.rows.slice(0, visible).map((row, i) => {
const content = (
<>
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
{variant === "Steps" && (
i < visible - 1 || !working ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M20 6L9 17l-5-5" />
</svg>
) : (
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
)
)}
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
{row.primary}
</span>
{row.secondary && (
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
{row.secondary}
</span>
)}
{row.add !== undefined && (
<span className="shrink-0 font-mono text-[11px] tabular-nums">
<span className="text-green">+{row.add}</span>{" "}
<span className="text-red">−{row.del}</span>
</span>
)}
</>
);
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
if (variant === "Search") {
return (
<a
key={row.primary}
href={row.href}
target="_blank"
rel="noreferrer"
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
style={animation}
>
{content}
</a>
);
}
if (variant === "Coding") {
const selected = selectedTool === row.primary;
return (
<button
key={row.primary}
type="button"
aria-pressed={selected}
onClick={() => setSelectedTool(selected ? null : row.primary)}
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
style={animation}
>
{content}
</button>
);
}
return (
<div key={row.primary} className={rowClass} style={animation}>
{content}
</div>
);
})}
{variant === "Search" && stage >= 3 && (
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
+7 more
</span>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/* Spike #293 — dev-only entry. Not part of the production bundle. */
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@/spike/beautifui/foundation.css";
import SpikePage from "@/spike/SpikePage";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SpikePage />
</StrictMode>,
);
+91
View File
@@ -0,0 +1,91 @@
/* Spike #293 — PraisonAIUI-shaped mock event stream.
*
* These events use the real `SSEEvent` contract from `@/types` so the spike
* proves the BeautifulUI primitives can render against the *actual* event
* vocabulary the chat surface emits (see src/praisonaiui/templates/frontend/
* plugins/views/chat.js and RunEventType in src/frontend/src/types.ts).
*
* Nothing here talks to a live socket — that is an explicit non-goal of the
* spike. The timeline below is replayed by SpikePage with setTimeout to
* emulate SSE deltas / tool lifecycle / a HITL approval request.
*/
import type { SSEEvent } from "@/types";
/** A scripted SSE frame plus the delay (ms) before it fires. */
export type MockFrame = { at: number; event: SSEEvent };
/** approval_request payload shaped like a row from GET /api/approvals. */
export type ApprovalRequest = {
id: string;
tool: string;
args: Record<string, unknown>;
questions: { q: string; type: "radio" | "check"; options: string[] }[];
};
/* SSE-style text deltas — streamed word-by-word as `run_content`. */
const ANSWER =
"Pistachio is your fastest-growing flavor — sales are up 23% this month " +
"and margins beat vanilla by 8 points. Stone-fruit flavors trend in the same range.";
function contentDeltas(startAt: number, stepMs = 55): MockFrame[] {
const words = ANSWER.split(" ");
return words.map((w, i) => ({
at: startAt + i * stepMs,
event: { type: "run_content", token: w + " ", content: w + " " } as SSEEvent,
}));
}
const LAST_DELTA_AT = 300 + ANSWER.split(" ").length * 55;
/* Reasoning + tool lifecycle, mapped to ThinkingState rows. */
export const REASONING_STREAM: SSEEvent[] = [
{ type: "reasoning_started" },
{ type: "reasoning_step", step: "Reading flavor briefs", step_number: 1 },
{ type: "tool_call_started", name: "sql.query", tool_call_id: "t1", args: { table: "sales" } },
{ type: "tool_call_completed", name: "sql.query", tool_call_id: "t1", formatted_result: "6 flavors" },
{ type: "reasoning_step", step: "Comparing tasting notes", step_number: 2 },
{ type: "tool_call_started", name: "trends.lookup", tool_call_id: "t2", args: { window: "30d" } },
{ type: "tool_call_completed", name: "trends.lookup", tool_call_id: "t2", formatted_result: "+23%" },
{ type: "reasoning_completed" },
];
/* approval_request payload — the /api/approvals shape a HITL gate would push. */
export const APPROVAL_REQUEST: ApprovalRequest = {
id: "appr_9f2",
tool: "menu.publish",
args: { flavors: 3, season: "winter" },
questions: [
{ q: "How many flavors should we launch?", type: "radio", options: ["Three (core line)", "Five (full case)", "Just one hero"] },
{ q: "Which mix-ins should we stock?", type: "check", options: ["Chocolate chips", "Waffle bits", "Sprinkles"] },
],
};
/** Full scripted timeline for the streaming panel. */
export function streamingTimeline(): MockFrame[] {
return [
{ at: 0, event: { type: "run_started" } as SSEEvent },
...contentDeltas(300),
{ at: LAST_DELTA_AT + 100, event: { type: "run_completed" } as SSEEvent },
];
}
/* ── Adapters: PraisonAIUI events → BeautifulUI primitive props ── */
/** run_content deltas → StreamingText tokens (`cite` marks a source chip slot). */
export function tokensFromDeltas(frames: MockFrame[]): { text: string; cite?: boolean }[] {
return frames
.filter((f) => f.event.type === "run_content")
.map((f) => ({ text: (f.event.token ?? f.event.content ?? "").trim() }));
}
/** reasoning_step / tool_call_* → ThinkingState rows. */
export function rowsFromReasoning(events: SSEEvent[]): { primary: string; secondary?: string; mono?: boolean }[] {
const rows: { primary: string; secondary?: string; mono?: boolean }[] = [];
for (const e of events) {
if (e.type === "reasoning_step" && e.step) rows.push({ primary: e.step });
else if (e.type === "tool_call_started" && e.name) rows.push({ primary: e.name, mono: true });
else if (e.type === "tool_call_completed" && e.name)
rows.push({ primary: e.name, secondary: e.formatted_result, mono: true });
}
return rows;
}