Compare commits

...
Author SHA1 Message Date
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
MervinPraisonandCursor 3058886312 chore: bump automation hub to v1.1.10 for merge gate fix
Co-authored-by: Cursor <[email protected]>
2026-09-22 15:30:07 +01:00
Mervin Praison 70d8610733 Merge pull request #291 from MervinPraison/claude/issue-290-20260916
docs: add SECURITY.md with private disclosure policy
2026-09-17 10:37:03 +01:00
MervinPraison 0f60ec41f1 Release v0.3.146 2026-09-17 03:02:10 +00:00
MervinPraisonandClaude Opus 4.8 2797667e04 docs: add SECURITY.md with private disclosure policy (fixes #290)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-16 16:28:01 +00:00
Mervin Praison f23aacdec9 Merge pull request #289 from MervinPraison/claude/issue-286-20260915
fix: auto-load ~/.praisonai/.env on aiui run so env: channel tokens resolve
2026-09-16 14:43:31 +01:00
MervinPraisonandClaude Opus 4.8 8dc8b9f49f fix: auto-load ~/.praisonai/.env on aiui run so env: tokens resolve (fixes #286)
Channels configured with env:VAR token references silently resolved to
empty strings when the dashboard was started without the shell exporting
the tokens, so bots showed 'Connected' but never replied. aiui run now
loads the standard PraisonAI .env files (~/.praisonai/.env, then ./.env)
into the process environment at startup, matching the PraisonAI CLI
convention. Existing process vars are never overridden and parsing is
dependency-free (no python-dotenv).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-15 13:16:37 +00:00
24 changed files with 2231 additions and 19 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
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].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || '' }}
+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].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
- 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].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
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].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+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].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
- 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].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+37
View File
@@ -0,0 +1,37 @@
# Security Policy
We take the security of PraisonAIUI seriously and appreciate responsible
disclosure of vulnerabilities.
## Supported Versions
Security fixes are applied to the latest released version of PraisonAIUI.
Please make sure you are on the most recent release before reporting.
## Reporting a Vulnerability
**Please do not open a public issue for security vulnerabilities.**
Report privately through GitHub's **Private Vulnerability Reporting**:
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
of this repository.
2. Click **"Report a vulnerability"** to open a private advisory.
3. Include as much detail as you can:
- Affected component(s) and version.
- Steps to reproduce.
- Impact assessment.
- Any suggested fix or mitigation.
If you are unable to use private reporting, you may open a public issue that
contains **no technical specifics** and requests a private channel, and a
maintainer will follow up.
## What to Expect
- We aim to acknowledge new reports within a few business days.
- We will keep you informed as we investigate and work on a fix.
- Once a fix is released, we are happy to credit you in the advisory (unless
you prefer to remain anonymous).
Thank you for helping keep PraisonAIUI and its users safe.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.145"
version = "0.3.146"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+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;
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.145"
__version__ = "0.3.146"
+48
View File
@@ -44,6 +44,43 @@ def _icon(symbol: str, fallback: str) -> str:
return symbol if _supports_unicode() else fallback
def _load_env_files() -> int:
"""Load environment variables from the standard PraisonAI ``.env`` files.
Mirrors the PraisonAI CLI convention so ``env:VAR`` references (e.g. in
Channels config) resolve when the server was started without the shell
exporting the tokens. Reads ``~/.praisonai/.env`` first, then a ``.env`` in
the current working directory. Existing process env vars are never
overridden, and parsing is intentionally dependency-free (no python-dotenv).
Returns the number of variables newly set into ``os.environ``.
"""
import os
loaded = 0
for env_path in (Path.home() / ".praisonai" / ".env", Path.cwd() / ".env"):
try:
if not env_path.is_file():
continue
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export "):]
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
loaded += 1
except Exception:
# Never let env loading break startup — resolution falls back to
# the existing process environment.
continue
return loaded
app = typer.Typer(
name="aiui",
help="PraisonAIUI - YAML-driven website generator",
@@ -1380,6 +1417,17 @@ def run(
console.print(f"[red]Error:[/red] App file not found: {app_file}")
raise typer.Exit(code=1)
# Auto-load standard PraisonAI env files so `env:VAR` references (e.g. in
# Channels config) resolve even when the shell did not export the tokens.
# This matches the PraisonAI CLI convention and fixes the "Connected but
# bot never replies" class of issues caused by empty env-resolved tokens.
_loaded = _load_env_files()
if _loaded:
console.print(
f"[green]{_icon('✓', 'OK')}[/green] Loaded {_loaded} environment "
f"variable(s) from PraisonAI config"
)
is_yaml = app_file.suffix in (".yaml", ".yml")
if is_yaml:
+68
View File
@@ -0,0 +1,68 @@
"""Unit tests for `_load_env_files` — auto-loading PraisonAI `.env` files.
Regression coverage for issue #286: `env:VAR` channel token references
resolved to empty strings because `aiui run` never loaded the standard
`~/.praisonai/.env` file, so bots showed "Connected" but never replied.
"""
import os
from praisonaiui.cli import _load_env_files
def _write_env(path, body):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def test_loads_home_praisonai_env(tmp_path, monkeypatch):
"""Variables in ~/.praisonai/.env are loaded into the process env."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
_write_env(tmp_path / ".praisonai" / ".env", 'TELEGRAM_BOT_TOKEN="123:AAtoken"\n')
loaded = _load_env_files()
assert loaded >= 1
assert os.environ["TELEGRAM_BOT_TOKEN"] == "123:AAtoken"
def test_does_not_override_existing_env(tmp_path, monkeypatch):
"""An already-set process variable is never clobbered by the file."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-shell")
_write_env(tmp_path / ".praisonai" / ".env", "SLACK_BOT_TOKEN=xoxb-from-file\n")
_load_env_files()
assert os.environ["SLACK_BOT_TOKEN"] == "xoxb-from-shell"
def test_ignores_comments_blanks_and_export_prefix(tmp_path, monkeypatch):
"""Comments/blank lines are skipped and `export ` prefixes are stripped."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("FOO", raising=False)
monkeypatch.delenv("BAR", raising=False)
_write_env(
tmp_path / ".praisonai" / ".env",
"# a comment\n\nexport FOO=foo\nBAR='bar'\n",
)
_load_env_files()
assert os.environ["FOO"] == "foo"
assert os.environ["BAR"] == "bar"
def test_missing_files_are_harmless(tmp_path, monkeypatch):
"""No env files present → returns 0 and does not raise."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
assert _load_env_files() == 0
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.145"
version = "0.3.146"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },