Quant Agent — multi-agent quant terminal on Hyperliquid
All checks were successful
deploy / deploy (push) Successful in 7s

Live HL data → in-browser TA → 4-agent committee → LONG/SHORT/SKIP verdict.
Terminal grid command bar + interactive agent-workflow graph. Paper-trading
worker reuses the browser brain (dry-run + Telegram). Forgejo static deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zefiroff 2026-06-09 19:00:13 +04:00
commit f3b533b032
110 changed files with 15045 additions and 0 deletions

View file

@ -0,0 +1,97 @@
---
name: add-x-tweet
description: Add X (Twitter) testimonials to the bklit homepage. Fetches username, avatar, and tweet text from a status URL and appends an entry to apps/web/lib/testimonials.ts. Use when the user shares an x.com/twitter.com tweet URL to add or replace a testimonial.
---
# Add X Tweet Testimonial
Add social proof tweets to the homepage testimonial grid.
## When to use
- User provides one or more `https://x.com/.../status/...` URLs
- User asks to replace, prioritize, or reorder testimonials
- User says "add tweet", "new testimonial", or similar
## Workflow
### 1. Fetch tweet metadata
Run the fetch script from the repo root (requires network):
```bash
node .agents/skills/add-x-tweet/scripts/fetch-tweet.mjs "https://x.com/user/status/123..."
```
For multiple URLs, pass each URL or run once per URL.
The script prints JSON:
```json
{
"id": "123...",
"url": "https://x.com/user/status/123...",
"author": { "name": "...", "handle": "@user", "avatar": "https://pbs.twimg.com/...", "verified": true },
"content": "tweet text without @uixmat prefix"
}
```
**Fallback:** If the script fails, use Twitter oEmbed:
```bash
curl -sL "https://publish.twitter.com/oembed?url=TWEET_URL&omit_script=1"
```
Parse `author_name` and HTML `<p>` for text. Avatar still needs fxtwitter or manual profile image URL.
### 2. Edit `apps/web/lib/testimonials.ts`
- Use **status id** as `id` (numeric string from URL)
- Set `url` to the canonical tweet URL (no query params)
- Run avatar through `twitterAvatarUrl()` (upgrades `_200x200``_400x400`)
- Strip leading `@uixmat` from content; trim whitespace
- Shorten very long tweets if they include trailing promo links (keep the praise, drop link-only tails)
### 3. Ordering rules
Masonry uses CSS columns (top-to-bottom per column). On `lg`, **indices 02** = column 1 top 3, **35** = column 2 top 3, **68** = column 3 top 3.
- **Prioritize** when asked: place in the next open slot among indices 08 without duplicating ids
- **Replace** when asked: remove the old entry (match by handle or old id) before adding the new one
- Set `author.verified` from script output (`author.verification.verified` via FxTwitter)
### 4. Collapsed grid
The homepage shows `testimonialCollapsedCount` (6) cards before **See more**. No change needed when adding tweets unless the user asks to change visible rows.
### 5. Verify
```bash
cd apps/web && npx tsc --noEmit -p tsconfig.json
```
Open homepage — grid is 3 columns, bottom fade + **See more** expands max-height.
## Data source
Primary: [FxTwitter API](https://github.com/FixTweet/FxTwitter) `https://api.fxtwitter.com/{user}/status/{id}` — returns author name, screen_name, avatar_url, and text.
Do not commit API keys; the public endpoint is used by the script only at add-tweet time.
## Example entry
```ts
{
id: "2056717453816729751",
url: "https://x.com/jordienr/status/2056717453816729751",
author: {
name: "jordi",
handle: "@jordienr",
avatar: twitterAvatarUrl(
"https://pbs.twimg.com/profile_images/2053541405121769475/TUDez6zL_200x200.jpg"
),
verified: true,
},
content: "bklit saved my marriage",
},
```

View file

@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Fetch X tweet metadata for homepage testimonials.
* Usage: node fetch-tweet.mjs "https://x.com/user/status/123"
*/
const urls = process.argv.slice(2);
if (urls.length === 0) {
console.error("Usage: node fetch-tweet.mjs <tweet-url> [tweet-url...]");
process.exit(1);
}
function parseTweetUrl(url) {
const match = url.match(/(?:x|twitter)\.com\/([^/]+)\/status\/(\d+)/i);
if (!match) {
throw new Error(`Invalid tweet URL: ${url}`);
}
return { screenName: match[1], statusId: match[2] };
}
function stripReplyPrefix(text) {
return text.replace(/^@uixmat\s*/i, "").trim();
}
function upgradeAvatar(url) {
return url.replace(/_200x200\.(jpg|png|webp)$/i, "_400x400.$1");
}
async function fetchTweet(url) {
const { screenName, statusId } = parseTweetUrl(url);
const apiUrl = `https://api.fxtwitter.com/${screenName}/status/${statusId}`;
const res = await fetch(apiUrl, {
headers: { "User-Agent": "bklit-ui-add-x-tweet/1.0" },
});
if (!res.ok) {
throw new Error(`FxTwitter ${res.status} for ${url}`);
}
const data = await res.json();
const tweet = data.tweet ?? data;
const author = tweet.author ?? {};
const canonicalUrl = `https://x.com/${screenName}/status/${statusId}`;
const verified = author.verification?.verified === true;
return {
id: statusId,
url: canonicalUrl,
author: {
name: author.name ?? screenName,
handle: `@${author.screen_name ?? screenName}`,
avatar: upgradeAvatar(author.avatar_url ?? author.avatar ?? ""),
verified,
},
content: stripReplyPrefix(tweet.text ?? ""),
};
}
for (const url of urls) {
try {
const entry = await fetchTweet(url);
console.log(JSON.stringify(entry, null, 2));
} catch (err) {
console.error(`Failed: ${url}`);
console.error(err.message);
process.exitCode = 1;
}
}

View file

@ -0,0 +1,30 @@
---
name: bklit-playground
description: >
DEPRECATED — use bklit-studio instead. Local /playground is no longer the
chart development workflow for bklit-ui contributors.
disable-model-invocation: true
---
# Deprecated: bklit-playground
**This skill is retired.** Chart prototyping and editing now happen in **Studio** only.
## Use instead
Read and follow **`.agents/skills/bklit-studio/SKILL.md`**.
- Dev URL: **http://localhost:3000/studio** (`pnpm dev` from repo root)
- Optional: `?chart=line-chart` (or any slug from `packages/studio/src/chart-slugs.ts`)
## Why
Studio already includes the editor shell, component tree, properties, motion controls, codegen, and registry-backed previews. The gitignored `/playground` route duplicated wiring and drifted from production Studio.
## `/playground` route
Redirects to `/studio`. Do not scaffold `apps/web/app/playground/page.tsx` or `apps/web/components/playground/` for new chart work.
## Shipping
Unchanged: **`.agents/skills/bklit-ship/SKILL.md`** — prototypes should live in `packages/ui` and `packages/studio`, not under `apps/web/components/playground/`.

View file

@ -0,0 +1,7 @@
/**
* DEPRECATED do not copy to apps/web/app/playground/page.tsx.
*
* Chart development uses Studio: http://localhost:3000/studio
* See .agents/skills/bklit-studio/SKILL.md
*/
export {};

View file

@ -0,0 +1,115 @@
---
name: bklit-ship
description: bklit-ui monorepo contributors only — ship a chart or component from Studio prototype to production in packages/ui with docs and registry.
disable-model-invocation: true
---
# Bklit Ship Skill
This skill is for **bklit-ui monorepo contributors** taking a chart or component validated in **Studio** into **production**: published in the UI package, documented, and ready for users.
## When to use this skill
- You cloned `bklit/bklit-ui` and have a working prototype validated in **Studio** (`/studio`) and want to ship it.
- You are ready to move from "scaffolding" to "permanent": the API and key props/variants are decided from the prototyping phase.
---
## Plan: Prototype → Production
Follow these steps in order. Treat this as a checklist; each step has concrete locations in the repo.
### 1. Move into the UI package and export
- **Move** or finalize chart/component source in `packages/ui/` (e.g. `packages/ui/src/charts/` for charts). Prototypes should already live here if you followed **bklit-studio**; remove any leftover copies under `apps/web/components/playground/` if present.
- **Export** the new chart/component from the packages public API:
- For charts: add exports in `packages/ui/src/charts/index.ts`.
- If the package uses `exports` in `package.json` for specific entry points, add or update the relevant entry so the new component is importable as `@bklitui/ui/charts` (or the appropriate path).
- **Do not** add app-only chart copies under `apps/web/components/playground/` (deprecated). Studio previews belong in `packages/studio`.
### 2. Documentation and examples (apps/web)
Documentation lives under `apps/web/`. Do all of the following.
- **Update existing component docs** when shipping extends an existing chart (new props, subcomponents, or behavior):
- Add new props to the relevant tables in the **parent** doc (e.g. `line-chart.mdx` for `Grid highlightRowValues`, `ChartTooltip indicatorColor`).
- Update related utility docs if needed (e.g. `content/docs/utility/grid.mdx`, `tooltip.mdx`).
- Keep the **primary docs preview unchanged** — do not swap the standard preview on an existing page for the new variant.
- Add a short section linking to a dedicated doc page when the feature warrants one (e.g. Profit/Loss → `profit-loss-line.mdx`).
- **Chart examples (live demos on `/charts/[slug]`)**
- Add examples to the **corresponding gallery route** (e.g. profit/loss variants under `/charts/line-chart`, not only a new docs preview).
- If the feature is a new chart kind, add its slug to `apps/web/components/charts/chart-slugs.ts` and register examples in `apps/web/components/charts/chart-examples.tsx` (`CHART_NAV_ITEMS` / factory registry as appropriate).
- Reuse or mirror the prop variants you validated in the scaffolding phase.
- **Dedicated doc page** (when shipping a new composable or chart kind)
- Add `apps/web/content/docs/components/<name>.mdx` with frontmatter, `<ComponentPreview>`, installation, usage, and props — consistent with existing component docs.
- Add the slug to `apps/web/content/docs/components/meta.json` (desktop sidebar).
- Add an entry to `apps/web/components/docs/site-header.tsx` (mobile nav).
### 3. Studio
- **Update the existing studio chart** when the feature is a variant of an existing type, **or add a new studio chart** when it is a distinct kind.
- Wire **all tunable props** into studio (if not already done while prototyping):
- `packages/studio/src/lib/studio-parsers.ts` — URL state keys and defaults
- `packages/studio/src/lib/registry-control-groups.ts` — control groups
- `packages/studio/src/lib/registry.tsx` — render preview + `generateCode`
- `packages/studio/src/lib/studio-components.ts` — layer tree
- Chart-type defaults in `packages/studio/src/components/studio-state-provider.tsx` when switching chart type
### 4. Rebuild the shadcn registry
- From the **repo root**: run `pnpm registry:build`.
- This updates `apps/web/public/r/` from `packages/ui`. Ensure new components are listed in `packages/ui/registry.json` when they should be installable via shadcn.
### 5. Lint, format, test, and build
Run from repo root until clean:
```bash
pnpm lint
pnpm format
pnpm --filter @bklitui/ui test # when package logic changed
pnpm build
pnpm registry:build # if not already run in step 4
```
Fix all errors; repeat until hooks pass on commit.
### 6. Commit, push, and open a PR
- **Commit** with a short, clear message (e.g. `feat(charts): add ProfitLossLine component`).
- **Push** the branch.
- **Open a PR** with the ship checklist filled in (see below).
---
## PR checklist
- [ ] Chart/component moved to `packages/ui` and exported
- [ ] **Existing docs updated** with new props/features (standard preview unchanged)
- [ ] Gallery examples on the correct `/charts/**` route
- [ ] Dedicated doc page + sidebar + mobile nav (if new composable/chart kind)
- [ ] **Studio** chart updated or added with all props in control groups
- [ ] Registry rebuilt (`pnpm registry:build`)
- [ ] `pnpm lint`, tests (if applicable), and `pnpm build` pass
---
## File reference (quick lookup)
| Step | Location |
|------|----------|
| Chart exports | `packages/ui/src/charts/index.ts` |
| Chart slugs | `apps/web/components/charts/chart-slugs.ts` |
| Chart examples (nav + registry) | `apps/web/components/charts/chart-examples.tsx` |
| Component docs | `apps/web/content/docs/components/<name>.mdx` |
| Utility docs (Grid, Tooltip, …) | `apps/web/content/docs/utility/*.mdx` |
| Sidebar (desktop) | `apps/web/content/docs/components/meta.json``pages` |
| Mobile nav | `apps/web/components/docs/site-header.tsx``components` array |
| Studio registry | `packages/studio/src/lib/registry.tsx` |
| Studio components tree | `packages/studio/src/lib/studio-components.ts` |
| Studio controls | `packages/studio/src/lib/registry-control-groups.ts` |
| Studio URL state | `packages/studio/src/lib/studio-parsers.ts` |
| Registry (source) | `packages/ui/registry.json`; build output: `apps/web/public/r/` |
| Registry build | From root: `pnpm registry:build` |

View file

@ -0,0 +1,134 @@
---
name: bklit-studio-chart-performance
description: >
Reusable Studio chart performance audit and fix workflow. Use when a chart
feels sluggish in /studio (pan, slider ticks, legend hover) but siblings
like pie-chart feel fine.
---
# Studio chart performance
Use when a chart feels sluggish in Studio but similar charts (e.g. pie-chart) are fine.
## One-line rule
Keep enter animation on paths if you need it, then **drop Motion path subscriptions** and **isolate hover** so Studio slider and legend updates don't replay expensive arc/path math across every series every frame.
---
## 1. Find what re-renders on every interaction
Studio updates `displayState` on every slider tick and on legend/slice hover. Trace:
- Does hover live in the **same context** as data, scales, and animation config?
- Does the preview **recreate children** (`data.map`, pattern defs, motion props) every render?
- Does the chart **remount unnecessarily** (`key` tied to motion signature vs manual replay)?
**Pattern:** Split context like cartesian / pie charts — **stable slice** (data, geometry, animation config) vs **hover slice** (`hoveredIndex`, tooltip). Consumers that don't need hover use only the stable hook (`usePieStable`, `useRingStable`, `useChartStable`, …).
**Studio pan:** Wrap chart render in `StudioChartRender` (`packages/studio/src/components/studio-chart-render.tsx`) so camera pan / FPS counter parent updates skip the chart tree when render props are unchanged.
---
## 2. Treat SVG path `d` animation as expensive
Animating `d` with Motion / `useTransform` + d3 arc (or similar) runs layout + paint every frame, per series.
| Prefer | Avoid |
|--------|--------|
| `transform` / `opacity` for hover (compositor-friendly) | Continuous `d` morphing after enter is done |
| Static `d` once enter finishes | Keeping Motion subscriptions on `d` for the chart's lifetime |
| Enter animation only, then static paths | Re-running enter path math on unrelated prop changes |
**Pattern:** `useMountProgress` for enter → when progress ≥ 1 (`useEnterComplete`), render **static paths** and only animate hover with `x`/`y`/`opacity`/`scale` on a **`motion.g`** wrapper (not per-path `scale` on `motion.path`).
Shared hook: `packages/ui/src/charts/use-enter-complete.ts`
---
## 3. Memoize chart shell context
Unmemoized provider values force all children to reconcile on every parent render.
- Memoize the **stable context object** with explicit deps (data, arcs/radii, dimensions, callbacks).
- Memoize **hover context** on `hoveredIndex` + stable `setHoveredIndex` (`useCallback` in chart shell).
- Match **`isLoaded`** to ring/cartesian: `useEffect` + timeout, not a lazy `useState` initializer.
Reference: `pie-context.tsx`, `ring-context.tsx`, `chart-context.tsx`, `PieChartCore` / `RingChartCore` `useMemo` on provider value.
---
## 4. Studio previewspecific wins
Chart-agnostic; apply in `packages/studio/src/components/charts/*-studio*.tsx`:
| Win | How |
|-----|-----|
| Conditional defs | Only pass `patternDefs` / gradients when a series uses patterns |
| Memo derived data | Colored/mapped data arrays; slice/series lists (`useMemo`, deps: `dataSeed` + design fields that affect color) |
| Memo motion enter | Don't call `getStudioMotionEnterProps` inline; `useMemo` with **motion-only** deps (not full `state`) |
| Memo legend hover | `{ hoveredIndex, setHoveredIndex }` in `useMemo` — already in `studio-legend-hover.tsx` |
| Memo chart body | `memo()` wrapper; pass **primitives** (`chartKey`, `chartSize`, `data`) not whole `ctx` so pan/shell re-renders skip rebuild |
| Disable glow in Studio | `showGlow={false}` on series components |
Reference: `pie-studio-preview.tsx`, `ring-studio-preview.tsx`
---
## 5. Compare against a “fast” sibling in Studio
Diff the slow chart against one that feels smooth in the same editor (usually **pie-chart**):
| Check | Slow chart often has | Fast chart often has |
|-------|----------------------|----------------------|
| Shell | Inline render, extra defs | `StudioChartShell` + conditional patterns |
| Series count | Many animated paths | Fewer paths or simpler geometry |
| Hover | Context + full tree re-render | Stable subscribers; hover on `motion.g` / translate |
| Enter | Path `d` wipe per series | Static `d` after enter; transform-only hover |
| Pan | Chart tree rebuilds every frame | `StudioChartRender` memo boundary |
---
## 6. Validation bar
Before opening a PR:
```bash
pnpm lint
pnpm --filter @bklitui/ui check-types
pnpm --filter @bklitui/studio check-types
# scoped production build when touching studio/web
```
Manual `/studio?chart=<slug>`:
- [ ] Enter animation
- [ ] Hover / legend sync
- [ ] Drag geometry sliders (no unnecessary remount)
- [ ] Canvas pan (space + drag) after enter — FPS near pie-chart baseline
- [ ] Pattern/gradient mode if supported
---
## Chart status (bklit-ui)
| Chart | Slug | Status |
|-------|------|--------|
| Pie | `pie-chart` | ✅ Reference (#120) |
| Ring | `ring-chart` | ✅ Aligned to checklist (context split, static paths, `StudioChartRender`, preview memo) |
| Radar / Funnel / Choropleth | various | Partial — run checklist |
| Cartesian / scatter / live-line | various | ✅ #91 decimation + hover batching |
| Sankey | `sankey-chart` | `useTransform` on link stroke — candidate for `useEnterComplete` |
| Gauge | `gauge-chart` | Low priority (single arc) |
---
## Key files
| Area | Path |
|------|------|
| Enter-complete hook | `packages/ui/src/charts/use-enter-complete.ts` |
| Pie reference | `packages/ui/src/charts/pie-slice.tsx`, `pie-context.tsx`, `pie-studio-preview.tsx` |
| Ring | `packages/ui/src/charts/ring.tsx`, `ring-chart.tsx`, `ring-studio-preview.tsx` |
| Pan isolation | `packages/studio/src/components/studio-chart-render.tsx` |
| Registry | `packages/studio/src/lib/registry.tsx` |

View file

@ -0,0 +1,107 @@
---
name: bklit-studio
description: >
bklit-ui monorepo contributors only. Use automatically when building or editing
charts, tuning props/animation, or prototyping in Studio (/studio). Replaces
the deprecated local /playground route.
---
# Bklit Studio Skill (chart development)
**Monorepo contributors only.** Use this skill **automatically** whenever you build, edit, or prototype a chart — before shipping via **bklit-ship**.
Studio at **`/studio`** is the single development surface: full editor shell, component tree, properties, motion, codegen, and registry previews. Do **not** scaffold `apps/web/app/playground/`.
## When to use (auto-trigger)
- Build, prototype, or edit a chart (new or existing)
- Tune props, layers, data, styling, or animation
- Mentions Studio, `/studio`, registry preview, or chart controls
- Work continues from a prior playground task → use Studio instead
## Quick start
```bash
pnpm dev # repo root
```
Open **http://localhost:3000/studio** (optional query: `?chart=line-chart`).
| Task | URL |
|------|-----|
| Edit existing chart | `/studio?chart=<slug>` — slugs in `packages/studio/src/chart-slugs.ts` |
| Profit/loss line mode | `/studio?chart=line-chart&lineChartMode=profitLoss` |
## Editor layout
| Region | Purpose |
|--------|---------|
| **Left** | Chart type (in full Studio), layer list, data controls, animation |
| **Center** | Canvas, rulers, resizable frame, replay/scramble |
| **Right** | Properties for the **selected layer** |
**Pane rules:** animation → left (`motionPanel` on chart config); per-layer props → right; visibility → eye icon on layer (uses `hiddenComponents` in URL state).
## Where to implement (existing charts)
Edit the chart in **`packages/studio`** and **`packages/ui`** — not `apps/web/components/playground/`.
| Concern | Path |
|---------|------|
| Chart preview + codegen | `packages/studio/src/lib/registry.tsx` (`render`, `generateCode`) |
| Layer tree (components panel) | `packages/studio/src/lib/studio-components.ts` (`resolve*Components`) |
| Control groups (properties) | `packages/studio/src/lib/registry-control-groups.ts` |
| URL / default state | `packages/studio/src/lib/studio-parsers.ts` |
| Chart UI | `packages/ui/src/charts/` |
| Slugs | `packages/studio/src/chart-slugs.ts` + `studioRegistry` in `registry.tsx` |
| Studio-only previews | `packages/studio/src/components/charts/*-studio-preview.tsx` |
### Patterns in `registry.tsx` render
Reuse committed helpers (do not reimplement):
- `StudioChartShell` + `studioCartesianLegendItems` — legend grid + `showLegend`
- `StudioVisibleLayer` + `componentId` — ties render to layer visibility (`line.grid`, `line.series.0`, …)
- `getStudioCssRevealPropsForPreview` — motion / reveal
- `isStudioComponentVisible(state, componentId)` — conditional children
- `seriesStrokePropsFromState`, `fadeEdgesPropValue`, `chartTooltipPropsFromState`
Reference implementation: `lineConfig.render` in `registry.tsx`.
## New chart (not in registry yet)
1. Implement component(s) in `packages/ui/src/charts/` (minimal API first).
2. Add slug to `packages/studio/src/chart-slugs.ts`.
3. Add `StudioChartConfig` to `studioRegistry` in `registry.tsx`:
- `render(state, ctx)` — preview inside `EditorChartFrame` via `StudioShell`
- `resolveComponents` in `studio-components.ts`
- `controlGroups` / `resolveControlGroups`
- `generateCode` for the code sheet
4. Open `/studio?chart=<new-slug>` and iterate.
5. When stable, follow **bklit-ship** for docs, gallery, shadcn registry.
## Adding a prop or layer
1. Add key to `StudioUrlState` / `defaultStudioState` in `studio-parsers.ts` if missing.
2. Add control(s) on the right layer in `registry-control-groups.ts` or per-component `controlGroups` in `studio-components.ts`.
3. Wire prop in `registry.tsx` `render` (and in `packages/ui` chart code).
4. If the prop affects animation → ensure `motionPanel: true` on chart config.
5. Verify in Studio: layer list, visibility toggle, properties, replay.
## Shipping
When the API is stable, read `.agents/skills/bklit-ship/SKILL.md` — prototype code should already live in `packages/ui` + `packages/studio`; no playground route to migrate.
## Deprecated: `/playground`
The local **`/playground`** route and **`bklit-playground`** skill are deprecated. `/playground` redirects to `/studio`. Do not copy `playground-editor-shell` or `apps/web/components/playground/` for new work.
## File reference
| Item | Path |
|------|------|
| Studio app route | `apps/web/app/studio/page.tsx` (`StudioShell`) |
| Studio package | `packages/studio/` |
| Studio skill | `.agents/skills/bklit-studio/SKILL.md` |
| Ship checklist | `.agents/skills/bklit-ship/SKILL.md` |
| Performance audit | `.agents/skills/bklit-studio-chart-performance/SKILL.md` |

View file

@ -0,0 +1,136 @@
---
name: bklit-ui
description: >
Bklit UI charts and data visualization for any project using the @bklit
shadcn registry. Install, compose, theme, and animate charts correctly.
Triggers when working with @bklitui/ui/charts, @bklit components, data
visualization, dashboards, or chart theming. Also invoke manually for
chart tasks.
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
# Bklit UI
Composable chart components for React, distributed via the `@bklit` shadcn registry. Charts are installed as source into the user's project.
> **IMPORTANT:** Run shadcn CLI commands with the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`.
## Current Project Context
```json
!`npx shadcn@latest info --json`
```
Use the JSON above for framework, aliases, Tailwind version, installed components, and resolved paths. Confirm the `@bklit` registry is configured before adding charts.
## Principles
1. **Install before inventing.** Use `npx shadcn@latest add @bklit/<chart>` — charts are registry components, not hand-rolled SVG.
2. **Compose, don't flatten.** Root chart → `Grid` → series → axes → `ChartTooltip`. See [composition.md](./rules/composition.md).
3. **Theme with tokens.** Use `chartCssVars` and `--chart-*` variables — never hardcode one-off colors. See [theming.md](./rules/theming.md).
4. **Read the doc page first.** Each chart has props, data shape, and examples at `https://ui.bklit.com/docs/components/<slug>`.
5. **Browse variants.** Gallery: `https://ui.bklit.com/charts/<slug>` — Studio: `https://ui.bklit.com/studio?chart=<slug>`.
## Critical Rules
These rules are **always enforced**. Each links to Incorrect/Correct examples.
### Composition → [composition.md](./rules/composition.md)
- **Series and axes live inside the root chart**`LineChart`, `BarChart`, `AreaChart`, etc.
- **One root per chart** — use `ComposedChart` for mixed series types.
- **Grid before series** so lines/bars render above grid lines.
- **`ChartTooltip` as a chart child** — required for crosshair and hover context.
### Theming → [theming.md](./rules/theming.md)
- **Use `chartCssVars`** from `@bklitui/ui/charts` instead of raw `"var(--chart-…)"` strings.
- **Series palette:** `--chart-1``--chart-5` for multi-series charts.
- **Tooltip surfaces:** `bg-popover text-popover-foreground` — avoids white-on-white in light mode.
### Animation → [animation.md](./rules/animation.md)
- **Default duration ~1100ms** for cartesian enter animations unless the doc specifies otherwise.
- **Replay:** change `revealSignature` or remount with a new `key`.
- **Live charts:** use `paused` on `LiveLineChart` to debug without stopping the rAF loop manually.
### Tooltips → [tooltips.md](./rules/tooltips.md)
- **Custom content via `ChartTooltip` `content` prop** or children patterns from docs.
- **`indicatorColor` function** for candlestick / dynamic crosshair colors.
- **Custom indicators:** use `useChart()` — do not track mouse globally outside chart context.
### Installation → [installation.md](./rules/installation.md)
- **Require `@bklit` registry** in `components.json`.
- **Install:** `npx shadcn@latest add @bklit/<slug>`.
- **Let the CLI install peer dependencies** — do not pin `@visx/*` / `motion` manually unless resolving a conflict.
## Chart Catalog
| Slug | Use when | Install | Docs | Gallery |
|------|----------|---------|------|---------|
| `area-chart` | Trends with filled regions under lines | `@bklit/area-chart` | [/docs/components/area-chart](https://ui.bklit.com/docs/components/area-chart) | [/charts/area-chart](https://ui.bklit.com/charts/area-chart) |
| `bar-chart` | Category comparisons, stacked or grouped bars | `@bklit/bar-chart` | [/docs/components/bar-chart](https://ui.bklit.com/docs/components/bar-chart) | [/charts/bar-chart](https://ui.bklit.com/charts/bar-chart) |
| `line-chart` | Time series, multi-line trends, markers | `@bklit/line-chart` | [/docs/components/line-chart](https://ui.bklit.com/docs/components/line-chart) | [/charts/line-chart](https://ui.bklit.com/charts/line-chart) |
| `live-line-chart` | Streaming / real-time data | `@bklit/live-line-chart` | [/docs/components/live-line-chart](https://ui.bklit.com/docs/components/live-line-chart) | [/charts/live-line-chart](https://ui.bklit.com/charts/live-line-chart) |
| `composed-chart` | Mixed bar + line (or similar) on one axis | `@bklit/composed-chart` | [/docs/components/composed-chart](https://ui.bklit.com/docs/components/composed-chart) | [/charts/composed-chart](https://ui.bklit.com/charts/composed-chart) |
| `scatter-chart` | Correlation, distribution, bubble sizing | `@bklit/scatter-chart` | [/docs/components/scatter-chart](https://ui.bklit.com/docs/components/scatter-chart) | [/charts/scatter-chart](https://ui.bklit.com/charts/scatter-chart) |
| `candlestick-chart` | OHLC financial data, brushes | `@bklit/candlestick-chart` | [/docs/components/candlestick-chart](https://ui.bklit.com/docs/components/candlestick-chart) | [/charts/candlestick-chart](https://ui.bklit.com/charts/candlestick-chart) |
| `pie-chart` | Part-to-whole slices | `@bklit/pie-chart` | [/docs/components/pie-chart](https://ui.bklit.com/docs/components/pie-chart) | [/charts/pie-chart](https://ui.bklit.com/charts/pie-chart) |
| `ring-chart` | Donut / ring KPIs | `@bklit/ring-chart` | [/docs/components/ring-chart](https://ui.bklit.com/docs/components/ring-chart) | [/charts/ring-chart](https://ui.bklit.com/charts/ring-chart) |
| `radar-chart` | Multi-axis comparison | `@bklit/radar-chart` | [/docs/components/radar-chart](https://ui.bklit.com/docs/components/radar-chart) | [/charts/radar-chart](https://ui.bklit.com/charts/radar-chart) |
| `gauge-chart` | Single-value KPI dial | `@bklit/gauge-chart` | [/docs/components/gauge-chart](https://ui.bklit.com/docs/components/gauge-chart) | [/charts/gauge-chart](https://ui.bklit.com/charts/gauge-chart) |
| `funnel-chart` | Stage conversion funnels | `@bklit/funnel-chart` | [/docs/components/funnel-chart](https://ui.bklit.com/docs/components/funnel-chart) | [/charts/funnel-chart](https://ui.bklit.com/charts/funnel-chart) |
| `sankey-chart` | Flow between nodes | `@bklit/sankey-chart` | [/docs/components/sankey-chart](https://ui.bklit.com/docs/components/sankey-chart) | [/charts/sankey-chart](https://ui.bklit.com/charts/sankey-chart) |
| `choropleth-chart` | Geo regions colored by value | `@bklit/choropleth-chart` | [/docs/components/choropleth-chart](https://ui.bklit.com/docs/components/choropleth-chart) | [/charts/choropleth-chart](https://ui.bklit.com/charts/choropleth-chart) |
## Workflow
1. Run `npx shadcn@latest info --json` — verify `@bklit` registry and aliases.
2. Pick a chart from the catalog (or ask the user what story the data tells).
3. Open the doc URL for data shape and props.
4. If not installed: `npx shadcn@latest add @bklit/<slug>`.
5. Compose with grid, series, axes, tooltip — apply theming tokens.
6. Point the user to the gallery or Studio URL for variant inspiration.
## Quick Reference
```bash
# Project info
npx shadcn@latest info --json
# Add a chart
npx shadcn@latest add @bklit/line-chart
# Search registries (if configured)
npx shadcn@latest search @bklit
```
```tsx
import { LineChart, Line, Grid, XAxis, ChartTooltip, chartCssVars } from "@bklitui/ui/charts";
<LineChart data={data} xDataKey="date">
<Grid horizontal />
<Line dataKey="users" stroke={chartCssVars.linePrimary} />
<XAxis />
<ChartTooltip />
</LineChart>
```
## Utility docs
- Theming: https://ui.bklit.com/docs/theming
- Grid: https://ui.bklit.com/docs/utility/grid
- Legend: https://ui.bklit.com/docs/utility/legend
- Tooltip: https://ui.bklit.com/docs/utility/tooltip
- Custom indicator: https://ui.bklit.com/docs/utility/custom-indicator
- useChart: https://ui.bklit.com/docs/utility/use-chart
## Detailed References
- [composition.md](./rules/composition.md)
- [theming.md](./rules/theming.md)
- [animation.md](./rules/animation.md)
- [tooltips.md](./rules/tooltips.md)
- [installation.md](./rules/installation.md)

View file

@ -0,0 +1,42 @@
# Animation
## Defaults
Most cartesian charts default to ~1100ms enter animation. Bar charts often use staggered reveals (~1.2s total) with easing `cubic-bezier(0.85, 0, 0.15, 1)`.
## Replay enter animations
Pass a changing `revealSignature` (or remount the chart with a new `key`) to replay mount animations after prop changes.
### Correct
```tsx
const [replayKey, setReplayKey] = useState(0);
<LineChart key={replayKey} data={data} revealSignature={String(replayKey)}>
{/* ... */}
</LineChart>
<button type="button" onClick={() => setReplayKey((k) => k + 1)}>
Replay
</button>
```
## Live charts
- `LiveLineChart` runs its own animation loop — use `paused` to freeze updates for debugging.
- Avoid nesting heavy state updates inside the rAF path; pass stable props where possible.
## Reduced motion
Respect `prefers-reduced-motion` when adding custom animation wrappers around charts. Bklit charts handle reduced motion internally for enter transitions.
## Performance
- Prefer CSS/SVG-friendly props over re-creating large data arrays every frame.
- For streaming data, append points and trim the window instead of replacing the full array when possible.
## Inspiration
Browse animated variants: https://ui.bklit.com/charts/<chart-slug>
Tune motion interactively: https://ui.bklit.com/studio?chart=<chart-slug>

View file

@ -0,0 +1,57 @@
# Chart Composition
## Root + children
Charts use a composable API. Always wrap series, axes, and overlays inside the root chart component.
### Incorrect
```tsx
<div className="h-80">
<Line dataKey="users" />
<XAxis />
</div>
```
### Correct
```tsx
<LineChart data={data}>
<Grid horizontal />
<Line dataKey="users" />
<XAxis />
<ChartTooltip />
</LineChart>
```
## Axes and grid
- Put `Grid` before series so lines render on top.
- Use `XAxis` / `YAxis` as siblings inside the root chart — not outside the chart context.
- For live streaming charts, use `LiveXAxis` and `LiveYAxis` inside `LiveLineChart`.
## Tooltips and markers
- `ChartTooltip` must be a child of the root chart so it can read hover state.
- Custom tooltip content receives chart context — prefer `useChart()` when building custom indicators.
- `ChartMarkers` and marker content render inside `ChartTooltip` when showing event callouts.
## Multi-series charts
- One `Line` / `Bar` / `Area` child per `dataKey`.
- Use `--chart-1``--chart-5` or explicit stroke/fill props for series differentiation.
- For combined line + bar, use `ComposedChart` instead of nesting unrelated roots.
## Data shape
- Cartesian charts: array of objects; set `xDataKey` on the root (default `"date"`).
- OHLC: use `CandlestickChart` with `{ date, open, high, low, close }`.
- Live line: `{ time, value }` points with a separate `value` prop on the root.
- Sankey / funnel / pie: follow each charts doc for node/link or slice shape.
## Docs
- Composition patterns: https://ui.bklit.com/docs/components/line-chart
- `useChart` hook: https://ui.bklit.com/docs/utility/use-chart
- Grid: https://ui.bklit.com/docs/utility/grid
- Legend: https://ui.bklit.com/docs/utility/legend

View file

@ -0,0 +1,74 @@
# Installation
## Prerequisites
Bklit UI is a shadcn registry. Initialize shadcn in the project first:
```bash
npx shadcn@latest init
```
## Registry namespace
Add the `@bklit` namespace to `components.json` if it is not already present:
```json
{
"registries": {
"@bklit": "https://ui.bklit.com/r/{name}.json"
}
}
```
New projects scaffolded with Bklit often include this automatically.
## Install a chart
```bash
npx shadcn@latest add @bklit/line-chart
```
Replace `line-chart` with any chart slug from the catalog. The CLI copies source into your components directory and installs peer dependencies.
## Verify project context
```bash
npx shadcn@latest info --json
```
Use the JSON to confirm framework, aliases, Tailwind version, and which `@bklit` components are already installed.
## Import path
After install, import from the charts entry (path may vary by alias — check `info --json`):
```tsx
import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@/components/ui/line-chart";
// or from package re-exports when using the npm package directly:
import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts";
```
Prefer the import path your projects shadcn install actually generated.
## Common peer dependencies
| Chart | Typical dependencies |
|-------|---------------------|
| line-chart, area-chart | `@visx/curve`, `@visx/shape`, `motion` |
| bar-chart | `@visx/gradient`, `@visx/pattern`, `@visx/shape`, `motion` |
| candlestick-chart | `@visx/scale`, `@visx/shape`, `@visx/responsive`, `d3-array`, `motion` |
| live-line-chart | `@visx/curve`, `@visx/scale`, `@visx/shape`, `@visx/responsive`, `@visx/event`, `d3-array`, `motion` |
| choropleth-chart | `@visx/geo`, `@visx/responsive`, `@visx/zoom`, `d3-geo`, `topojson-client`, `motion` |
| sankey-chart | `@visx/gradient`, `@visx/pattern`, `@visx/responsive`, `@visx/sankey`, `motion` |
| scatter-chart | `d3-scale`, `d3-array`, `motion`, `react-use-measure` |
| pie-chart, ring-chart | `@visx/responsive`, `@visx/shape`, `motion` |
| radar-chart | `@visx/responsive`, `d3-shape`, `motion` |
| funnel-chart | `motion` |
| gauge-chart | `@visx/responsive`, `motion` |
The CLI installs these when adding a chart — do not guess versions; let `shadcn add` resolve them.
## Docs
- Installation: https://ui.bklit.com/docs/installation
- Per-chart install tabs: https://ui.bklit.com/docs/components/<slug>

View file

@ -0,0 +1,55 @@
# Theming
## Use chartCssVars
Prefer the typed `chartCssVars` export over raw CSS variable strings.
### Incorrect
```tsx
<line stroke="var(--chart-crosshair)" />
<rect fill="var(--chart-indicator-color)" />
```
### Correct
```tsx
import { chartCssVars } from "@bklitui/ui/charts";
<line stroke={chartCssVars.crosshair} />
<rect fill={chartCssVars.indicatorColor} />
```
## Series colors
- Multi-series: `var(--chart-1)` through `var(--chart-5)` or theme tokens.
- Line charts: `var(--chart-line-primary)` / `var(--chart-line-secondary)` for default strokes.
- Do not hardcode hex colors unless the design explicitly requires brand colors outside the theme.
## Tooltip and badge surfaces
Tooltip boxes and live value badges should use shadcn popover tokens so text stays readable in light and dark mode:
```tsx
// Tooltip content / badges
className="bg-popover text-popover-foreground"
```
## Dark mode
Override chart variables in `:root` and `.dark` — do not sprinkle `dark:` on individual chart SVG elements.
```css
:root {
--chart-background: oklch(1 0 0);
--chart-grid: oklch(0.9 0 0);
}
.dark {
--chart-background: oklch(0.145 0.004 285);
--chart-grid: oklch(0.25 0 0);
}
```
## Docs
- Full theming guide: https://ui.bklit.com/docs/theming

View file

@ -0,0 +1,71 @@
# Tooltips
## Default tooltip
Use `ChartTooltip` as a child of the root chart. Defaults include crosshair, dots, and date pill where applicable.
```tsx
<LineChart data={data}>
<Line dataKey="users" />
<XAxis />
<ChartTooltip />
</LineChart>
```
## Custom content
```tsx
<ChartTooltip
content={({ point, formattedDate }) => (
<div className="rounded-md bg-popover px-3 py-2 text-popover-foreground text-sm">
<div className="font-medium">{formattedDate}</div>
<div>{point.users as number} users</div>
</div>
)}
/>
```
## indicatorColor (candlestick and crosshair)
Match the crosshair to the hovered datum — e.g. green/red candles:
```tsx
<ChartTooltip
indicatorColor={(point) =>
(point.close as number) >= (point.open as number)
? "var(--chart-1)"
: "var(--chart-3)"
}
showDots={false}
/>
```
## Custom indicators
For advanced crosshair/markers, use `useChart()` and the patterns in the custom indicator docs.
### Incorrect
```tsx
// Reading mouse position manually outside chart context
const [x, setX] = useState(0);
useEffect(() => {
window.addEventListener("mousemove", ...);
}, []);
```
### Correct
```tsx
import { useChart } from "@bklitui/ui/charts";
function CustomIndicator() {
const { tooltipData } = useChart();
// render from tooltipData
}
```
## Docs
- ChartTooltip: https://ui.bklit.com/docs/utility/tooltip
- Custom indicators: https://ui.bklit.com/docs/utility/custom-indicator

View file

@ -0,0 +1,299 @@
---
name: pr-open
description: |
Open a pull request the bklit-ui way: stage and commit with pre-commit hooks,
run ultracite from the repo root, rebuild the shadcn registry, run a production test
build, fix failures, push, and create a PR with a structured summary. Use when the
user asks to commit, push,
open a PR, "ship it", or run the full pre-PR checklist.
---
# PR Open Skill
End-to-end workflow for committing work and opening a merge-ready PR in **bklit-ui**.
Read this skill when the user wants to add, commit, validate, push, and open a PR — or references `@pr-open`.
---
## Before you start
1. **Confirm branch context**
- If the user merged an earlier PR on this branch and there are new changes, branch from latest `main` instead of stacking on a stale feature branch:
```bash
git fetch origin main
git checkout -b <new-branch-name> origin/main
```
- Re-apply or cherry-pick only the intended changes; do not commit unrelated `registry:build` noise (e.g. reformatted `packages/ui/registry/examples/*` unless those edits are intentional).
2. **Never commit secrets** — skip `.env`, credentials, API keys, etc. Warn the user if they ask to commit them.
3. **Git safety** (required)
- Do not update git config.
- Do not run destructive commands (`push --force`, `reset --hard`) unless the user explicitly asks.
- Do not skip hooks (`--no-verify`) unless the user explicitly asks.
- Do not `git commit --amend` unless all amend rules in the user's git rules are satisfied (HEAD commit is yours, not pushed, or user requested amend).
- If a commit **fails** due to a hook, fix the issue and create a **new** commit — do not amend a failed commit.
- Use HEREDOC for commit messages (see below).
- Do not push unless the user asked to push or open a PR.
---
## Step 1 — Inspect changes
Run in parallel from the repo root:
```bash
git status
git diff
git diff --staged
git log -5 --oneline
```
If opening a PR, also check divergence from base:
```bash
git fetch origin main
git log origin/main..HEAD --oneline
git diff origin/main...HEAD --stat
```
Draft a commit message that explains **why**, not just what. Match recent repo style (e.g. `feat(charts): …`, `fix(web): …`).
---
## Step 2 — Stage and commit (pre-commit loop)
### Stage
```bash
git add <paths> # prefer explicit paths over blind git add -A
```
### Commit
```bash
git commit -m "$(cat <<'EOF'
<subject line>
<optional body 12 sentences on why>
EOF
)"
```
Husky **pre-commit** runs `npx ultracite fix` (see `.husky/pre-commit`).
### If pre-commit fails or leaves unstaged fixes
1. Read the hook output and fix every reported issue (lint correctness, nested ternaries, `biome-ignore` only when justified, etc.).
2. Re-stage affected files: `git add <paths>`
3. Commit again with a **new** commit (or amend only if amend rules allow and the previous commit succeeded but the hook auto-modified files).
Repeat until `git commit` succeeds **and** `git status` is clean (no leftover hook formatting).
---
## Step 3 — Ultracite from repo root
After commits are clean, run the root pnpm scripts (not `npx ultracite` directly unless fixing a one-off):
```bash
pnpm lint # ultracite check
```
If check fails:
```bash
pnpm lint:fix # ultracite fix (same as pnpm format)
```
Then:
1. Review `git diff` for unexpected changes.
2. If files changed: `git add <paths>` → `git commit -m "$(cat <<'EOF'
Fix lint issues from ultracite
EOF
)"`
3. Re-run `pnpm lint` until it passes with no fixes pending.
Do not open a PR while `pnpm lint` fails or while lint fixes are unstaged.
---
## Step 4 — Rebuild shadcn registry (required)
**Always** run this before the production build on every PR — not only when `packages/ui` changed. Registry artifacts under `apps/web/public/r/` must stay in sync with the committed UI package.
From the repo root:
```bash
pnpm registry:build
```
This updates `apps/web/public/r/` from `packages/ui` (see `packages/ui/package.json``registry:build`).
1. Review `git diff` after the build. **Include** intentional changes under `apps/web/public/r/`.
2. **Do not** commit incidental reformats on `packages/ui/registry/examples/*` unless those edits are intentional.
3. If `apps/web/public/r/` (or `packages/ui/registry.json` when you edited it) changed:
```bash
git add apps/web/public/r packages/ui/registry.json
git commit -m "$(cat <<'EOF'
chore(registry): rebuild shadcn registry for PR
EOF
)"
```
4. Re-run **Step 3** (`pnpm lint`) if any source files changed outside `public/r/`.
Do not open a PR while registry outputs are stale (uncommitted `apps/web/public/r/` diffs after `registry:build`).
---
## Step 5 — Test build
Run a production build to catch type and compile errors before the PR.
**Default (whole monorepo):**
```bash
pnpm build
```
**Web app only** (faster when changes are confined to `apps/web`):
```bash
cd apps/web && pnpm build
```
If the web build OOMs in dev/CI-like environments, retry with more heap:
```bash
cd apps/web && NODE_OPTIONS='--max-old-space-size=8192' pnpm build
```
**Optional but recommended** when touching TypeScript:
```bash
pnpm check-types
```
### If build fails
1. Fix the root cause (types, imports, missing registry files, etc.).
2. Re-run the failing command until it passes.
3. Commit fixes with a clear message, then re-run **Step 3** (`pnpm lint`) and **Step 4** (`pnpm registry:build`) if source files under `packages/ui` changed.
---
## Step 6 — Push
Only when the user asked to push or open a PR:
```bash
git push -u origin HEAD
```
If the branch was already pushed and you added commits, `git push` is enough.
---
## Step 7 — Open the PR
Use GitHub CLI from the repo root:
```bash
gh pr create --title "<PR title>" --body "$(cat <<'EOF'
## Summary
- <bullet: what changed and why>
- <bullet: user-facing impact>
- <bullet: follow-ups or deploy notes if any>
## Test plan
- [ ] `pnpm lint` passes at repo root
- [ ] `pnpm registry:build` run; `apps/web/public/r/` committed if updated
- [ ] `pnpm build` (or `apps/web` build for web-only changes)
- [ ] <manual verification step 1>
- [ ] <manual verification step 2>
EOF
)"
```
Return the PR URL to the user.
### PR title guidelines
- Short, imperative, scoped (e.g. `Add @bklit/chart-animation registry item`)
- Match the primary commit subject when possible
### PR body template (copy structure every time)
```markdown
## Summary
- <13 bullets: what and why>
## Test plan
- [ ] `pnpm lint` passes at repo root
- [ ] `pnpm registry:build` run; registry outputs committed if changed
- [ ] Production build succeeds (`pnpm build` or scoped web build)
- [ ] <feature-specific check>
- [ ] <regression check if applicable>
```
Add extra sections only when useful:
- **Context** — link to a merged PR or issue (e.g. "Follow-up to #70")
- **Deploy notes** — e.g. registry JSON must be live on `ui.bklit.com` for Open in v0
### Repo-specific PR notes
- **Registry**: Step 4 always runs `pnpm registry:build`; commit `apps/web/public/r/` when the build updates it.
- **Do not** commit incidental reformats from `registry:build` on `packages/ui/registry/examples/*` unless intentional.
- Chart/docs work: mention manual checks for docs pages, Studio, or Open in v0 when relevant.
---
## Full checklist (quick reference)
| Step | Command / action | Must pass before next step |
|------|------------------|----------------------------|
| 1 | `git status` / `git diff` / `git log` | Understand scope |
| 2 | `git add``git commit` | Pre-commit hook clean; working tree clean |
| 3 | `pnpm lint``pnpm lint:fix` if needed → re-commit | `pnpm lint` exits 0 |
| 4 | `pnpm registry:build` → commit `apps/web/public/r/` if changed | No stale registry diff |
| 5 | `pnpm build` (and `pnpm check-types` if TS changed) | Build exits 0 |
| 6 | `git push -u origin HEAD` | Only if user requested push/PR |
| 7 | `gh pr create` with Summary + Test plan | PR URL returned |
---
## Common failures
| Failure | Fix |
|---------|-----|
| Pre-commit biome errors | Fix code; add `biome-ignore` only with a one-line justification |
| Hook fixed files but commit already succeeded | `git add` + new commit (or amend if allowed) |
| `pnpm lint` fails after commit | `pnpm lint:fix`, review diff, commit, re-run `pnpm lint` |
| `Can't resolve './animation'` in registry consumers | Add missing `@bklit/*` registry deps; run `pnpm registry:build` |
| Web build OOM | `NODE_OPTIONS='--max-old-space-size=8192'` for `apps/web` build |
| PR branch already merged | New branch from `origin/main`, re-apply only new changes |
---
## Example flow
User: "commit this and open a PR"
1. `git status` + `git diff` + `git log -3`
2. `git add apps/web packages/ui``git commit` (fix pre-commit until clean)
3. `pnpm lint``pnpm lint:fix` if needed → commit → `pnpm lint`
4. `pnpm registry:build` → commit `apps/web/public/r/` if changed
5. `pnpm build` (fix until green) → commit if needed
6. `git push -u origin HEAD`
7. `gh pr create` with Summary + Test plan template
8. Reply with PR link and one-line summary of what was validated

View file

@ -0,0 +1,914 @@
---
name: turborepo
description: |
Turborepo monorepo build system guidance. Triggers on: turbo.json, task pipelines,
dependsOn, caching, remote cache, the "turbo" CLI, --filter, --affected, CI optimization, environment
variables, internal packages, monorepo structure/best practices, and boundaries.
Use when user: configures tasks/workflows/pipelines, creates packages, sets up
monorepo, shares code between apps, runs changed/affected packages, debugs cache,
or has apps/packages directories.
metadata:
version: 2.7.6-canary.3
---
# Turborepo Skill
Build system for JavaScript/TypeScript monorepos. Turborepo caches task outputs and runs tasks in parallel based on dependency graph.
## IMPORTANT: Package Tasks, Not Root Tasks
**DO NOT create Root Tasks. ALWAYS create package tasks.**
When creating tasks/scripts/pipelines, you MUST:
1. Add the script to each relevant package's `package.json`
2. Register the task in root `turbo.json`
3. Root `package.json` only delegates via `turbo run <task>`
**DO NOT** put task logic in root `package.json`. This defeats Turborepo's parallelization.
```json
// DO THIS: Scripts in each package
// apps/web/package.json
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
// apps/api/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// packages/ui/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
```
```json
// turbo.json - register tasks
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": { "dependsOn": ["build"] }
}
}
```
```json
// Root package.json - ONLY delegates, no task logic
{
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test"
}
}
```
```json
// DO NOT DO THIS - defeats parallelization
// Root package.json
{
"scripts": {
"build": "cd apps/web && next build && cd ../api && tsc",
"lint": "eslint apps/ packages/",
"test": "vitest"
}
}
```
Root Tasks (`//#taskname`) are ONLY for tasks that truly cannot exist in packages (rare).
## Secondary Rule: `turbo run` vs `turbo`
**Always use `turbo run` when the command is written into code:**
```json
// package.json - ALWAYS "turbo run"
{
"scripts": {
"build": "turbo run build"
}
}
```
```yaml
# CI workflows - ALWAYS "turbo run"
- run: turbo run build --affected
```
**The shorthand `turbo <tasks>` is ONLY for one-off terminal commands** typed directly by humans or agents. Never write `turbo build` into package.json, CI, or scripts.
## Quick Decision Trees
### "I need to configure a task"
```
Configure a task?
├─ Define task dependencies → references/configuration/tasks.md
├─ Lint/check-types (parallel + caching) → Use Transit Nodes pattern (see below)
├─ Specify build outputs → references/configuration/tasks.md#outputs
├─ Handle environment variables → references/environment/README.md
├─ Set up dev/watch tasks → references/configuration/tasks.md#persistent
├─ Package-specific config → references/configuration/README.md#package-configurations
└─ Global settings (cacheDir, daemon) → references/configuration/global-options.md
```
### "My cache isn't working"
```
Cache problems?
├─ Tasks run but outputs not restored → Missing `outputs` key
├─ Cache misses unexpectedly → references/caching/gotchas.md
├─ Need to debug hash inputs → Use --summarize or --dry
├─ Want to skip cache entirely → Use --force or cache: false
├─ Remote cache not working → references/caching/remote-cache.md
└─ Environment causing misses → references/environment/gotchas.md
```
### "I want to run only changed packages"
```
Run only what changed?
├─ Changed packages + dependents (RECOMMENDED) → turbo run build --affected
├─ Custom base branch → --affected --affected-base=origin/develop
├─ Manual git comparison → --filter=...[origin/main]
└─ See all filter options → references/filtering/README.md
```
**`--affected` is the primary way to run only changed packages.** It automatically compares against the default branch and includes dependents.
### "I want to filter packages"
```
Filter packages?
├─ Only changed packages → --affected (see above)
├─ By package name → --filter=web
├─ By directory → --filter=./apps/*
├─ Package + dependencies → --filter=web...
├─ Package + dependents → --filter=...web
└─ Complex combinations → references/filtering/patterns.md
```
### "Environment variables aren't working"
```
Environment issues?
├─ Vars not available at runtime → Strict mode filtering (default)
├─ Cache hits with wrong env → Var not in `env` key
├─ .env changes not causing rebuilds → .env not in `inputs`
├─ CI variables missing → references/environment/gotchas.md
└─ Framework vars (NEXT_PUBLIC_*) → Auto-included via inference
```
### "I need to set up CI"
```
CI setup?
├─ GitHub Actions → references/ci/github-actions.md
├─ Vercel deployment → references/ci/vercel.md
├─ Remote cache in CI → references/caching/remote-cache.md
├─ Only build changed packages → --affected flag
├─ Skip unnecessary builds → turbo-ignore (references/cli/commands.md)
└─ Skip container setup when no changes → turbo-ignore
```
### "I want to watch for changes during development"
```
Watch mode?
├─ Re-run tasks on change → turbo watch (references/watch/README.md)
├─ Dev servers with dependencies → Use `with` key (references/configuration/tasks.md#with)
├─ Restart dev server on dep change → Use `interruptible: true`
└─ Persistent dev tasks → Use `persistent: true`
```
### "I need to create/structure a package"
```
Package creation/structure?
├─ Create an internal package → references/best-practices/packages.md
├─ Repository structure → references/best-practices/structure.md
├─ Dependency management → references/best-practices/dependencies.md
├─ Best practices overview → references/best-practices/README.md
├─ JIT vs Compiled packages → references/best-practices/packages.md#compilation-strategies
└─ Sharing code between apps → references/best-practices/README.md#package-types
```
### "How should I structure my monorepo?"
```
Monorepo structure?
├─ Standard layout (apps/, packages/) → references/best-practices/README.md
├─ Package types (apps vs libraries) → references/best-practices/README.md#package-types
├─ Creating internal packages → references/best-practices/packages.md
├─ TypeScript configuration → references/best-practices/structure.md#typescript-configuration
├─ ESLint configuration → references/best-practices/structure.md#eslint-configuration
├─ Dependency management → references/best-practices/dependencies.md
└─ Enforce package boundaries → references/boundaries/README.md
```
### "I want to enforce architectural boundaries"
```
Enforce boundaries?
├─ Check for violations → turbo boundaries
├─ Tag packages → references/boundaries/README.md#tags
├─ Restrict which packages can import others → references/boundaries/README.md#rule-types
└─ Prevent cross-package file imports → references/boundaries/README.md
```
## Critical Anti-Patterns
### Using `turbo` Shorthand in Code
**`turbo run` is recommended in package.json scripts and CI pipelines.** The shorthand `turbo <task>` is intended for interactive terminal use.
```json
// WRONG - using shorthand in package.json
{
"scripts": {
"build": "turbo build",
"dev": "turbo dev"
}
}
// CORRECT
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
```yaml
# WRONG - using shorthand in CI
- run: turbo build --affected
# CORRECT
- run: turbo run build --affected
```
### Root Scripts Bypassing Turbo
Root `package.json` scripts MUST delegate to `turbo run`, not run tasks directly.
```json
// WRONG - bypasses turbo entirely
{
"scripts": {
"build": "bun build",
"dev": "bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
### Using `&&` to Chain Turbo Tasks
Don't chain turbo tasks with `&&`. Let turbo orchestrate.
```json
// WRONG - turbo task not using turbo run
{
"scripts": {
"changeset:publish": "bun build && changeset publish"
}
}
// CORRECT
{
"scripts": {
"changeset:publish": "turbo run build && changeset publish"
}
}
```
### `prebuild` Scripts That Manually Build Dependencies
Scripts like `prebuild` that manually build other packages bypass Turborepo's dependency graph.
```json
// WRONG - manually building dependencies
{
"scripts": {
"prebuild": "cd ../../packages/types && bun run build && cd ../utils && bun run build",
"build": "next build"
}
}
```
**However, the fix depends on whether workspace dependencies are declared:**
1. **If dependencies ARE declared** (e.g., `"@repo/types": "workspace:*"` in package.json), remove the `prebuild` script. Turbo's `dependsOn: ["^build"]` handles this automatically.
2. **If dependencies are NOT declared**, the `prebuild` exists because `^build` won't trigger without a dependency relationship. The fix is to:
- Add the dependency to package.json: `"@repo/types": "workspace:*"`
- Then remove the `prebuild` script
```json
// CORRECT - declare dependency, let turbo handle build order
// package.json
{
"dependencies": {
"@repo/types": "workspace:*",
"@repo/utils": "workspace:*"
},
"scripts": {
"build": "next build"
}
}
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
```
**Key insight:** `^build` only runs build in packages listed as dependencies. No dependency declaration = no automatic build ordering.
### Overly Broad `globalDependencies`
`globalDependencies` affects ALL tasks in ALL packages. Be specific.
```json
// WRONG - heavy hammer, affects all hashes
{
"globalDependencies": ["**/.env.*local"]
}
// BETTER - move to task-level inputs
{
"globalDependencies": [".env"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
```
### Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed. Turborepo supports shared configuration patterns.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks": {
"build": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"test": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"dev": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"cache": false,
"persistent": true
}
}
}
// BETTER - use globalEnv and globalDependencies for shared config
{
"globalEnv": ["API_URL", "DATABASE_URL"],
"globalDependencies": [".env*"],
"tasks": {
"build": {},
"test": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
**When to use global vs task-level:**
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
### NOT an Anti-Pattern: Large `env` Arrays
A large `env` array (even 50+ variables) is **not** a problem. It usually means the user was thorough about declaring their build's environment dependencies. Do not flag this as an issue.
### Using `--parallel` Flag
The `--parallel` flag bypasses Turborepo's dependency graph. If tasks need parallel execution, configure `dependsOn` correctly instead.
```bash
# WRONG - bypasses dependency graph
turbo run lint --parallel
# CORRECT - configure tasks to allow parallel execution
# In turbo.json, set dependsOn appropriately (or use transit nodes)
turbo run lint
```
### Package-Specific Task Overrides in Root turbo.json
When multiple packages need different task configurations, use **Package Configurations** (`turbo.json` in each package) instead of cluttering root `turbo.json` with `package#task` overrides.
```json
// WRONG - root turbo.json with many package-specific overrides
{
"tasks": {
"test": { "dependsOn": ["build"] },
"@repo/web#test": { "outputs": ["coverage/**"] },
"@repo/api#test": { "outputs": ["coverage/**"] },
"@repo/utils#test": { "outputs": [] },
"@repo/cli#test": { "outputs": [] },
"@repo/core#test": { "outputs": [] }
}
}
// CORRECT - use Package Configurations
// Root turbo.json - base config only
{
"tasks": {
"test": { "dependsOn": ["build"] }
}
}
// packages/web/turbo.json - package-specific override
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
// packages/api/turbo.json
{
"extends": ["//"],
"tasks": {
"test": { "outputs": ["coverage/**"] }
}
}
```
**Benefits of Package Configurations:**
- Keeps configuration close to the code it affects
- Root turbo.json stays clean and focused on base patterns
- Easier to understand what's special about each package
- Works with `$TURBO_EXTENDS$` to inherit + extend arrays
**When to use `package#task` in root:**
- Single package needs a unique dependency (e.g., `"deploy": { "dependsOn": ["web#build"] }`)
- Temporary override while migrating
See `references/configuration/README.md#package-configurations` for full details.
### Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
```json
// WRONG - traversing out of package
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
}
}
}
// CORRECT - use $TURBO_ROOT$ for repo root
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
}
}
}
```
### Missing `outputs` for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG: build produces files but they're not cached
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
// CORRECT: build outputs are cached
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
```
Common outputs by framework:
- Next.js: `[".next/**", "!.next/cache/**"]`
- Vite/Rollup: `["dist/**"]`
- tsc: `["dist/**"]` or custom `outDir`
**TypeScript `--noEmit` can still produce cache files:**
When `incremental: true` in tsconfig.json, `tsc --noEmit` writes `.tsbuildinfo` files even without emitting JS. Check the tsconfig before assuming no outputs:
```json
// If tsconfig has incremental: true, tsc --noEmit produces cache files
{
"tasks": {
"typecheck": {
"outputs": ["node_modules/.cache/tsbuildinfo.json"] // or wherever tsBuildInfoFile points
}
}
}
```
To determine correct outputs for TypeScript tasks:
1. Check if `incremental` or `composite` is enabled in tsconfig
2. Check `tsBuildInfoFile` for custom cache location (default: alongside `outDir` or in project root)
3. If no incremental mode, `tsc --noEmit` produces no files
### `^build` vs `build` Confusion
```json
{
"tasks": {
// ^build = run build in DEPENDENCIES first (other packages this one imports)
"build": {
"dependsOn": ["^build"]
},
// build (no ^) = run build in SAME PACKAGE first
"test": {
"dependsOn": ["build"]
},
// pkg#task = specific package's task
"deploy": {
"dependsOn": ["web#build"]
}
}
}
```
### Environment Variables Not Hashed
```json
// WRONG: API_URL changes won't cause rebuilds
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}
// CORRECT: API_URL changes invalidate cache
{
"tasks": {
"build": {
"outputs": ["dist/**"],
"env": ["API_URL", "API_KEY"]
}
}
}
```
### `.env` Files Not in Inputs
Turbo does NOT load `.env` files - your framework does. But Turbo needs to know about changes:
```json
// WRONG: .env changes don't invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
// CORRECT: .env file changes invalidate cache
{
"tasks": {
"build": {
"env": ["API_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.*"]
}
}
}
```
### Root `.env` File in Monorepo
A `.env` file at the repo root is an anti-pattern — even for small monorepos or starter templates. It creates implicit coupling between packages and makes it unclear which packages depend on which variables.
```
// WRONG - root .env affects all packages implicitly
my-monorepo/
├── .env # Which packages use this?
├── apps/
│ ├── web/
│ └── api/
└── packages/
// CORRECT - .env files in packages that need them
my-monorepo/
├── apps/
│ ├── web/
│ │ └── .env # Clear: web needs DATABASE_URL
│ └── api/
│ └── .env # Clear: api needs API_KEY
└── packages/
```
**Problems with root `.env`:**
- Unclear which packages consume which variables
- All packages get all variables (even ones they don't need)
- Cache invalidation is coarse-grained (root .env change invalidates everything)
- Security risk: packages may accidentally access sensitive vars meant for others
- Bad habits start small — starter templates should model correct patterns
**If you must share variables**, use `globalEnv` to be explicit about what's shared, and document why.
### Strict Mode Filtering CI Variables
By default, Turborepo filters environment variables to only those in `env`/`globalEnv`. CI variables may be missing:
```json
// If CI scripts need GITHUB_TOKEN but it's not in env:
{
"globalPassThroughEnv": ["GITHUB_TOKEN", "CI"],
"tasks": { ... }
}
```
Or use `--env-mode=loose` (not recommended for production).
### Shared Code in Apps (Should Be a Package)
```
// WRONG: Shared code inside an app
apps/
web/
shared/ # This breaks monorepo principles!
utils.ts
// CORRECT: Extract to a package
packages/
utils/
src/utils.ts
```
### Accessing Files Across Package Boundaries
```typescript
// WRONG: Reaching into another package's internals
import { Button } from "../../packages/ui/src/button";
// CORRECT: Install and import properly
import { Button } from "@repo/ui/button";
```
### Too Many Root Dependencies
```json
// WRONG: App dependencies in root
{
"dependencies": {
"react": "^18",
"next": "^14"
}
}
// CORRECT: Only repo tools in root
{
"devDependencies": {
"turbo": "latest"
}
}
```
## Common Task Configurations
### Standard Build Pipeline
```json
{
"$schema": "https://turborepo.dev/schema.v2.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
Add a `transit` task if you have tasks that need parallel execution with cache invalidation (see below).
### Dev Task with `^dev` Pattern (for `turbo watch`)
A `dev` task with `dependsOn: ["^dev"]` and `persistent: false` in root turbo.json may look unusual but is **correct for `turbo watch` workflows**:
```json
// Root turbo.json
{
"tasks": {
"dev": {
"dependsOn": ["^dev"],
"cache": false,
"persistent": false // Packages have one-shot dev scripts
}
}
}
// Package turbo.json (apps/web/turbo.json)
{
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true // Apps run long-running dev servers
}
}
}
```
**Why this works:**
- **Packages** (e.g., `@acme/db`, `@acme/validators`) have `"dev": "tsc"` — one-shot type generation that completes quickly
- **Apps** override with `persistent: true` for actual dev servers (Next.js, etc.)
- **`turbo watch`** re-runs the one-shot package `dev` scripts when source files change, keeping types in sync
**Intended usage:** Run `turbo watch dev` (not `turbo run dev`). Watch mode re-executes one-shot tasks on file changes while keeping persistent tasks running.
**Alternative pattern:** Use a separate task name like `prepare` or `generate` for one-shot dependency builds to make the intent clearer:
```json
{
"tasks": {
"prepare": {
"dependsOn": ["^prepare"],
"outputs": ["dist/**"]
},
"dev": {
"dependsOn": ["prepare"],
"cache": false,
"persistent": true
}
}
}
```
### Transit Nodes for Parallel Tasks with Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must invalidate cache when dependency source code changes.
**The problem with `dependsOn: ["^taskname"]`:**
- Forces sequential execution (slow)
**The problem with `dependsOn: []` (no dependencies):**
- Allows parallel execution (fast)
- But cache is INCORRECT - changing dependency source won't invalidate cache
**Transit Nodes solve both:**
```json
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"my-task": { "dependsOn": ["transit"] }
}
}
```
The `transit` task creates dependency relationships without matching any actual script, so tasks run in parallel with correct cache invalidation.
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
### With Environment Variables
```json
{
"globalEnv": ["NODE_ENV"],
"globalDependencies": [".env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "DATABASE_URL"]
}
}
}
```
## Reference Index
### Configuration
| File | Purpose |
| ------------------------------------------------------------------------------- | -------------------------------------------------------- |
| [configuration/README.md](./references/configuration/README.md) | turbo.json overview, Package Configurations |
| [configuration/tasks.md](./references/configuration/tasks.md) | dependsOn, outputs, inputs, env, cache, persistent |
| [configuration/global-options.md](./references/configuration/global-options.md) | globalEnv, globalDependencies, cacheDir, daemon, envMode |
| [configuration/gotchas.md](./references/configuration/gotchas.md) | Common configuration mistakes |
### Caching
| File | Purpose |
| --------------------------------------------------------------- | -------------------------------------------- |
| [caching/README.md](./references/caching/README.md) | How caching works, hash inputs |
| [caching/remote-cache.md](./references/caching/remote-cache.md) | Vercel Remote Cache, self-hosted, login/link |
| [caching/gotchas.md](./references/caching/gotchas.md) | Debugging cache misses, --summarize, --dry |
### Environment Variables
| File | Purpose |
| ------------------------------------------------------------- | ----------------------------------------- |
| [environment/README.md](./references/environment/README.md) | env, globalEnv, passThroughEnv |
| [environment/modes.md](./references/environment/modes.md) | Strict vs Loose mode, framework inference |
| [environment/gotchas.md](./references/environment/gotchas.md) | .env files, CI issues |
### Filtering
| File | Purpose |
| ----------------------------------------------------------- | ------------------------ |
| [filtering/README.md](./references/filtering/README.md) | --filter syntax overview |
| [filtering/patterns.md](./references/filtering/patterns.md) | Common filter patterns |
### CI/CD
| File | Purpose |
| --------------------------------------------------------- | ------------------------------- |
| [ci/README.md](./references/ci/README.md) | General CI principles |
| [ci/github-actions.md](./references/ci/github-actions.md) | Complete GitHub Actions setup |
| [ci/vercel.md](./references/ci/vercel.md) | Vercel deployment, turbo-ignore |
| [ci/patterns.md](./references/ci/patterns.md) | --affected, caching strategies |
### CLI
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------- |
| [cli/README.md](./references/cli/README.md) | turbo run basics |
| [cli/commands.md](./references/cli/commands.md) | turbo run flags, turbo-ignore, other commands |
### Best Practices
| File | Purpose |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
| [best-practices/README.md](./references/best-practices/README.md) | Monorepo best practices overview |
| [best-practices/structure.md](./references/best-practices/structure.md) | Repository structure, workspace config, TypeScript/ESLint setup |
| [best-practices/packages.md](./references/best-practices/packages.md) | Creating internal packages, JIT vs Compiled, exports |
| [best-practices/dependencies.md](./references/best-practices/dependencies.md) | Dependency management, installing, version sync |
### Watch Mode
| File | Purpose |
| ----------------------------------------------- | ----------------------------------------------- |
| [watch/README.md](./references/watch/README.md) | turbo watch, interruptible tasks, dev workflows |
### Boundaries (Experimental)
| File | Purpose |
| --------------------------------------------------------- | ----------------------------------------------------- |
| [boundaries/README.md](./references/boundaries/README.md) | Enforce package isolation, tag-based dependency rules |
## Source Documentation
This skill is based on the official Turborepo documentation at:
- Source: `docs/site/content/docs/` in the Turborepo repository
- Live: https://turborepo.dev/docs

View file

@ -0,0 +1,70 @@
---
description: Load Turborepo skill for creating workflows, tasks, and pipelines in monorepos. Use when users ask to "create a workflow", "make a task", "generate a pipeline", or set up build orchestration.
---
Load the Turborepo skill and help with monorepo task orchestration: creating workflows, configuring tasks, setting up pipelines, and optimizing builds.
## Workflow
### Step 1: Load turborepo skill
```
skill({ name: 'turborepo' })
```
### Step 2: Identify task type from user request
Analyze $ARGUMENTS to determine:
- **Topic**: configuration, caching, filtering, environment, CI, or CLI
- **Task type**: new setup, debugging, optimization, or implementation
Use decision trees in SKILL.md to select the relevant reference files.
### Step 3: Read relevant reference files
Based on task type, read from `references/<topic>/`:
| Task | Files to Read |
| -------------------- | --------------------------------------------------------- |
| Configure turbo.json | `configuration/README.md` + `configuration/tasks.md` |
| Debug cache issues | `caching/gotchas.md` |
| Set up remote cache | `caching/remote-cache.md` |
| Filter packages | `filtering/README.md` + `filtering/patterns.md` |
| Environment problems | `environment/gotchas.md` + `environment/modes.md` |
| Set up CI | `ci/README.md` + `ci/github-actions.md` or `ci/vercel.md` |
| CLI usage | `cli/commands.md` |
### Step 4: Execute task
Apply Turborepo-specific patterns from references to complete the user's request.
**CRITICAL - When creating tasks/scripts/pipelines:**
1. **DO NOT create Root Tasks** - Always create package tasks
2. Add scripts to each relevant package's `package.json` (e.g., `apps/web/package.json`, `packages/ui/package.json`)
3. Register the task in root `turbo.json`
4. Root `package.json` only contains `turbo run <task>` - never actual task logic
**Other things to verify:**
- `outputs` defined for cacheable tasks
- `dependsOn` uses correct syntax (`^task` vs `task`)
- Environment variables in `env` key
- `.env` files in `inputs` if used
- Use `turbo run` (not `turbo`) in package.json and CI
### Step 5: Summarize
```
=== Turborepo Task Complete ===
Topic: <configuration|caching|filtering|environment|ci|cli>
Files referenced: <reference files consulted>
<brief summary of what was done>
```
<user-request>
$ARGUMENTS
</user-request>

View file

@ -0,0 +1,246 @@
# Dependency Management
Best practices for managing dependencies in a Turborepo monorepo.
## Core Principle: Install Where Used
Dependencies belong in the package that uses them, not the root.
```bash
# Good: Install in specific package
pnpm add react --filter=@repo/ui
pnpm add next --filter=web
# Avoid: Installing in root
pnpm add react -w # Only for repo-level tools!
```
## Benefits of Local Installation
### 1. Clarity
Each package's `package.json` lists exactly what it needs:
```json
// packages/ui/package.json
{
"dependencies": {
"react": "^18.0.0",
"class-variance-authority": "^0.7.0"
}
}
```
### 2. Flexibility
Different packages can use different versions when needed:
```json
// packages/legacy-ui/package.json
{ "dependencies": { "react": "^17.0.0" } }
// packages/ui/package.json
{ "dependencies": { "react": "^18.0.0" } }
```
### 3. Better Caching
Installing in root changes workspace lockfile, invalidating all caches.
### 4. Pruning Support
`turbo prune` can remove unused dependencies for Docker images.
## What Belongs in Root
Only repository-level tools:
```json
// Root package.json
{
"devDependencies": {
"turbo": "latest",
"husky": "^8.0.0",
"lint-staged": "^15.0.0"
}
}
```
**NOT** application dependencies:
- react, next, express
- lodash, axios, zod
- Testing libraries (unless truly repo-wide)
## Installing Dependencies
### Single Package
```bash
# pnpm
pnpm add lodash --filter=@repo/utils
# npm
npm install lodash --workspace=@repo/utils
# yarn
yarn workspace @repo/utils add lodash
# bun
cd packages/utils && bun add lodash
```
### Multiple Packages
```bash
# pnpm
pnpm add jest --save-dev --filter=web --filter=@repo/ui
# npm
npm install jest --save-dev --workspace=web --workspace=@repo/ui
# yarn (v2+)
yarn workspaces foreach -R --from '{web,@repo/ui}' add jest --dev
```
### Internal Packages
```bash
# pnpm
pnpm add @repo/ui --filter=web
# This updates package.json:
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
```
## Keeping Versions in Sync
### Option 1: Tooling
```bash
# syncpack - Check and fix version mismatches
npx syncpack list-mismatches
npx syncpack fix-mismatches
# manypkg - Similar functionality
npx @manypkg/cli check
npx @manypkg/cli fix
# sherif - Rust-based, very fast
npx sherif
```
### Option 2: Package Manager Commands
```bash
# pnpm - Update everywhere
pnpm up --recursive typescript@latest
# npm - Update in all workspaces
npm install typescript@latest --workspaces
```
### Option 3: pnpm Catalogs (pnpm 9.5+)
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^18.2.0
typescript: ^5.3.0
```
```json
// Any package.json
{
"dependencies": {
"react": "catalog:" // Uses version from catalog
}
}
```
## Internal vs External Dependencies
### Internal (Workspace)
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" }
// npm/yarn
{ "@repo/ui": "*" }
```
Turborepo understands these relationships and orders builds accordingly.
### External (npm Registry)
```json
{ "lodash": "^4.17.21" }
```
Standard semver versioning from npm.
## Peer Dependencies
For library packages that expect the consumer to provide dependencies:
```json
// packages/ui/package.json
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"react": "^18.0.0", // For development/testing
"react-dom": "^18.0.0"
}
}
```
## Common Issues
### "Module not found"
1. Check the dependency is installed in the right package
2. Run `pnpm install` / `npm install` to update lockfile
3. Check exports are defined in the package
### Version Conflicts
Packages can use different versions - this is a feature, not a bug. But if you need consistency:
1. Use tooling (syncpack, manypkg)
2. Use pnpm catalogs
3. Create a lint rule
### Hoisting Issues
Some tools expect dependencies in specific locations. Use package manager config:
```yaml
# .npmrc (pnpm)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
```
## Lockfile
**Required** for:
- Reproducible builds
- Turborepo dependency analysis
- Cache correctness
```bash
# Commit your lockfile!
git add pnpm-lock.yaml # or package-lock.json, yarn.lock
```

View file

@ -0,0 +1,335 @@
# Creating Internal Packages
How to create and structure internal packages in your monorepo.
## Package Creation Checklist
1. Create directory in `packages/`
2. Add `package.json` with name and exports
3. Add source code in `src/`
4. Add `tsconfig.json` if using TypeScript
5. Install as dependency in consuming packages
6. Run package manager install to update lockfile
## Package Compilation Strategies
### Just-in-Time (JIT)
Export TypeScript directly. The consuming app's bundler compiles it.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx"
},
"scripts": {
"lint": "eslint .",
"check-types": "tsc --noEmit"
}
}
```
**When to use:**
- Apps use modern bundlers (Turbopack, webpack, Vite)
- You want minimal configuration
- Build times are acceptable without caching
**Limitations:**
- No Turborepo cache for the package itself
- Consumer must support TypeScript compilation
- Can't use TypeScript `paths` (use Node.js subpath imports instead)
### Compiled
Package handles its own compilation.
```json
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
"./button": {
"types": "./src/button.tsx",
"default": "./dist/button.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
}
```
```json
// packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
**When to use:**
- You want Turborepo to cache builds
- Package will be used by non-bundler tools
- You need maximum compatibility
**Remember:** Add `dist/**` to turbo.json outputs!
## Defining Exports
### Multiple Entrypoints
```json
{
"exports": {
".": "./src/index.ts", // @repo/ui
"./button": "./src/button.tsx", // @repo/ui/button
"./card": "./src/card.tsx", // @repo/ui/card
"./hooks": "./src/hooks/index.ts" // @repo/ui/hooks
}
}
```
### Conditional Exports (Compiled)
```json
{
"exports": {
"./button": {
"types": "./src/button.tsx",
"import": "./dist/button.mjs",
"require": "./dist/button.cjs",
"default": "./dist/button.js"
}
}
}
```
## Installing Internal Packages
### Add to Consuming Package
```json
// apps/web/package.json
{
"dependencies": {
"@repo/ui": "workspace:*" // pnpm/bun
// "@repo/ui": "*" // npm/yarn
}
}
```
### Run Install
```bash
pnpm install # Updates lockfile with new dependency
```
### Import and Use
```typescript
// apps/web/src/page.tsx
import { Button } from '@repo/ui/button';
export default function Page() {
return <Button>Click me</Button>;
}
```
## One Purpose Per Package
### Good Examples
```
packages/
├── ui/ # Shared UI components
├── utils/ # General utilities
├── auth/ # Authentication logic
├── database/ # Database client/schemas
├── eslint-config/ # ESLint configuration
├── typescript-config/ # TypeScript configuration
└── api-client/ # Generated API client
```
### Avoid Mega-Packages
```
// BAD: One package for everything
packages/
└── shared/
├── components/
├── utils/
├── hooks/
├── types/
└── api/
// GOOD: Separate by purpose
packages/
├── ui/ # Components
├── utils/ # Utilities
├── hooks/ # React hooks
├── types/ # Shared TypeScript types
└── api-client/ # API utilities
```
## Config Packages
### TypeScript Config
```json
// packages/typescript-config/package.json
{
"name": "@repo/typescript-config",
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./library.json": "./library.json"
}
}
```
### ESLint Config
```json
// packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"exports": {
"./base": "./base.js",
"./next": "./next.js"
},
"dependencies": {
"eslint": "^8.0.0",
"eslint-config-next": "latest"
}
}
```
## Common Mistakes
### Forgetting to Export
```json
// BAD: No exports defined
{
"name": "@repo/ui"
}
// GOOD: Clear exports
{
"name": "@repo/ui",
"exports": {
"./button": "./src/button.tsx"
}
}
```
### Wrong Workspace Syntax
```json
// pnpm/bun
{ "@repo/ui": "workspace:*" } // Correct
// npm/yarn
{ "@repo/ui": "*" } // Correct
{ "@repo/ui": "workspace:*" } // Wrong for npm/yarn!
```
### Missing from turbo.json Outputs
```json
// Package builds to dist/, but turbo.json doesn't know
{
"tasks": {
"build": {
"outputs": [".next/**"] // Missing dist/**!
}
}
}
// Correct
{
"tasks": {
"build": {
"outputs": [".next/**", "dist/**"]
}
}
}
```
## TypeScript Best Practices
### Use Node.js Subpath Imports (Not `paths`)
TypeScript `compilerOptions.paths` breaks with JIT packages. Use Node.js subpath imports instead (TypeScript 5.4+).
**JIT Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./src/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.ts"; // Uses .ts extension
```
**Compiled Package:**
```json
// packages/ui/package.json
{
"imports": {
"#*": "./dist/*"
}
}
```
```typescript
// packages/ui/button.tsx
import { MY_STRING } from "#utils.js"; // Uses .js extension
```
### Use `tsc` for Internal Packages
For internal packages, prefer `tsc` over bundlers. Bundlers can mangle code before it reaches your app's bundler, causing hard-to-debug issues.
### Enable Go-to-Definition
For Compiled Packages, enable declaration maps:
```json
// tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true
}
}
```
This creates `.d.ts` and `.d.ts.map` files for IDE navigation.
### No Root tsconfig.json Needed
Each package should have its own `tsconfig.json`. A root one causes all tasks to miss cache when changed. Only use root `tsconfig.json` for non-package scripts.
### Avoid TypeScript Project References
They add complexity and another caching layer. Turborepo handles dependencies better.

View file

@ -0,0 +1,269 @@
# Repository Structure
Detailed guidance on structuring a Turborepo monorepo.
## Workspace Configuration
### pnpm (Recommended)
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
```
### npm/yarn/bun
```json
// package.json
{
"workspaces": ["apps/*", "packages/*"]
}
```
## Root package.json
```json
{
"name": "my-monorepo",
"private": true,
"packageManager": "pnpm@9.0.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "latest"
}
}
```
Key points:
- `private: true` - Prevents accidental publishing
- `packageManager` - Enforces consistent package manager version
- **Scripts only delegate to `turbo run`** - No actual build logic here!
- Minimal devDependencies (just turbo and repo tools)
## Always Prefer Package Tasks
**Always use package tasks. Only use Root Tasks if you cannot succeed with package tasks.**
```json
// packages/web/package.json
{
"scripts": {
"build": "next build",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
// packages/api/package.json
{
"scripts": {
"build": "tsc",
"lint": "eslint .",
"test": "vitest",
"typecheck": "tsc --noEmit"
}
}
```
Package tasks enable Turborepo to:
1. **Parallelize** - Run `web#lint` and `api#lint` simultaneously
2. **Cache individually** - Each package's task output is cached separately
3. **Filter precisely** - Run `turbo run test --filter=web` for just one package
**Root Tasks are a fallback** for tasks that truly cannot run per-package:
```json
// AVOID unless necessary - sequential, not parallelized, can't filter
{
"scripts": {
"lint": "eslint apps/web && eslint apps/api && eslint packages/ui"
}
}
```
## Root turbo.json
```json
{
"$schema": "https://turborepo.dev/schema.v2.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
```
## Directory Organization
### Grouping Packages
You can group packages by adding more workspace paths:
```yaml
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "packages/config/*" # Grouped configs
- "packages/features/*" # Feature packages
```
This allows:
```
packages/
├── ui/
├── utils/
├── config/
│ ├── eslint/
│ ├── typescript/
│ └── tailwind/
└── features/
├── auth/
└── payments/
```
### What NOT to Do
```yaml
# BAD: Nested wildcards cause ambiguous behavior
packages:
- "packages/**" # Don't do this!
```
## Package Anatomy
### Minimum Required Files
```
packages/ui/
├── package.json # Required: Makes it a package
├── src/ # Source code
│ └── button.tsx
└── tsconfig.json # TypeScript config (if using TS)
```
### package.json Requirements
```json
{
"name": "@repo/ui", // Unique, namespaced name
"version": "0.0.0", // Version (can be 0.0.0 for internal)
"private": true, // Prevents accidental publishing
"exports": { // Entry points
"./button": "./src/button.tsx"
}
}
```
## TypeScript Configuration
### Shared Base Config
Create a shared TypeScript config package:
```
packages/
└── typescript-config/
├── package.json
├── base.json
├── nextjs.json
└── library.json
```
```json
// packages/typescript-config/base.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022"
}
}
```
### Extending in Packages
```json
// packages/ui/tsconfig.json
{
"extends": "@repo/typescript-config/library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
### No Root tsconfig.json
You likely don't need a `tsconfig.json` in the workspace root. Each package should have its own config extending from the shared config package.
## ESLint Configuration
### Shared Config Package
```
packages/
└── eslint-config/
├── package.json
├── base.js
├── next.js
└── library.js
```
```json
// packages/eslint-config/package.json
{
"name": "@repo/eslint-config",
"exports": {
"./base": "./base.js",
"./next": "./next.js",
"./library": "./library.js"
}
}
```
### Using in Packages
```js
// apps/web/.eslintrc.js
module.exports = {
extends: ["@repo/eslint-config/next"],
};
```
## Lockfile
A lockfile is **required** for:
- Reproducible builds
- Turborepo to understand package dependencies
- Cache correctness
Without a lockfile, you'll see unpredictable behavior.

View file

@ -0,0 +1,169 @@
# Debugging Cache Issues
## Diagnostic Tools
### `--summarize`
Generates a JSON file with all hash inputs. Compare two runs to find differences.
```bash
turbo build --summarize
# Creates .turbo/runs/<run-id>.json
```
The summary includes:
- Global hash and its inputs
- Per-task hashes and their inputs
- Environment variables that affected the hash
**Comparing runs:**
```bash
# Run twice, compare the summaries
diff .turbo/runs/<first-run>.json .turbo/runs/<second-run>.json
```
### `--dry` / `--dry=json`
See what would run without executing anything:
```bash
turbo build --dry
turbo build --dry=json # machine-readable output
```
Shows cache status for each task without running them.
### `--force`
Skip reading cache, re-execute all tasks:
```bash
turbo build --force
```
Useful to verify tasks actually work (not just cached results).
## Unexpected Cache Misses
**Symptom:** Task runs when you expected a cache hit.
### Environment Variable Changed
Check if an env var in the `env` key changed:
```json
{
"tasks": {
"build": {
"env": ["API_URL", "NODE_ENV"]
}
}
}
```
Different `API_URL` between runs = cache miss.
### .env File Changed
`.env` files aren't tracked by default. Add to `inputs`:
```json
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local"]
}
}
}
```
Or use `globalDependencies` for repo-wide env files:
```json
{
"globalDependencies": [".env"]
}
```
### Lockfile Changed
Installing/updating packages changes the global hash.
### Source Files Changed
Any file in the package (or in `inputs`) triggers a miss.
### turbo.json Changed
Config changes invalidate the global hash.
## Incorrect Cache Hits
**Symptom:** Cached output is stale/wrong.
### Missing Environment Variable
Task uses an env var not listed in `env`:
```javascript
// build.js
const apiUrl = process.env.API_URL; // not tracked!
```
Fix: add to task config:
```json
{
"tasks": {
"build": {
"env": ["API_URL"]
}
}
}
```
### Missing File in Inputs
Task reads a file outside default inputs:
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
"../../shared-config.json" // file outside package
]
}
}
}
```
## Useful Flags
```bash
# Only show output for cache misses
turbo build --output-logs=new-only
# Show output for everything (debugging)
turbo build --output-logs=full
# See why tasks are running
turbo build --verbosity=2
```
## Quick Checklist
Cache miss when expected hit:
1. Run with `--summarize`, compare with previous run
2. Check env vars with `--dry=json`
3. Look for lockfile/config changes in git
Cache hit when expected miss:
1. Verify env var is in `env` array
2. Verify file is in `inputs` array
3. Check if file is outside package directory

View file

@ -0,0 +1,127 @@
# Remote Caching
Share cache artifacts across your team and CI pipelines.
## Benefits
- Team members get cache hits from each other's work
- CI gets cache hits from local development (and vice versa)
- Dramatically faster CI runs after first build
- No more "works on my machine" rebuilds
## Vercel Remote Cache
Free, zero-config when deploying on Vercel. For local dev and other CI:
### Local Development Setup
```bash
# Authenticate with Vercel
npx turbo login
# Link repo to your Vercel team
npx turbo link
```
This creates `.turbo/config.json` with your team info (gitignored by default).
### CI Setup
Set these environment variables:
```bash
TURBO_TOKEN=<your-token>
TURBO_TEAM=<your-team-slug>
```
Get your token from Vercel dashboard → Settings → Tokens.
**GitHub Actions example:**
```yaml
- name: Build
run: npx turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
## Configuration in turbo.json
```json
{
"remoteCache": {
"enabled": true,
"signature": false
}
}
```
Options:
- `enabled`: toggle remote cache (default: true when authenticated)
- `signature`: require artifact signing (default: false)
## Artifact Signing
Verify cache artifacts haven't been tampered with:
```bash
# Set a secret key (use same key across all environments)
export TURBO_REMOTE_CACHE_SIGNATURE_KEY="your-secret-key"
```
Enable in config:
```json
{
"remoteCache": {
"signature": true
}
}
```
Signed artifacts can only be restored if the signature matches.
## Self-Hosted Options
Community implementations for running your own cache server:
- **turbo-remote-cache** (Node.js) - supports S3, GCS, Azure
- **turborepo-remote-cache** (Go) - lightweight, S3-compatible
- **ducktape** (Rust) - high-performance option
Configure with environment variables:
```bash
TURBO_API=https://your-cache-server.com
TURBO_TOKEN=your-auth-token
TURBO_TEAM=your-team
```
## Cache Behavior Control
```bash
# Disable remote cache for a run
turbo build --remote-cache-read-only # read but don't write
turbo build --no-cache # skip cache entirely
# Environment variable alternative
TURBO_REMOTE_ONLY=true # only use remote, skip local
```
## Debugging Remote Cache
```bash
# Verbose output shows cache operations
turbo build --verbosity=2
# Check if remote cache is configured
turbo config
```
Look for:
- "Remote caching enabled" in output
- Upload/download messages during runs
- "cache hit, replaying output" with remote cache indicator

View file

@ -0,0 +1,162 @@
# GitHub Actions
Complete setup guide for Turborepo with GitHub Actions.
## Basic Workflow Structure
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build and Test
run: turbo run build test lint
```
## Package Manager Setup
### pnpm
```yaml
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
```
### Yarn
```yaml
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- run: yarn install --frozen-lockfile
```
### Bun
```yaml
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- run: bun install --frozen-lockfile
```
## Remote Cache Setup
### 1. Create Vercel Access Token
1. Go to [Vercel Dashboard](https://vercel.com/account/tokens)
2. Create a new token with appropriate scope
3. Copy the token value
### 2. Add Secrets and Variables
In your GitHub repository settings:
**Secrets** (Settings > Secrets and variables > Actions > Secrets):
- `TURBO_TOKEN`: Your Vercel access token
**Variables** (Settings > Secrets and variables > Actions > Variables):
- `TURBO_TEAM`: Your Vercel team slug
### 3. Add to Workflow
```yaml
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
## Alternative: actions/cache
If you can't use remote cache, cache Turborepo's local cache directory:
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}
restore-keys: |
turbo-${{ runner.os }}-
```
Note: This is less effective than remote cache since it's per-branch.
## Complete Example
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: turbo run build --affected
- name: Test
run: turbo run test --affected
- name: Lint
run: turbo run lint --affected
```

View file

@ -0,0 +1,145 @@
# CI Optimization Patterns
Strategies for efficient CI/CD with Turborepo.
## PR vs Main Branch Builds
### PR Builds: Only Affected
Test only what changed in the PR:
```yaml
- name: Test (PR)
if: github.event_name == 'pull_request'
run: turbo run build test --affected
```
### Main Branch: Full Build
Ensure complete validation on merge:
```yaml
- name: Test (Main)
if: github.ref == 'refs/heads/main'
run: turbo run build test
```
## Custom Git Ranges with --filter
For advanced scenarios, use `--filter` with git refs:
```bash
# Changes since specific commit
turbo run test --filter="...[abc123]"
# Changes between refs
turbo run test --filter="...[main...HEAD]"
# Changes in last 3 commits
turbo run test --filter="...[HEAD~3]"
```
## Caching Strategies
### Remote Cache (Recommended)
Best performance - shared across all CI runs and developers:
```yaml
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
```
### actions/cache Fallback
When remote cache isn't available:
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ github.ref }}-
turbo-${{ runner.os }}-
```
Limitations:
- Cache is branch-scoped
- PRs restore from base branch cache
- Less efficient than remote cache
## Matrix Builds
Test across Node versions:
```yaml
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: turbo run test
```
## Parallelizing Across Jobs
Split tasks into separate jobs:
```yaml
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: turbo run lint --affected
test:
runs-on: ubuntu-latest
steps:
- run: turbo run test --affected
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- run: turbo run build
```
### Cache Considerations
When parallelizing:
- Each job has separate cache writes
- Remote cache handles this automatically
- With actions/cache, use unique keys per job to avoid conflicts
```yaml
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.job }}-${{ github.sha }}
```
## Conditional Tasks
Skip expensive tasks on draft PRs:
```yaml
- name: E2E Tests
if: github.event.pull_request.draft == false
run: turbo run test:e2e --affected
```
Or require label for full test:
```yaml
- name: Full Test Suite
if: contains(github.event.pull_request.labels.*.name, 'full-test')
run: turbo run test
```

View file

@ -0,0 +1,103 @@
# Vercel Deployment
Turborepo integrates seamlessly with Vercel for monorepo deployments.
## Remote Cache
Remote caching is **automatically enabled** when deploying to Vercel. No configuration needed - Vercel detects Turborepo and enables caching.
This means:
- No `TURBO_TOKEN` or `TURBO_TEAM` setup required on Vercel
- Cache is shared across all deployments
- Preview and production builds benefit from cache
## turbo-ignore
Skip unnecessary builds when a package hasn't changed using `turbo-ignore`.
### Installation
```bash
npx turbo-ignore
```
Or install globally in your project:
```bash
pnpm add -D turbo-ignore
```
### Setup in Vercel
1. Go to your project in Vercel Dashboard
2. Navigate to Settings > Git > Ignored Build Step
3. Select "Custom" and enter:
```bash
npx turbo-ignore
```
### How It Works
`turbo-ignore` checks if the current package (or its dependencies) changed since the last successful deployment:
1. Compares current commit to last deployed commit
2. Uses Turborepo's dependency graph
3. Returns exit code 0 (skip) if no changes
4. Returns exit code 1 (build) if changes detected
### Options
```bash
# Check specific package
npx turbo-ignore web
# Use specific comparison ref
npx turbo-ignore --fallback=HEAD~1
# Verbose output
npx turbo-ignore --verbose
```
## Environment Variables
Set environment variables in Vercel Dashboard:
1. Go to Project Settings > Environment Variables
2. Add variables for each environment (Production, Preview, Development)
Common variables:
- `DATABASE_URL`
- `API_KEY`
- Package-specific config
## Monorepo Root Directory
For monorepos, set the root directory in Vercel:
1. Project Settings > General > Root Directory
2. Set to the package path (e.g., `apps/web`)
Vercel automatically:
- Installs dependencies from monorepo root
- Runs build from the package directory
- Detects framework settings
## Build Command
Vercel auto-detects `turbo run build` when `turbo.json` exists at root.
Override if needed:
```bash
turbo run build --filter=web
```
Or for production-only optimizations:
```bash
turbo run build --filter=web --env-mode=strict
```

View file

@ -0,0 +1,297 @@
# turbo run Flags Reference
Full docs: https://turborepo.dev/docs/reference/run
## Package Selection
### `--filter` / `-F`
Select specific packages to run tasks in.
```bash
turbo build --filter=web
turbo build -F=@repo/ui -F=@repo/utils
turbo test --filter=./apps/*
```
See `filtering/` for complete syntax (globs, dependencies, git ranges).
### Task Identifier Syntax (v2.2.4+)
Run specific package tasks directly:
```bash
turbo run web#build # Build web package
turbo run web#build docs#lint # Multiple specific tasks
```
### `--affected`
Run only in packages changed since the base branch.
```bash
turbo build --affected
turbo test --affected --filter=./apps/* # combine with filter
```
**How it works:**
- Default: compares `main...HEAD`
- In GitHub Actions: auto-detects `GITHUB_BASE_REF`
- Override base: `TURBO_SCM_BASE=development turbo build --affected`
- Override head: `TURBO_SCM_HEAD=your-branch turbo build --affected`
**Requires git history** - shallow clones may fall back to running all tasks.
## Execution Control
### `--dry` / `--dry=json`
Preview what would run without executing.
```bash
turbo build --dry # human-readable
turbo build --dry=json # machine-readable
```
### `--force`
Ignore all cached artifacts, re-run everything.
```bash
turbo build --force
```
### `--concurrency`
Limit parallel task execution.
```bash
turbo build --concurrency=4 # max 4 tasks
turbo build --concurrency=50% # 50% of CPU cores
```
### `--continue`
Keep running other tasks when one fails.
```bash
turbo build test --continue
```
### `--only`
Run only the specified task, skip its dependencies.
```bash
turbo build --only # skip running dependsOn tasks
```
### `--parallel` (Discouraged)
Ignores task graph dependencies, runs all tasks simultaneously. **Avoid using this flag**—if tasks need to run in parallel, configure `dependsOn` correctly instead. Using `--parallel` bypasses Turborepo's dependency graph, which can cause race conditions and incorrect builds.
## Cache Control
### `--cache`
Fine-grained cache behavior control.
```bash
# Default: read/write both local and remote
turbo build --cache=local:rw,remote:rw
# Read-only local, no remote
turbo build --cache=local:r,remote:
# Disable local, read-only remote
turbo build --cache=local:,remote:r
# Disable all caching
turbo build --cache=local:,remote:
```
## Output & Debugging
### `--graph`
Generate task graph visualization.
```bash
turbo build --graph # opens in browser
turbo build --graph=graph.svg # SVG file
turbo build --graph=graph.png # PNG file
turbo build --graph=graph.json # JSON data
turbo build --graph=graph.mermaid # Mermaid diagram
```
### `--summarize`
Generate JSON run summary for debugging.
```bash
turbo build --summarize
# creates .turbo/runs/<run-id>.json
```
### `--output-logs`
Control log output verbosity.
```bash
turbo build --output-logs=full # all logs (default)
turbo build --output-logs=new-only # only cache misses
turbo build --output-logs=errors-only # only failures
turbo build --output-logs=none # silent
```
### `--profile`
Generate Chrome tracing profile for performance analysis.
```bash
turbo build --profile=profile.json
# open chrome://tracing and load the file
```
### `--verbosity` / `-v`
Control turbo's own log level.
```bash
turbo build -v # verbose
turbo build -vv # more verbose
turbo build -vvv # maximum verbosity
```
## Environment
### `--env-mode`
Control environment variable handling.
```bash
turbo build --env-mode=strict # only declared env vars (default)
turbo build --env-mode=loose # include all env vars in hash
```
## UI
### `--ui`
Select output interface.
```bash
turbo build --ui=tui # interactive terminal UI (default in TTY)
turbo build --ui=stream # streaming logs (default in CI)
```
---
# turbo-ignore
Full docs: https://turborepo.dev/docs/reference/turbo-ignore
Skip CI work when nothing relevant changed. Useful for skipping container setup.
## Basic Usage
```bash
# Check if build is needed for current package (uses Automatic Package Scoping)
npx turbo-ignore
# Check specific package
npx turbo-ignore web
# Check specific task
npx turbo-ignore --task=test
```
## Exit Codes
- `0`: No changes detected - skip CI work
- `1`: Changes detected - proceed with CI
## CI Integration Example
```yaml
# GitHub Actions
- name: Check for changes
id: turbo-ignore
run: npx turbo-ignore web
continue-on-error: true
- name: Build
if: steps.turbo-ignore.outcome == 'failure' # changes detected
run: pnpm build
```
## Comparison Depth
Default: compares to parent commit (`HEAD^1`).
```bash
# Compare to specific commit
npx turbo-ignore --fallback=abc123
# Compare to branch
npx turbo-ignore --fallback=main
```
---
# Other Commands
## turbo boundaries
Check workspace violations (experimental).
```bash
turbo boundaries
```
See `references/boundaries/` for configuration.
## turbo watch
Re-run tasks on file changes.
```bash
turbo watch build test
```
See `references/watch/` for details.
## turbo prune
Create sparse checkout for Docker.
```bash
turbo prune web --docker
```
## turbo link / unlink
Connect/disconnect Remote Cache.
```bash
turbo link # connect to Vercel Remote Cache
turbo unlink # disconnect
```
## turbo login / logout
Authenticate with Remote Cache provider.
```bash
turbo login # authenticate
turbo logout # log out
```
## turbo generate
Scaffold new packages.
```bash
turbo generate
```

View file

@ -0,0 +1,195 @@
# Global Options Reference
Options that affect all tasks. Full docs: https://turborepo.dev/docs/reference/configuration
## globalEnv
Environment variables affecting all task hashes.
```json
{
"globalEnv": ["CI", "NODE_ENV", "VERCEL_*"]
}
```
Use for variables that should invalidate all caches when changed.
## globalDependencies
Files that affect all task hashes.
```json
{
"globalDependencies": [
"tsconfig.json",
".env",
"pnpm-lock.yaml"
]
}
```
Lockfile is included by default. Add shared configs here.
## globalPassThroughEnv
Variables available to tasks but not included in hash.
```json
{
"globalPassThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
}
```
Use for credentials that shouldn't affect cache keys.
## cacheDir
Custom cache location. Default: `node_modules/.cache/turbo`.
```json
{
"cacheDir": ".turbo/cache"
}
```
## daemon
Background process for faster subsequent runs. Default: `true`.
```json
{
"daemon": false
}
```
Disable in CI or when debugging.
## envMode
How unspecified env vars are handled. Default: `"strict"`.
```json
{
"envMode": "strict" // Only specified vars available
// or
"envMode": "loose" // All vars pass through
}
```
Strict mode catches missing env declarations.
## ui
Terminal UI mode. Default: `"stream"`.
```json
{
"ui": "tui" // Interactive terminal UI
// or
"ui": "stream" // Traditional streaming logs
}
```
TUI provides better UX for parallel tasks.
## remoteCache
Configure remote caching.
```json
{
"remoteCache": {
"enabled": true,
"signature": true,
"timeout": 30,
"uploadTimeout": 60
}
}
```
| Option | Default | Description |
| --------------- | ---------------------- | ------------------------------------------------------ |
| `enabled` | `true` | Enable/disable remote caching |
| `signature` | `false` | Sign artifacts with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` |
| `preflight` | `false` | Send OPTIONS request before cache requests |
| `timeout` | `30` | Timeout in seconds for cache operations |
| `uploadTimeout` | `60` | Timeout in seconds for uploads |
| `apiUrl` | `"https://vercel.com"` | Remote cache API endpoint |
| `loginUrl` | `"https://vercel.com"` | Login endpoint |
| `teamId` | - | Team ID (must start with `team_`) |
| `teamSlug` | - | Team slug for querystring |
See https://turborepo.dev/docs/core-concepts/remote-caching for setup.
## concurrency
Default: `"10"`
Limit parallel task execution.
```json
{
"concurrency": "4" // Max 4 tasks at once
// or
"concurrency": "50%" // 50% of available CPUs
}
```
## futureFlags
Enable experimental features that will become default in future versions.
```json
{
"futureFlags": {
"errorsOnlyShowHash": true
}
}
```
### `errorsOnlyShowHash`
When using `outputLogs: "errors-only"`, show task hashes on start/completion:
- Cache miss: `cache miss, executing <hash> (only logging errors)`
- Cache hit: `cache hit, replaying logs (no errors) <hash>`
## noUpdateNotifier
Disable update notifications when new turbo versions are available.
```json
{
"noUpdateNotifier": true
}
```
## dangerouslyDisablePackageManagerCheck
Bypass the `packageManager` field requirement. Use for incremental migration.
```json
{
"dangerouslyDisablePackageManagerCheck": true
}
```
**Warning**: Unstable lockfiles can cause unpredictable behavior.
## Git Worktree Cache Sharing (Pre-release)
When working in Git worktrees, Turborepo automatically shares local cache between the main worktree and linked worktrees.
**How it works:**
- Detects worktree configuration
- Redirects cache to main worktree's `.turbo/cache`
- Works alongside Remote Cache
**Benefits:**
- Cache hits across branches
- Reduced disk usage
- Faster branch switching
**Disabled by**: Setting explicit `cacheDir` in turbo.json.

View file

@ -0,0 +1,348 @@
# Configuration Gotchas
Common mistakes and how to fix them.
## #1 Root Scripts Not Using `turbo run`
Root `package.json` scripts for turbo tasks MUST use `turbo run`, not direct commands.
```json
// WRONG - bypasses turbo, no parallelization or caching
{
"scripts": {
"build": "bun build",
"dev": "bun dev"
}
}
// CORRECT - delegates to turbo
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev"
}
}
```
**Why this matters:** Running `bun build` or `npm run build` at root bypasses Turborepo entirely - no parallelization, no caching, no dependency graph awareness.
## #2 Using `&&` to Chain Turbo Tasks
Don't use `&&` to chain tasks that turbo should orchestrate.
```json
// WRONG - changeset:publish chains turbo task with non-turbo command
{
"scripts": {
"changeset:publish": "bun build && changeset publish"
}
}
// CORRECT - use turbo run, let turbo handle dependencies
{
"scripts": {
"changeset:publish": "turbo run build && changeset publish"
}
}
```
If the second command (`changeset publish`) depends on build outputs, the turbo task should run through turbo to get caching and parallelization benefits.
## #3 Overly Broad globalDependencies
`globalDependencies` affects hash for ALL tasks in ALL packages. Be specific.
```json
// WRONG - affects all hashes
{
"globalDependencies": ["**/.env.*local"]
}
// CORRECT - move to specific tasks that need it
{
"globalDependencies": [".env"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
}
}
}
```
**Why this matters:** `**/.env.*local` matches .env files in ALL packages, causing unnecessary cache invalidation. Instead:
- Use `globalDependencies` only for truly global files (root `.env`)
- Use task-level `inputs` for package-specific .env files with `$TURBO_DEFAULT$` to preserve default behavior
## #4 Repetitive Task Configuration
Look for repeated configuration across tasks that can be collapsed.
```json
// WRONG - repetitive env and inputs across tasks
{
"tasks": {
"build": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
},
"test": {
"env": ["API_URL", "DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env*"]
}
}
}
// BETTER - use globalEnv and globalDependencies
{
"globalEnv": ["API_URL", "DATABASE_URL"],
"globalDependencies": [".env*"],
"tasks": {
"build": {},
"test": {}
}
}
```
**When to use global vs task-level:**
- `globalEnv` / `globalDependencies` - affects ALL tasks, use for truly shared config
- Task-level `env` / `inputs` - use when only specific tasks need it
## #5 Using `../` to Traverse Out of Package in `inputs`
Don't use relative paths like `../` to reference files outside the package. Use `$TURBO_ROOT$` instead.
```json
// WRONG - traversing out of package
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "../shared-config.json"]
}
}
}
// CORRECT - use $TURBO_ROOT$ for repo root
{
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/shared-config.json"]
}
}
}
```
## #6 MOST COMMON MISTAKE: Creating Root Tasks
**DO NOT create Root Tasks. ALWAYS create package tasks.**
When you need to create a task (build, lint, test, typecheck, etc.):
1. Add the script to **each relevant package's** `package.json`
2. Register the task in root `turbo.json`
3. Root `package.json` only contains `turbo run <task>`
```json
// WRONG - DO NOT DO THIS
// Root package.json with task logic
{
"scripts": {
"build": "cd apps/web && next build && cd ../api && tsc",
"lint": "eslint apps/ packages/",
"test": "vitest"
}
}
// CORRECT - DO THIS
// apps/web/package.json
{ "scripts": { "build": "next build", "lint": "eslint .", "test": "vitest" } }
// apps/api/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// packages/ui/package.json
{ "scripts": { "build": "tsc", "lint": "eslint .", "test": "vitest" } }
// Root package.json - ONLY delegates
{ "scripts": { "build": "turbo run build", "lint": "turbo run lint", "test": "turbo run test" } }
// turbo.json - register tasks
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": {}
}
}
```
**Why this matters:**
- Package tasks run in **parallel** across all packages
- Each package's output is cached **individually**
- You can **filter** to specific packages: `turbo run test --filter=web`
Root Tasks (`//#taskname`) defeat all these benefits. Only use them for tasks that truly cannot exist in any package (extremely rare).
## #7 Tasks That Need Parallel Execution + Cache Invalidation
Some tasks can run in parallel (don't need built output from dependencies) but must still invalidate cache when dependency source code changes. Using `dependsOn: ["^taskname"]` forces sequential execution. Using no dependencies breaks cache invalidation.
**Use Transit Nodes for these tasks:**
```json
// WRONG - forces sequential execution (SLOW)
"my-task": {
"dependsOn": ["^my-task"]
}
// ALSO WRONG - no dependency awareness (INCORRECT CACHING)
"my-task": {}
// CORRECT - use Transit Nodes for parallel + correct caching
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"my-task": { "dependsOn": ["transit"] }
}
}
```
**Why Transit Nodes work:**
- `transit` creates dependency relationships without matching any actual script
- Tasks that depend on `transit` gain dependency awareness
- Since `transit` completes instantly (no script), tasks run in parallel
- Cache correctly invalidates when dependency source code changes
**How to identify tasks that need this pattern:** Look for tasks that read source files from dependencies but don't need their build outputs.
## Missing outputs for File-Producing Tasks
**Before flagging missing `outputs`, check what the task actually produces:**
1. Read the package's script (e.g., `"build": "tsc"`, `"test": "vitest"`)
2. Determine if it writes files to disk or only outputs to stdout
3. Only flag if the task produces files that should be cached
```json
// WRONG - build produces files but they're not cached
"build": {
"dependsOn": ["^build"]
}
// CORRECT - outputs are cached
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
```
No `outputs` key is fine for stdout-only tasks. For file-producing tasks, missing `outputs` means Turbo has nothing to cache.
## Forgetting ^ in dependsOn
```json
// WRONG - looks for "build" in SAME package (infinite loop or missing)
"build": {
"dependsOn": ["build"]
}
// CORRECT - runs dependencies' build first
"build": {
"dependsOn": ["^build"]
}
```
The `^` means "in dependency packages", not "in this package".
## Missing persistent on Dev Tasks
```json
// WRONG - dependent tasks hang waiting for dev to "finish"
"dev": {
"cache": false
}
// CORRECT
"dev": {
"cache": false,
"persistent": true
}
```
## Package Config Missing extends
```json
// WRONG - packages/web/turbo.json
{
"tasks": {
"build": { "outputs": [".next/**"] }
}
}
// CORRECT
{
"extends": ["//"],
"tasks": {
"build": { "outputs": [".next/**"] }
}
}
```
Without `"extends": ["//"]`, Package Configurations are invalid.
## Root Tasks Need Special Syntax
To run a task defined only in root `package.json`:
```bash
# WRONG
turbo run format
# CORRECT
turbo run //#format
```
And in dependsOn:
```json
"build": {
"dependsOn": ["//#codegen"] // Root package's codegen
}
```
## Overwriting Default Inputs
```json
// WRONG - only watches test files, ignores source changes
"test": {
"inputs": ["tests/**"]
}
// CORRECT - extends defaults, adds test files
"test": {
"inputs": ["$TURBO_DEFAULT$", "tests/**"]
}
```
Without `$TURBO_DEFAULT$`, you replace all default file watching.
## Caching Tasks with Side Effects
```json
// WRONG - deploy might be skipped on cache hit
"deploy": {
"dependsOn": ["build"]
}
// CORRECT
"deploy": {
"dependsOn": ["build"],
"cache": false
}
```
Always disable cache for deploy, publish, or mutation tasks.

View file

@ -0,0 +1,285 @@
# Task Configuration Reference
Full docs: https://turborepo.dev/docs/reference/configuration#tasks
## dependsOn
Controls task execution order.
```json
{
"tasks": {
"build": {
"dependsOn": [
"^build", // Dependencies' build tasks first
"codegen", // Same package's codegen task first
"shared#build" // Specific package's build task
]
}
}
}
```
| Syntax | Meaning |
| ---------- | ------------------------------------ |
| `^task` | Run `task` in all dependencies first |
| `task` | Run `task` in same package first |
| `pkg#task` | Run specific package's task first |
The `^` prefix is crucial - without it, you're referencing the same package.
### Transit Nodes for Parallel Tasks
For tasks like `lint` and `check-types` that can run in parallel but need dependency-aware caching:
```json
{
"tasks": {
"transit": { "dependsOn": ["^transit"] },
"lint": { "dependsOn": ["transit"] },
"check-types": { "dependsOn": ["transit"] }
}
}
```
**DO NOT use `dependsOn: ["^lint"]`** - this forces sequential execution.
**DO NOT use `dependsOn: []`** - this breaks cache invalidation.
The `transit` task creates dependency relationships without running anything (no matching script), so tasks run in parallel with correct caching.
## outputs
Glob patterns for files to cache. **If omitted, nothing is cached.**
```json
{
"tasks": {
"build": {
"outputs": ["dist/**", "build/**"]
}
}
}
```
**Framework examples:**
```json
// Next.js
"outputs": [".next/**", "!.next/cache/**"]
// Vite
"outputs": ["dist/**"]
// TypeScript (tsc)
"outputs": ["dist/**", "*.tsbuildinfo"]
// No file outputs (lint, typecheck)
"outputs": []
```
Use `!` prefix to exclude patterns from caching.
## inputs
Files considered when calculating task hash. Defaults to all tracked files in package.
```json
{
"tasks": {
"test": {
"inputs": ["src/**", "tests/**", "vitest.config.ts"]
}
}
}
```
**Special values:**
| Value | Meaning |
| --------------------- | --------------------------------------- |
| `$TURBO_DEFAULT$` | Include default inputs, then add/remove |
| `$TURBO_ROOT$/<path>` | Reference files from repo root |
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
"!README.md",
"$TURBO_ROOT$/tsconfig.base.json"
]
}
}
}
```
## env
Environment variables to include in task hash.
```json
{
"tasks": {
"build": {
"env": [
"API_URL",
"NEXT_PUBLIC_*", // Wildcard matching
"!DEBUG" // Exclude from hash
]
}
}
}
```
Variables listed here affect cache hits - changing the value invalidates cache.
## cache
Enable/disable caching for a task. Default: `true`.
```json
{
"tasks": {
"dev": { "cache": false },
"deploy": { "cache": false }
}
}
```
Disable for: dev servers, deploy commands, tasks with side effects.
## persistent
Mark long-running tasks that don't exit. Default: `false`.
```json
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
```
Required for dev servers - without it, dependent tasks wait forever.
## interactive
Allow task to receive stdin input. Default: `false`.
```json
{
"tasks": {
"login": {
"cache": false,
"interactive": true
}
}
}
```
## outputLogs
Control when logs are shown. Options: `full`, `hash-only`, `new-only`, `errors-only`, `none`.
```json
{
"tasks": {
"build": {
"outputLogs": "new-only" // Only show logs on cache miss
}
}
}
```
## with
Run tasks alongside this task. For long-running tasks that need runtime dependencies.
```json
{
"tasks": {
"dev": {
"with": ["api#dev"],
"persistent": true,
"cache": false
}
}
}
```
Unlike `dependsOn`, `with` runs tasks concurrently (not sequentially). Use for dev servers that need other services running.
## interruptible
Allow `turbo watch` to restart the task on changes. Default: `false`.
```json
{
"tasks": {
"dev": {
"persistent": true,
"interruptible": true,
"cache": false
}
}
}
```
Use for dev servers that don't automatically detect dependency changes.
## description (Pre-release)
Human-readable description of the task.
```json
{
"tasks": {
"build": {
"description": "Compiles the application for production deployment"
}
}
}
```
For documentation only - doesn't affect execution or caching.
## passThroughEnv
Environment variables available at runtime but NOT included in cache hash.
```json
{
"tasks": {
"build": {
"passThroughEnv": ["AWS_SECRET_KEY", "GITHUB_TOKEN"]
}
}
}
```
**Warning**: Changes to these vars won't cause cache misses. Use `env` if changes should invalidate cache.
## extends (Package Configuration only)
Control task inheritance in Package Configurations.
```json
// packages/ui/turbo.json
{
"extends": ["//"],
"tasks": {
"lint": {
"extends": false // Exclude from this package
}
}
}
```
| Value | Behavior |
| ---------------- | -------------------------------------------------------------- |
| `true` (default) | Inherit from root turbo.json |
| `false` | Exclude task from package, or define fresh without inheritance |

View file

@ -0,0 +1,145 @@
# Environment Variable Gotchas
Common mistakes and how to fix them.
## .env Files Must Be in `inputs`
Turbo does NOT read `.env` files. Your framework (Next.js, Vite, etc.) or `dotenv` loads them. But Turbo needs to know when they change.
**Wrong:**
```json
{
"tasks": {
"build": {
"env": ["DATABASE_URL"]
}
}
}
```
**Right:**
```json
{
"tasks": {
"build": {
"env": ["DATABASE_URL"],
"inputs": ["$TURBO_DEFAULT$", ".env", ".env.local", ".env.production"]
}
}
}
```
## Strict Mode Filters CI Variables
In strict mode, CI provider variables (GITHUB_TOKEN, GITLAB_CI, etc.) are filtered unless explicitly listed.
**Symptom:** Task fails with "authentication required" or "permission denied" in CI.
**Solution:**
```json
{
"globalPassThroughEnv": ["GITHUB_TOKEN", "GITLAB_CI", "CI"]
}
```
## passThroughEnv Doesn't Affect Hash
Variables in `passThroughEnv` are available at runtime but changes WON'T trigger rebuilds.
**Dangerous example:**
```json
{
"tasks": {
"build": {
"passThroughEnv": ["API_URL"]
}
}
}
```
If `API_URL` changes from staging to production, Turbo may serve a cached build pointing to the wrong API.
**Use passThroughEnv only for:**
- Auth tokens that don't affect output (SENTRY_AUTH_TOKEN)
- CI metadata (GITHUB_RUN_ID)
- Variables consumed after build (deploy credentials)
## Runtime-Created Variables Are Invisible
Turbo captures env vars at startup. Variables created during execution aren't seen.
**Won't work:**
```bash
# In package.json scripts
"build": "export API_URL=$COMPUTED_VALUE && next build"
```
**Solution:** Set vars before invoking turbo:
```bash
API_URL=$COMPUTED_VALUE turbo run build
```
## Different .env Files for Different Environments
If you use `.env.development` and `.env.production`, both should be in inputs.
```json
{
"tasks": {
"build": {
"inputs": [
"$TURBO_DEFAULT$",
".env",
".env.local",
".env.development",
".env.development.local",
".env.production",
".env.production.local"
]
}
}
}
```
## Complete Next.js Example
```json
{
"$schema": "https://turborepo.dev/schema.v2.json",
"globalEnv": ["CI", "NODE_ENV", "VERCEL"],
"globalPassThroughEnv": ["GITHUB_TOKEN", "VERCEL_URL"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"env": [
"DATABASE_URL",
"NEXT_PUBLIC_*",
"!NEXT_PUBLIC_ANALYTICS_ID"
],
"passThroughEnv": ["SENTRY_AUTH_TOKEN"],
"inputs": [
"$TURBO_DEFAULT$",
".env",
".env.local",
".env.production",
".env.production.local"
],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
```
This config:
- Hashes DATABASE*URL and NEXT_PUBLIC*\* vars (except analytics)
- Passes through SENTRY_AUTH_TOKEN without hashing
- Includes all .env file variants in the hash
- Makes CI tokens available globally

View file

@ -0,0 +1,101 @@
# Environment Modes
Turborepo supports different modes for handling environment variables during task execution.
## Strict Mode (Default)
Only explicitly configured variables are available to tasks.
**Behavior:**
- Tasks only see vars listed in `env`, `globalEnv`, `passThroughEnv`, or `globalPassThroughEnv`
- Unlisted vars are filtered out
- Tasks fail if they require unlisted variables
**Benefits:**
- Guarantees cache correctness
- Prevents accidental dependencies on system vars
- Reproducible builds across machines
```bash
# Explicit (though it's the default)
turbo run build --env-mode=strict
```
## Loose Mode
All system environment variables are available to tasks.
```bash
turbo run build --env-mode=loose
```
**Behavior:**
- Every system env var is passed through
- Only vars in `env`/`globalEnv` affect the hash
- Other vars are available but NOT hashed
**Risks:**
- Cache may restore incorrect results if unhashed vars changed
- "Works on my machine" bugs
- CI vs local environment mismatches
**Use case:** Migrating legacy projects or debugging strict mode issues.
## Framework Inference (Automatic)
Turborepo automatically detects frameworks and includes their conventional env vars.
### Inferred Variables by Framework
| Framework | Pattern |
| ---------------- | ------------------- |
| Next.js | `NEXT_PUBLIC_*` |
| Vite | `VITE_*` |
| Create React App | `REACT_APP_*` |
| Gatsby | `GATSBY_*` |
| Nuxt | `NUXT_*`, `NITRO_*` |
| Expo | `EXPO_PUBLIC_*` |
| Astro | `PUBLIC_*` |
| SvelteKit | `PUBLIC_*` |
| Remix | `REMIX_*` |
| Redwood | `REDWOOD_ENV_*` |
| Sanity | `SANITY_STUDIO_*` |
| Solid | `VITE_*` |
### Disabling Framework Inference
Globally via CLI:
```bash
turbo run build --framework-inference=false
```
Or exclude specific patterns in config:
```json
{
"tasks": {
"build": {
"env": ["!NEXT_PUBLIC_*"]
}
}
}
```
### Why Disable?
- You want explicit control over all env vars
- Framework vars shouldn't bust the cache (e.g., analytics IDs)
- Debugging unexpected cache misses
## Checking Environment Mode
Use `--dry-run` to see which vars affect each task:
```bash
turbo run build --dry-run=json | jq '.tasks[].environmentVariables'
```

View file

@ -0,0 +1,152 @@
# Common Filter Patterns
Practical examples for typical monorepo scenarios.
## Single Package
Run task in one package:
```bash
turbo run build --filter=web
turbo run test --filter=@acme/api
```
## Package with Dependencies
Build a package and everything it depends on:
```bash
turbo run build --filter=web...
```
Useful for: ensuring all dependencies are built before the target.
## Package Dependents
Run in all packages that depend on a library:
```bash
turbo run test --filter=...ui
```
Useful for: testing consumers after changing a shared package.
## Dependents Only (Exclude Target)
Test packages that depend on ui, but not ui itself:
```bash
turbo run test --filter=...^ui
```
## Changed Packages
Run only in packages with file changes since last commit:
```bash
turbo run lint --filter=[HEAD^1]
```
Since a specific branch point:
```bash
turbo run lint --filter=[main...HEAD]
```
## Changed + Dependents (PR Builds)
Run in changed packages AND packages that depend on them:
```bash
turbo run build test --filter=...[HEAD^1]
```
Or use the shortcut:
```bash
turbo run build test --affected
```
## Directory-Based
Run in all apps:
```bash
turbo run build --filter=./apps/*
```
Run in specific directories:
```bash
turbo run build --filter=./apps/web --filter=./apps/api
```
## Scope-Based
Run in all packages under a scope:
```bash
turbo run build --filter=@acme/*
```
## Exclusions
Run in all apps except admin:
```bash
turbo run build --filter=./apps/* --filter=!admin
```
Run everywhere except specific packages:
```bash
turbo run lint --filter=!legacy-app --filter=!deprecated-pkg
```
## Complex Combinations
Apps that changed, plus their dependents:
```bash
turbo run build --filter=...[HEAD^1] --filter=./apps/*
```
All packages except docs, but only if changed:
```bash
turbo run build --filter=[main...HEAD] --filter=!docs
```
## Debugging Filters
Use `--dry` to see what would run without executing:
```bash
turbo run build --filter=web... --dry
```
Use `--dry=json` for machine-readable output:
```bash
turbo run build --filter=...[HEAD^1] --dry=json
```
## CI/CD Patterns
PR validation (most common):
```bash
turbo run build test lint --affected
```
Deploy only changed apps:
```bash
turbo run deploy --filter=./apps/* --filter=[main...HEAD]
```
Full rebuild of specific app and deps:
```bash
turbo run build --filter=production-app...
```

View file

@ -0,0 +1,111 @@
---
name: unit-tests
description: |
Guardrails for adding unit tests in bklit-ui without over-testing. Use when the
user mentions unit test, unit tests, tests, test coverage, add tests, write tests,
vitest, jest, or asks whether something should be tested.
---
# Unit Tests (bklit-ui)
Read this skill before proposing or writing tests. **Default stance: fewer, higher-signal tests.**
---
## When to add tests
Add tests when they **lock in behavior that is easy to break silently**:
| Worth testing | Why |
|---------------|-----|
| Pure functions / modules | Stable inputs → outputs; fast; no DOM |
| Formatters, parsers, scale math, bounds | Regression on string/number output is user-visible |
| Codegen / export / registry helpers | Output shape is the contract |
| Non-obvious edge cases | Empty data, reversed ranges, clamping |
**Examples in this repo:** `chart-formatters.test.ts`, `highlight-segment-bounds.test.ts`, `animation.test.ts`, `apps/web/lib/studio/__tests__/*`.
---
## When NOT to add tests (unless explicitly requested)
Do **not** add tests just to increase coverage or “be thorough”:
| Skip | Why |
|------|-----|
| React component render smoke tests | visx, motion, portals → brittle; manual/docs check is cheaper |
| `memo()` / guard extractions (#65-style) | Structural perf refactors; output unchanged; RTL mount assertions are heavy |
| Default prop passthrough | `formatValue = intFmt` — types + formatter tests cover it |
| Third-party library behavior | Dont re-test d3, visx, or React |
| Trivial getters / one-line wrappers | No regression signal |
| Snapshot entire chart SVG/JSX | High churn, low signal |
If the user asks “should we test X?” — **say no** when X falls in this table, and suggest a lighter alternative (pure helper test, manual check, CI build).
---
## Repo conventions
**Runner:** Node built-in test runner + `tsx` (not Jest/Vitest unless the repo adopts them later).
```bash
pnpm test # root — turbo runs packages with a test script
pnpm --filter @bklitui/ui test
cd apps/web && pnpm test
```
**Place tests:** `**/__tests__/**/*.test.ts` next to the code under test.
**Pattern:**
```ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { myFn } from "../my-module";
describe("myFn", () => {
it("handles empty input", () => {
assert.equal(myFn([]), expected);
});
});
```
**Equivalence tests** (preferred for formatters): assert shared module output matches the **previous inline** call (e.g. `toLocaleDateString` with same locale/options) so tests stay timezone-safe and prove no visual regression.
**New package test script:** add to `package.json`:
```json
"test": "node scripts/run-tests.mjs"
```
Use a small `scripts/run-tests.mjs` that collects `*.test.ts` from `__tests__` and invokes `node --import tsx --test` — shell globs break on Linux CI. Add `tsx` as a devDependency if missing. Wire into root `turbo.json` `test` task; CI runs `pnpm test`.
---
## Decision checklist (run before writing)
1. Is the logic **pure** or extractable to pure functions? → Test that. Consider extracting first.
2. Would a test fail on a **real user-visible bug**? → Good candidate.
3. Does it need **jsdom / RTL / Playwright**? → Stop; justify to user or defer to manual/visual check.
4. Did the user **ask** for tests? → Still apply this skill; dont over-deliver.
5. How many cases? → **310 focused cases**, not exhaustive matrices.
---
## What to tell the user
When recommending tests, be explicit:
- **Add:** “Test `chart-formatters.ts` — pure, high regression value.”
- **Skip:** “Skip component tests for the memo split — no output change; build + manual chart docs are enough.”
- **CI:** Mention `pnpm test` in PR test plan when adding or changing tests.
---
## Anti-patterns
- Adding Jest/Vitest/Testing Library for a one-off without team buy-in
- Mocking entire chart context to assert `useMemo` call counts
- Testing implementation details (hook order, internal component names)
- Duplicating type-checker work (`expect(typeof x).toBe('function')`)
- Committing tests that only pass locally due to hard-coded timezone/locale strings

View file

@ -0,0 +1,129 @@
---
name: wiki-llms-txt
description: Generates llms.txt and llms-full.txt files for LLM-friendly project documentation following the llms.txt specification. Use when the user wants to create LLM-readable summaries, llms.txt files, or make their wiki accessible to language models.
license: MIT
metadata:
author: Microsoft
version: "1.0.0"
---
# llms.txt Generator
Generate `llms.txt` and `llms-full.txt` files that provide LLM-friendly access to wiki documentation, following the [llms.txt specification](https://llmstxt.org/).
## When This Skill Activates
- User asks to generate `llms.txt` or mentions the llms.txt standard
- User wants to make documentation "LLM-friendly" or "LLM-readable"
- User asks for a project summary file for language models
- User mentions `llms-full.txt` or context-expanded documentation
## Source Repository Resolution (MUST DO FIRST)
Before generating, resolve the source repository context:
1. **Check for git remote**: Run `git remote get-url origin`
2. **Ask the user**: _"Is this a local-only repository, or do you have a source repository URL?"_
- Remote URL → store as `REPO_URL`
- Local → use relative paths only
3. **Determine default branch**: Run `git rev-parse --abbrev-ref HEAD`
4. **Do NOT proceed** until resolved
## llms.txt Format (Spec-Compliant)
The file follows the [llms.txt specification](https://llmstxt.org/):
```markdown
# {Project Name}
> {Dense one-paragraph summary — what it does, who it's for, key technologies}
{Important context paragraphs — constraints, architectural philosophy, non-obvious things}
## {Section Name}
- [{Page Title}]({relative-path-to-md}): {One-sentence description of what the reader will learn}
## Optional
- [{Page Title}]({relative-path-to-md}): {Description — these can be skipped for shorter context}
```
### Key Rules
1. **H1** — Project name (exactly one, required)
2. **Blockquote** — Dense, specific summary (required). Must be unique to THIS project.
3. **Context paragraphs** — Non-obvious constraints, things LLMs would get wrong without being told
4. **H2 sections** — Organized by topic, each with a list of `[Title](url): Description` entries
5. **"Optional" H2** — Special meaning: links here can be skipped for shorter context
6. **Relative links** — All paths relative to wiki directory
7. **Dynamic** — ALL content derived from actual wiki pages, not templates
8. **Section order** — Most important first: Onboarding → Architecture → Getting Started → Deep Dive → Optional
### Description Quality
| ❌ Bad | ✅ Good |
|--------|---------|
| "Architecture overview" | "System architecture showing how Orleans grains communicate via message passing with at-least-once delivery" |
| "Getting started guide" | "Prerequisites, local dev setup with Docker Compose, and first API call walkthrough" |
| "The API reference" | "REST endpoints with auth requirements, rate limits, and request/response schemas" |
## llms-full.txt Format
Same structure as `llms.txt` but with full content inlined:
```markdown
# {Project Name}
> {Same summary}
{Same context}
## {Section Name}
<doc title="{Page Title}" path="{relative-path}">
{Full markdown content — frontmatter stripped, citations and diagrams preserved}
</doc>
```
### Inlining Rules
- **Strip YAML frontmatter** (`---` blocks) from each page
- **Preserve Mermaid diagrams** — keep `` ```mermaid `` fences intact
- **Preserve citations** — all `[file:line](URL)` links stay as-is
- **Preserve tables** — all markdown tables stay intact
- **Preserve `<!-- Sources: -->` comments** — these provide diagram provenance
## Prerequisites
This skill works best when wiki pages already exist (via `/deep-wiki:generate` or `/deep-wiki:page`). If no wiki exists yet:
1. Suggest running `/deep-wiki:generate` first
2. OR generate a minimal `llms.txt` from README + source code scan (without wiki page links)
## Output Files
Generate three files:
| File | Purpose | Discoverability |
|------|---------|-----------------|
| `./llms.txt` | Root discovery file | Standard path per llms.txt spec. GitHub MCP `get_file_contents` and `search_code` find this first. |
| `wiki/llms.txt` | Wiki-relative links | For VitePress deployment and wiki-internal navigation. |
| `wiki/llms-full.txt` | Full inlined content | Comprehensive reference for agents needing all docs in one file. |
The root `./llms.txt` links into `wiki/` (e.g., `[Guide](./wiki/onboarding/contributor-guide.md)`). The `wiki/llms.txt` uses wiki-relative paths (e.g., `[Guide](./onboarding/contributor-guide.md)`).
If a root `llms.txt` already exists and was NOT generated by deep-wiki, do NOT overwrite it.
## Validation Checklist
Before finalizing:
- [ ] All linked files in `llms.txt` actually exist
- [ ] All `<doc>` blocks in `llms-full.txt` have real content (not empty)
- [ ] Blockquote is specific to this project (not generic boilerplate)
- [ ] Sections ordered by importance
- [ ] No duplicate page entries across sections
- [ ] "Optional" section only contains truly optional content
- [ ] `llms.txt` is concise (1-5 KB)
- [ ] `llms-full.txt` contains all wiki pages

17
.claude/launch.json Normal file
View file

@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "quant",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 5173
},
{
"name": "quant-build",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["preview", "--port", "4173"],
"port": 4173
}
]
}

1
.claude/skills/add-x-tweet Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/add-x-tweet

View file

@ -0,0 +1 @@
../../.agents/skills/bklit-playground

1
.claude/skills/bklit-ship Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/bklit-ship

1
.claude/skills/bklit-studio Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/bklit-studio

View file

@ -0,0 +1 @@
../../.agents/skills/bklit-studio-chart-performance

1
.claude/skills/bklit-ui Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/bklit-ui

1
.claude/skills/pr-open Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/pr-open

1
.claude/skills/turborepo Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/turborepo

1
.claude/skills/unit-tests Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/unit-tests

View file

@ -0,0 +1 @@
../../.agents/skills/wiki-llms-txt

10
.env Normal file
View file

@ -0,0 +1,10 @@
# Deploy config (Forgejo Actions). This file IS committed — it's config, not secrets.
# Secrets go via Forgejo → repo → Settings → Actions → Secrets (+ RUNTIME_KEYS).
# Push to main → platform builds the Vite app with bun and serves dist/ on the domain (SSL auto).
# Site domain. Point its A-record at the platform IP (154.83.149.72) so SSL can issue.
DOMAIN=agentsquant.xyz
# Static front-end (Vite + React → dist). Default, but pinned explicitly.
DEPLOY=static
BUILD_DIR=dist

23
.env.example Normal file
View file

@ -0,0 +1,23 @@
# Copy to .env.local and fill in.
# FactsAI deep-research API key (https://factsai.org/docs).
# WARNING: with a static build this is baked into the client JS and visible to anyone.
# For production, leave KEY empty and set PROXY to a tiny serverless function that injects
# the key server-side and adds CORS headers — that hides the key and avoids browser CORS.
VITE_FACTSAI_KEY=
VITE_FACTSAI_PROXY=
# ── Paper-trading worker (worker/, run with `pnpm worker`) ──────────────────────
# All optional — the worker runs in dry-run paper mode with sane defaults if unset.
# Telegram: create a bot via @BotFather for the token; get your chat id from @userinfobot.
# Leave both empty to run head-less (signals/fills print to stdout instead).
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Universe + cadence
QUANT_COINS=BTC,ETH,SOL
QUANT_INTERVAL=1h
QUANT_POLL_MS=60000
# Paper risk model
QUANT_EQUITY_START=10000
QUANT_LEVERAGE=5
QUANT_CONF_FLOOR=0.55

View file

@ -0,0 +1,22 @@
name: deploy
on:
push:
branches: [main]
workflow_dispatch: # lets the admin panel trigger a redeploy (Enable)
jobs:
deploy:
runs-on: docker-cli
container:
image: platform-ci-base:latest
volumes:
- /srv/sites:/srv/sites
- /srv/platform/caddy/sites:/srv/platform/caddy/sites
steps:
- uses: https://git.154.83.149.72.nip.io/platform/ci-templates@main
with:
repo: ${{ github.repository }}
ref: ${{ github.ref_name }}
sha: ${{ github.sha }}
token: ${{ github.token }}
secrets_json: ${{ toJSON(secrets) }}
vars_json: ${{ toJSON(vars) }}

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Worker paper-trading state (regenerated at runtime)
worker/data
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,90 @@
# $QUANT Terminal — interface design system
**Direction:** "Carbon Desk" — a serious institutional quant trading terminal (Linear ×
Bloomberg). Dark, dense, calm. The product is the live decision pipeline; the **agent
committee → verdict** is the hero. Reading order everywhere: **CALL → WHY → EVIDENCE**.
**Feel:** precise, quiet, instrument-like. No marketing voice. Craft whispers.
## Depth strategy
**Borders + surface lightness — NO shadows.** Elevation by lightness:
- page `--color-paper` #0B0C0E → panel chrome `--color-panel` #111316 → raised/hover/active
`--color-panel-2` #181B20. Data cells & wells **recede** to `--color-inset` #0E0F12.
- Borders are light-on-dark rgba: `line` .10 (standard), `line-soft` .06 (dividers),
`line-strong` .16 (emphasis / zero-axis). `line-amber` .45 only for the verdict accent.
- Bar/ring tracks use `--color-track` rgba(255,255,255,.09) — a carved channel, never `line-soft`.
## Color
- **One accent: amber `--color-accent` #FFB020** — reserved for "the call" (verdict accent
strip, L4 pipeline dot, active tab, PAPER chip). Do not spread it.
- **Semantic only:** long `#2BD974`, short `#F2444D` (+ `-dim` 14% fills). Never decorative.
- Direction must never be color-only — pair with a glyph/sign (▲ ▼ —, +/).
## Text hierarchy (all AA on panel)
`--color-bone` #E8EAED primary · `--color-bone-dim` #AAB1BA secondary/prose ·
`--color-muted` #828A94 labels · `--color-faint` #727983 captions/ticks.
Rule: value=bone, supporting prose=bone-dim, labels=muted, codes/units/ticks=faint.
## Type
- Display/UI **Geist**, data **Geist Mono**. All numbers mono + `.tnum` (tabular).
- Scale tokens: `--text-micro` 10px · `--text-label` 11px · `--text-read` 12px · `--text-data` 13px.
Big display via inline `clamp()` (verdict glyph clamp(3rem,6vw,5rem)).
- Labels: `.label` (11px, .14em) and `.label-sm` (10px, .1em) are the ONLY label recipes.
Two letter-spacings, not eight.
- Weight: bold (700) reserved for display (verdict glyph, masthead, QUANT). Data = 500/medium.
## Spacing & radius
- 4px base. Panel inset = `px-4 py-3`. Hairline grids via `gap-px` over `bg-line-soft`.
- Radius scale 2/4/6/10. Panels `rounded-md` + `overflow-hidden`; nested wells clip to parent.
## Key patterns
- **Panel**`rounded-md border border-line bg-panel overflow-hidden`; header = `.label` +
faint meta. Accent panel adds a 2px amber top strip as a child div (clean rounded corners).
- **Cell** — recessed `bg-inset`, `.label-sm` over `.tnum` mono value; optional `sub` (agent tag)
and a thin `--color-track` bar.
- **DivergeBar** — the signature: shared zero-axis; LONG fills right (green), SHORT left (red),
SKIP a centred muted tick. Conviction = length. Committee ends in a NET LEAN row whose amber
marker is the aggregate — visually ties votes → verdict.
- **VerdictHero** — full-width band, the hero: clamp() decision glyph in dir color + arrow,
quorum ticks, big ConfidenceRing, then size/TP/SL/RR cells. Amber accent strip.
- **Pipeline** — honest L1→L6 with status dots (live=green, neutral=muted, the call=amber on L4,
off-terminal=faint). Never overclaim what the page doesn't do.
## Terminal layout (workspace)
The terminal is a **multi-pane workspace**, not tabs — everything visible at once like a real desk.
Order: status bar → masthead + coin tabs → reading guide → **VERDICT band on top** (the call,
full width) → 3-column grid `lg:grid-cols-[280px_minmax(0,1fr)_360px]` (stacks to 1 col below lg):
- **Left**`PipelineRail` (vertical L1→L6) + Market + Indicators stat cells (the "numbers").
- **Center** — the candlestick chart (the dominant pane).
- **Right**`AgentGraph` (signature) + Committee analytics list.
`AgentGraph` (`components/viz/AgentGraph.tsx`) is hub-and-spoke: 4 agent nodes feed one central
verdict node; edge colour = vote (long/short/skip), edge weight + node fill = conviction; the
centre shows decision + confidence. Honest (exactly the 4 real agents), animated between states.
## Charts
Charts come from **bklit-ui** (shadcn registry `@bklit`, built on @visx + motion), installed as
source under `src/components/charts/`. The price panel uses the **candlestick-chart**
(`src/components/viz/PriceCandles.tsx`) fed by real Hyperliquid OHLC, themed to Carbon Desk:
`positiveFill=var(--color-long)`, `negativeFill=var(--color-short)`. Dark chart vars are
activated via `class="dark"` on `<html>`. Add more charts with
`npx shadcn@latest add @bklit/<slug>`.
Two gotchas (already handled): (1) the alpha `@visx ParentSize` fails to size under React 19 —
`candlestick-chart.tsx` was patched to use a `useElementSize` ResizeObserver hook instead.
(2) Keep the global `* { min-width: 0 }` reset inside `@layer base`, or it (unlayered) beats
Tailwind's `min-w-*` utilities and breaks `flex-wrap` layouts.
## States
Seeded-vs-live is flagged (verdict shows a "seed · awaiting live" chip when `live===false`);
StatusBar surfaces stale/error (`DATA STALE` when fetch errors or `ago>40s`); footer copy
degrades the "live" claim accordingly. Global amber `:focus-visible` ring on all interactives.

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# $QUANT — landing + live terminal
An orchestra of ~18 AI agents trading Hyperliquid perps. Marketing site (LARP) with a
**Living Agent Swarm** aesthetic, driven by **real Hyperliquid market data**.
- **Landing** (`/`) — hero swarm + 6-layer pipeline + live signal desk + agent roster +
performance + tokenomics + roadmap + Telegram modes + FAQ + waitlist.
- **Terminal** (`/terminal`) — live dashboard: real price/funding/OI, in-browser TA, agent
vote matrix and a LONG/SHORT/SKIP verdict.
## Stack
React 19 · Vite 8 · TypeScript · Tailwind v4 · shadcn-style UI · anime.js v4 (SVG) ·
Recharts · React Router · Lenis. Package manager: **pnpm**.
## Run
```bash
pnpm install
pnpm dev # dev server (http://localhost:5173)
pnpm build # production build → dist/ (deploy this only)
pnpm preview # serve the built dist/
pnpm gen:icons # regenerate favicon.ico / og.png from public/favicon.svg
```
## Data
- **Live:** Hyperliquid public info API (`POST https://api.hyperliquid.xyz/info`, no auth,
CORS `*`) → live BTC/ETH/SOL price, funding, OI. Indicators (RSI/MACD/EMA/ATR/Bollinger) are
computed in-browser in `src/lib/ta.ts`; agent votes + verdict in `src/lib/signals.ts`.
- **Seed fallback:** `src/data/seedMarket.ts` renders deterministic data instantly (labelled
non-live) so cards never hang; live data replaces it.
- **Honesty:** anything that can't be real pre-TGE (token price/holders/PnL track record) is
rendered but clearly labelled **simulated**.
## Live paper-trading worker
`worker/` is an always-on Node loop that **reuses the browser brain** (`src/lib/ta.ts`
+ `signals.ts`) server-side: real candles → real TA → 4-agent vote → verdict, then it
**paper-trades** the verdict against live Hyperliquid prices and broadcasts every fill to
**Telegram**. This turns the simulated PnL track record into an honest, verifiable one —
no order is ever signed (`dry-run` only). See [`worker/README.md`](worker/README.md).
```bash
pnpm worker # run forever (Telegram optional — prints to stdout if unset)
pnpm worker:once # single tick smoke test
```
## ⚠️ Fill these before launch
Edit `src/data/site.ts`:
- `x` — the project's X/Twitter URL (manager-provided; shown in header + footer)
- `telegram` — Telegram link
- `contract` — $QUANT Solana mint (post-TGE)
Other:
- **Wallet:** `src/components/ConnectWallet.tsx` is a cosmetic Solana connect modal (honest
pre-TGE). To wire real **Reown AppKit (Solana)**, add a `projectId` (Reown dashboard) and
swap the modal body — see the project plan.
- **DexScreener token strip:** add the `$QUANT` pair address once it lists.
- The `base-verification` meta tag is already in `index.html`.
## Research agent (FactsAI)
The landing has a **"Research agent · L4"** section that renders a real AI market brief with
cited web sources from [FactsAI](https://factsai.org/docs) (`src/lib/factsai.ts`,
`components/sections/AIBrief.tsx`). It self-hides when the feed is unreachable, so the page
never looks broken.
⚠️ **A static site cannot call FactsAI directly** — the API returns no `Access-Control-Allow-Origin`
(browser blocks it) and a client key would be public. Also, at the time of building, FactsAI's
endpoint returned `500 "Service configuration error"` (their backend). To enable it:
1. Deploy the included proxy: [`proxy/factsai-worker.js`](proxy/factsai-worker.js) (Cloudflare
Worker — holds the key + adds CORS). See the header comment for `wrangler` steps.
2. Set `VITE_FACTSAI_PROXY=https://<your-worker>.workers.dev` in `.env.local`, then `pnpm build`.
3. FactsAI must also fix their upstream `500` before real answers return.
Without a proxy, set `VITE_FACTSAI_KEY` in `.env.local` for local dev only (the key is baked into
the bundle and CORS will still block production).
## Deploy
Ship **`dist/` only** to the GitLab static host (agency `templates/starter` pipeline). If the
app is served without SPA fallback, deep-linking `/terminal` on refresh may 404 — configure the
host to fall back to `index.html`, or switch to `HashRouter` in `src/App.tsx`.

BIN
bun.lockb Executable file

Binary file not shown.

24
components.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@bklit": "https://ui.bklit.com/r/{name}.json"
}
}

348
docs/architecture.md Normal file
View file

@ -0,0 +1,348 @@
# $QUANT — техническая документация (как это реализовано)
Описание реальной реализации системы: стек, поток данных, модули, алгоритмы, формулы.
Документ описывает то, что есть в коде. Термины называются как есть (rule-based,
детерминированно, paper-trading) — на этой фактуре можно строить тексты без додумывания.
---
## 1. Обзор
$QUANT — мульти-агентная система анализа бессрочных фьючерсов (перпов) на бирже
**Hyperliquid**. Поток обработки:
```
рыночные данные (Hyperliquid API)
теханализ (RSI, MACD, EMA, ATR, Bollinger)
4 стратегии-агента голосуют LONG / SHORT / SKIP
агрегация → вердикт (направление + уверенность + размер + TP/SL)
├──► веб-терминал (живая отрисовка, опрос раз в ~12 c)
└──► воркер: paper-trading на живых ценах + Telegram-уведомления
```
**Стек.** Фронтенд: React 19, Vite 8, TypeScript, Tailwind v4, Recharts, React Router 7,
anime.js, Lenis. Воркер: Node.js + `tsx` (TypeScript-рантайм). Менеджер пакетов: pnpm.
**Ключевое свойство — детерминированность.** Одно и то же состояние рынка даёт один и
тот же вердикт. В торговом контуре нет генератора случайных чисел и нет LLM/ML: решение
рассчитывается формулами по индикаторам.
---
## 2. Слой L1 — данные (`src/lib/hyperliquid.ts`)
Источник — публичный info-API Hyperliquid: `POST https://api.hyperliquid.xyz/info`,
**без авторизации**, CORS открыт (можно дёргать из браузера). Все ответы — JSON.
Три запроса:
| Функция | Тело запроса | Что возвращает |
|---|---|---|
| `getMarketStats(coins)` | `{ "type": "metaAndAssetCtxs" }` | mark/mid цена, ставка фандинга, OI, объём по каждой монете |
| `getCandles(coin, interval, lookback)` | `{ "type": "candleSnapshot", "req": { coin, interval, startTime, endTime } }` | массив свечей OHLCV |
| `getAllMids()` | `{ "type": "allMids" }` | средние цены по всем тикерам |
**Что извлекается в `MarketStat`:** `mark` (маркировочная цена), `mid`, `prevDayPx`
из неё `changePct` (изменение за 24 ч), `funding` (часовая ставка фандинга),
`openInterest` (в базовом активе) и `oiUsd = openInterest × mark`, `dayVolumeUsd`.
**Свечи** (`Candle = { t, o, h, l, c, v }`): по умолчанию интервал `1h`, глубина
`lookback = 120` свечей. Поддерживаемые интервалы: 1m / 5m / 15m / 1h / 4h / 1d.
**Торгуемая вселенная по умолчанию:** `BTC`, `ETH`, `SOL` (константа `SYMBOLS`).
Веб-терминал дополнительно показывает вкладки `BNB`, `AVAX`.
Сетевой слой: таймаут 12 c через `AbortController`, при ошибке — `throw`, который выше по
стеку обрабатывается (показывается последнее удачное значение / seed).
---
## 3. Слой L2 — технический анализ (`src/lib/ta.ts`)
Считается из реальных свечей. Реализованные индикаторы:
| Индикатор | Параметры | Реализация |
|---|---|---|
| **EMA** | периоды 9, 21, 50 | экспоненциальное сглаживание, `k = 2/(period+1)` |
| **RSI** | период 14 | по Уайлдеру (сглаженные средние gain/loss) |
| **MACD** | 12 / 26 / 9 | гистограмма = MACD-линия сигнальная |
| **ATR** | период 14 | EMA от True Range (волатильность) |
| **Bollinger %B** | 20, 2σ | позиция цены в полосах: 0 = низ, 1 = верх |
| **Funding-Z** | — | нормировка ставки фандинга: `clamp(funding / 0.00005, 4, 4)` (иллюстративная шкала, не статистический z-score по истории) |
Функция `summarizeTA(candles, funding)` сворачивает серию в снимок `TASnapshot`:
```ts
{ price, rsi, ema9, ema21, ema50,
emaDiffPct, // (ema9 ema21) / price × 100 — наклон тренда
macdHist, atr, atrPct,
pctB, // Bollinger %B
changePct, // изменение от первой свечи окна
fundingZ, closes }
```
Этот снимок — единственный вход для слоя сигналов. Один и тот же `TASnapshot` всегда даёт
один и тот же набор голосов.
---
## 4. Слой L3 — сигнальные агенты (`src/lib/signals.ts`)
Четыре независимых rule-based стратегии. Каждая возвращает голос
`Vote = { agent, layer, direction, score, reason }`, где `direction ∈ {LONG, SHORT, SKIP}`,
`score ∈ 0..1` — «уверенность» (конвикция), `reason` — человекочитаемое объяснение.
### TrendAgent — следование за трендом
- **LONG**, если `ema9 > ema21` и `rsi > 50`; **SHORT**, если `ema9 < ema21` и `rsi < 50`; иначе **SKIP**.
- `score = clamp(|emaDiffPct| × 14 + |rsi 50| / 60, 0, 1)`.
- Пример reason: `«EMA9 above EMA21, RSI 56»`.
### BreakoutAgent — пробой полос Боллинджера
- **LONG**, если `%B > 0.92`; **SHORT**, если `%B < 0.08`; иначе **SKIP**.
- `score = clamp(|%B 0.5| × 1.7, 0, 1)`.
### MeanReversionAgent — возврат к среднему (фейд крайностей)
- **LONG**, если `rsi < 32` и `%B < 0.15`; **SHORT**, если `rsi > 68` и `%B > 0.85`; иначе **SKIP**.
- `score` растёт по мере удаления RSI от порога.
### FundingAgent — фейд перекошенного фандинга
- **SHORT**, если `funding-Z > 1.5` (лонги перегреты); **LONG**, если `funding-Z < 1.5`; иначе **SKIP**.
- `score = clamp(|funding-Z| / 3, 0, 1)`.
Для голосов SKIP score дополнительно занижается (×0.30.4) — пропуск тоже взвешивается.
---
## 5. Слой L4 — агрегация и вердикт (`src/lib/signals.ts`, `aggregate()`)
Голоса сводятся в итог `Verdict`:
```ts
{ decision, // LONG / SHORT / SKIP
confidence, // 0..1
sizePct, // рекомендуемый размер позиции, %
tpPct, slPct, // take-profit / stop-loss, %
reasoning, // текстовое объяснение
net } // чистый счёт голосов
```
**Алгоритм:**
1. `net = Σ (±1 × score)` по всем не-SKIP голосам (LONG = +, SHORT = ).
2. `agreement` = доля голосов, совпавших со знаком `net`.
3. Порог `thr = 0.5`: `net > 0.5 → LONG`, `net < 0.5 → SHORT`, иначе `SKIP`.
4. **Уверенность:**
- для сделки: `confidence = clamp(0.45 + |net|×0.18 + agreement×0.18, 0, 0.97)`;
- для SKIP: `clamp(0.3 + |net|×0.2, 0.18, 0.5)`.
5. **Параметры риска** (масштабируются от волатильности `vol = clamp(atrPct, 0.4, 6)`):
- `tpPct = clamp(vol × 1.6, 0.8, 6)`;
- `slPct = clamp(vol × 0.9, 0.6, 3.5)`;
- `sizePct = clamp(confidence × 3.2, 0.5, 3.2)`.
6. `reasoning` собирается из числа согласных агентов и причины лидирующего голоса,
например: `«1/1 agents align LONG. EMA9 above EMA21, RSI 56. RR 1.9»` (RR = TP/SL).
> Примечание по терминологии: в UI этот слой назван «LLMScreener / ConfidenceGate», но в
> текущей реализации это **детерминированная агрегация формулами**, а не вызов LLM.
> Порог уверенности (роль «ConfidenceGate») в воркере вынесен в `confFloor` (см. §6).
---
## 6. Слой L5 — paper-trading движок (`worker/paper.ts`)
Превращает вердикт в **бумажную** позицию и ведёт её по реальной цене Hyperliquid.
Режим жёстко `dry-run`: ордера на биржу **не подписываются и не отправляются** — это
симуляция исполнения на живых рыночных данных, считающая реальный PnL.
**Открытие — `maybeOpen(coin, price, verdict)`:**
- пропуск, если `decision = SKIP`, или `confidence < confFloor` (по умолчанию 0.55),
или по монете уже есть открытая позиция (`maxOpenPerCoin = 1`);
- `entry = price` (текущая mark-цена);
- `tp = entry × (1 ± tpPct/100)`, `sl = entry × (1 ∓ slPct/100)` (знак по направлению);
- `notional = equity × sizePct/100 × leverage` (по умолчанию leverage = 5);
- сохраняется `Position { id, coin, dir, entry, notional, tp, sl, tpPct, slPct, confidence, reason, openedAt }`.
**Ведение — `markToMarket(coin, price)`:** на каждом тике, если цена пересекла TP или SL,
позиция закрывается по уровню (исполнение по TP/SL предполагается точным).
**Закрытие — расчёт PnL:**
```
move = (exit entry) / entry
pnlPct = (dir = LONG ? move : move) × 100
pnlUsd = notional × pnlPct / 100
equity = equity + pnlUsd // капитал компаундируется
```
Закрытая сделка пишется в `Trade` (добавляются `exit, closedAt, pnlPct, pnlUsd, outcome
('tp'|'sl'), holdMin`).
**Статистика — `stats()` (источник команды `/pnl`):** число сделок, открытые позиции,
винрейт, суммарный PnL ($), ROI (%), profit factor, средний выигрыш/проигрыш, лучшая и
худшая сделка, текущий капитал.
---
## 7. Слой L6 — Telegram (`worker/telegram.ts`, `worker/loop.ts`)
Реализован на «голом» Bot API (без библиотек), через `fetch`.
**Исходящие — `tgSend()`:** `POST .../sendMessage` с `parse_mode: HTML`. Сообщения шлются
при **открытии** и **закрытии** позиции. Если токен/чат не заданы — текст печатается в
stdout (воркер работает и без Telegram).
Пример сообщения об открытии (реальный формат):
```
🟢 LONG BTC-PERP @ $62,135
size 2.6% · ×5 → $1,300 notional
TP +1.30% ($62,943) · SL -0.70% ($61,700)
confidence 81% · 1/1 agents align LONG. EMA9 above EMA21, RSI 56. RR 1.9.
```
Пример закрытия:
```
✅ TP BTC-PERP LONG
$62,135 → $62,943 (+1.30%, +$16.90)
held 2.4h · equity $10,017
```
**Входящие — `tgPoll()`:** long-polling `getUpdates` (offset-based), раз в ~3 c.
Поддерживаемые команды: `/status` (капитал, открытые позиции, винрейт), `/positions`
(детально по открытым), `/pnl` (он же `/learn` — сводная статистика по закрытым сделкам),
`/help`, `/start`.
> Терминология: команда `/learn` в текущей реализации возвращает **статистическую сводку**
> по закрытым сделкам, а не дообучение модели.
---
## 8. Воркер: рантайм (`worker/loop.ts`)
Постоянный фоновый цикл — работает независимо от того, открыт сайт или нет.
**Тик (`tick()`)** — по умолчанию раз в `pollMs = 60 000` мс:
1. один запрос `getMarketStats(coins)` на все монеты;
2. по каждой монете (`tickCoin`):
`getCandles``summarizeTA``screen` (голоса + вердикт) → `price = mark || ta.price`
`markToMarket` (закрыть, если задело TP/SL, + уведомление) → `maybeOpen` (открыть, если
есть сигнал, + уведомление) → запись в лог сигналов **при смене решения** по монете;
3. `save()` — сохранение состояния на диск.
Параллельно: `commandLoop()` раз в 3 c (если задан Telegram-токен).
**Запуск:**
```bash
pnpm worker # бесконечный цикл
pnpm worker:once # один тик и выход (смоук-тест)
```
---
## 9. Хранение состояния (`worker/store.ts`)
Один JSON-файл `worker/data/state.json``.gitignore`), запись **атомарная**
(во временный файл + `rename`). Переживает перезапуск. Структура `State`:
```ts
{ startedAt, mode,
equity, // текущий капитал
positions: Position[], // открытые
closed: Trade[], // закрытые (последние 500)
signals: SignalLog[], // лог решений при смене (последние 200)
equityCurve: { t, equity }[] } // кривая капитала (последние 1000 точек)
```
Это ровно тот формат, который будущий HTTP-эндпоинт (`/api/equity`, `/api/signals`) сможет
отдавать на сайт. (SQLite заменяема без изменения вызывающего кода — сейчас используется
JSON.)
---
## 10. Фронтенд (`src/`)
Две страницы (React Router, `src/App.tsx`):
- **`/` — лендинг** (`src/pages/Landing.tsx`): секции Hero, Thesis, Layers (6-слойный
конвейер), SignalDesk, AIBrief (ресёрч-агент), AgentRoster, Performance, Tokenomics,
TelegramModes, Roadmap, FAQ, CTA.
- **`/terminal` — живой терминал** (`src/pages/Terminal.tsx`): опрашивает Hyperliquid раз в
~12 c (`useScreen`), показывает: цену/изменение/фандинг/OI/объём/ATR, сетку индикаторов,
карточку вердикта, голоса агентов с барами уверенности, граф «оркестра» агентов.
**Seed-фолбэк** (`src/data/seedMarket.ts`): пока грузятся живые данные, мгновенно
показываются детерминированные значения (помечены как не-live), чтобы UI не «висел»; затем
заменяются реальными.
**Ресёрч-агент (FactsAI)** (`src/lib/factsai.ts`, `AIBrief.tsx`): интеграция с deep-research
API (AI-бриф рынка с цитатами источников). Требует прокси (CORS + ключ на сервере);
секция сама скрывается, если фид недоступен.
---
## 11. Статус реализации (по слоям)
Технический срез: что считается на живых данных, что симулируется, чего ещё нет.
| Компонент | Статус |
|---|---|
| L1: свечи, mark/mid, фандинг, OI, объём | ✅ Реально (Hyperliquid live) |
| L1: стакан, лента ликвидаций | ⚪ Не реализовано (только в описании ростера) |
| L2: RSI / MACD / EMA / ATR / Bollinger / funding-Z | ✅ Реально |
| L3: 4 агента (Trend / Breakout / MeanReversion / Funding) | ✅ Реально |
| L4: агрегация → вердикт + риск-параметры | ✅ Реально (формулы, **не LLM**) |
| L5: исполнение | 🟡 Paper / dry-run (реальный PnL, **без ордеров на бирже**) |
| L6: Telegram (уведомления + команды) | ✅ Реально |
| Персистентность | ✅ Реально (JSON; в копирайте сайта упомянут SQLite) |
| Ростер «18 агентов» | ⚪ Метаданные UI; реально голосуют **4** стратегии |
| Кривая капитала / «последние сделки» на сайте | 🟡 Seed-заглушка (live-данные даёт воркер, на сайт ещё не выведены) |
| FactsAI ресёрч-бриф | 🟡 Код есть, **выключен** (нужен прокси; upstream отдаёт ошибку) |
| Коннект кошелька / hold-to-access | ⚪ Косметика (после TGE) |
| Токен $QUANT (цена/контракт/холдеры) | ⚪ pre-TGE (ещё не выпущен) |
**В разработке (roadmap):** реальное исполнение ордеров на Hyperliquid (live-режим за
флагом); LLM-слой (обоснование вердиктов и осмысленный `/learn` через Claude); вывод
реального трек-рекорда воркера на сайт через `/api/*`.
---
## 12. Конфигурация (env, всё опционально — есть дефолты)
Читается воркером из `.env.local` (`worker/config.ts`):
| Переменная | Дефолт | Назначение |
|---|---|---|
| `QUANT_COINS` | `BTC,ETH,SOL` | торгуемая вселенная |
| `QUANT_INTERVAL` | `1h` | таймфрейм свечей |
| `QUANT_POLL_MS` | `60000` | период тика, мс |
| `QUANT_EQUITY_START` | `10000` | стартовый бумажный капитал, $ |
| `QUANT_LEVERAGE` | `5` | плечо для расчёта notional |
| `QUANT_CONF_FLOOR` | `0.55` | минимальная уверенность для входа |
| `QUANT_MAX_OPEN_PER_COIN` | `1` | одновременных позиций на монету |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | Telegram (без них — вывод в stdout) |
---
## 13. Глоссарий
- **Perp (перп)** — бессрочный фьючерс, контракт без даты экспирации.
- **Hyperliquid** — децентрализованная биржа перпов с открытым публичным API данных.
- **LONG / SHORT / SKIP** — ставка на рост / на падение / нет сигнала.
- **Funding (фандинг)** — периодический платёж между лонгами и шортами; перекос показывает
«перегретость» толпы. Положительная ставка → лонги платят шортам.
- **OI (Open Interest)** — открытый интерес, сумма открытых контрактов.
- **Mark price** — справедливая цена для расчётов (а не цена последней сделки).
- **RSI** — осциллятор 0100: >70 перекуплен, <30 перепродан.
- **MACD** — индикатор моментума по разнице скользящих средних.
- **EMA** — экспоненциальная скользящая средняя (тренд).
- **ATR** — мера волатильности (средний диапазон свечи).
- **Bollinger %B** — положение цены внутри полос Боллинджера (0 = низ, 1 = верх).
- **TP / SL** — take-profit / stop-loss.
- **RR (risk/reward)** — отношение потенциальной прибыли к риску (TP/SL).
- **Notional** — номинальный размер позиции в $ (капитал × размер% × плечо).
- **PnL** — прибыль/убыток. **Equity** — капитал. **Profit factor** — сумма прибылей / сумма убытков.
- **Paper-trading / dry-run** — «бумажная» торговля: расчёт как в реале, но без ордеров и денег.
- **Детерминированный** — при одинаковом входе всегда одинаковый результат (нет случайности).
- **pre-TGE** — до выпуска токена (Token Generation Event).
```

95
docs/token-strategy.md Normal file
View file

@ -0,0 +1,95 @@
# Quant Agent ($QUANT) — token utility & engagement strategy
The honest, shippable answer to: **"what does a user get?"** and **"what keeps them clicking?"**
Built only on what the product really is — a transparent, paper-trading multi-agent terminal on
Hyperliquid (pre-TGE). Vetted by an adversarial honesty/legal pass.
---
## 1. What's the profit for the user? (the one-liner)
> **Hold $QUANT to see the whole swarm in real time and steer what it watches — access and
> influence, never a promised return.**
Free users see a **delayed teaser** (one coin, lagged verdict). Holders unlock the **full live
committee** — every coin, every timeframe, real-time verdict flips to their Telegram, and a vote
on what the agents watch next. You're selling **access + influence + speed**, not a payout.
This is the safe, defensible thesis. The moment the pitch becomes "hold and earn" or "hold for
profitable signals", it becomes a securities + advice problem (see §4).
---
## 2. Token utility — the 5 mechanics to build (in order)
1. **Hold-to-access tiers (Free vs Holder).** Connect a Solana wallet, read $QUANT balance
client-side, gate UI depth. Free = 1 coin + delayed verdict; Holder = all coins, live verdict,
full agent vote-matrix + reasons. *Two tiers only.* This is the spine — everything hangs off it.
2. **Holder alerts (real-time vs delayed).** The worker already detects verdict flips and Telegram
is already wired. Map wallet→chat_id (one-time sign), DM holders instantly on "committee flipped"
/ "high-conviction (>0.9)". Free = delayed digest. *Lowest effort, highest perceived value.*
3. **Premium signal surface.** The brain is parameterized (`CONFIG.coins`/`interval`). Run more
coins + timeframes (15m/4h, top-20 perps); expose via the worker's `/api/state.json`. Breadth is
genuinely compute-scarce, so gating it is honest.
4. **Free prediction game (no staking).** Call LONG/SHORT/SKIP before the next candle, scored vs the
real price move (we already track prices + equity). Cosmetic ranks only. *Retention engine for
non-holders → seeds future holders.*
5. **Governance over the agents/markets.** Balance-weighted snapshot votes on real knobs the config
exposes — which coin/timeframe to add next, agent weights, the confidence floor. Real because the
worker loads the result. *Strongest non-financial reason to hold: influence over a live product.*
(#6 once liability copy is locked: a **"Open this setup on Hyperliquid"** deep-link that prefills
coin/side/size/TP/SL from the verdict — convenience, the user signs their own trade. Never
auto-execute.)
---
## 3. On-site engagement — features that make people click & return
Ranked by leverage. Most reuse data we already compute.
| # | Feature | Why it drives clicks | Effort |
|---|---|---|---|
| 1 | **Public agent track record** (honest paper PnL: equity curve, win rate, last fills) | The #1 "come back" hook — a live scoreboard of how the committee is actually doing. We already persist `equity/closed/equityCurve` in the worker. | M |
| 2 | **Predict-the-verdict game** + seasonal leaderboard | Daily reason to return: "can you read the agents better than the swarm?" | ML |
| 3 | **Shareable verdict cards** (X/Twitter image) | Free top-of-funnel — every share is an ad with the real signal. | M |
| 4 | **Tune the committee** (weight the 4 agents, re-run live) | Hands-on play → ownership; explore every coin/timeframe. | M |
| 5 | **Multi-coin signal board** (whole universe at a glance) | A reason to scan & click instead of staring at one coin. | M |
| 6 | **Watchlist + price/verdict alerts** | Classic retention — pulls the tab back. | M |
| 7 | **Backtest / replay slider** on the chart | Explorable toy — drag across coins/timeframes. | L |
| 8 | **Live committee ticker / "debate feed"** | Makes the desk feel alive, never static. | S |
| 9 | **Streaks & daily check-in** (cosmetic points) | Connective tissue that turns the above into a daily habit. | M |
---
## 4. Red lines — what NOT to do (honesty + legal)
The trust of an honest, paper-trading, pre-TGE product is the moat. Avoid:
- ❌ **Fee-split / revenue-share to holders** (the $AUTR "50% to holders" model) — dividend-like,
strongly implies a security. Differentiate with **access/utility/status, no holder cash flows**.
- ❌ **Points → guaranteed airdrop** framing — "earn now, tokens later" = expectation of profit.
Keep points as **pure status** (rank/badges/cosmetics); never pre-promise token value.
- ❌ **Stake-to-earn / stake-for-rewards** — yield/securities risk. Staking may unlock only
cosmetics/role, never payouts.
- ❌ **"Hold for profitable signals" / any return promise.** Sell access to *data/views*, not outcomes.
- ❌ **Auto-executing trades** on the user's behalf — custody + advice exposure. Deep-link only; the
user signs.
- ❌ **Fake track record / paid-pump signals.** Always label paper/dry-run.
Everything in §2§3 is designed to stay on the right side of these lines.
---
## 5. Pre-TGE acquisition (you have no token yet — use it)
- **Points/season program as pure status now** (no airdrop promise) → daily habit before the token.
- **Public track record + shareable cards** → organic reach proving the agents are real.
- **Telegram alpha bot** (already built) with a waitlist → capture users where crypto lives.
- **Named agent personalities** (Trend / Breakout / Mean-Rev / Funding as a "cast") → memorable,
screenshot-able, turns dry TA into a story.
---
*Strategy synthesised from a multi-agent pass (utility models · engagement · reference meta ·
adversarial honesty critic). Not legal advice — have token mechanics + copy reviewed before TGE.*

22
eslint.config.js Normal file
View file

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

43
index.html Normal file
View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0c0e" />
<!-- required verification tag -->
<meta name="base-verification" content="7f3a9c21-4e8b-4d52-a1c6-9b0e2f8d4471" />
<title>Quant Agent — a multi-agent quant terminal on Hyperliquid</title>
<meta
name="description"
content="A real-time quant terminal for Hyperliquid perps: live market data, in-browser technical analysis, and a committee of strategy agents that resolve to a LONG / SHORT / SKIP verdict with risk parameters."
/>
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Quant Agent — a multi-agent quant terminal on Hyperliquid" />
<meta
property="og:description"
content="Live Hyperliquid data → technical analysis → a committee of strategy agents → one LONG / SHORT / SKIP verdict with size, TP and SL."
/>
<meta property="og:image" content="/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/og.png" />
<!-- fonts: Geist (technical grotesque) · Geist Mono (data) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

52
package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "larp-quant-agent",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"gen:icons": "node scripts/gen-icons.mjs",
"worker": "tsx worker/loop.ts",
"worker:once": "tsx worker/loop.ts --once"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lightweight-charts": "^5.2.0",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"to-ico": "^1.1.5",
"tsx": "^4.22.4",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"unplugin-fonts": "^2.0.0",
"vite": "^8.0.12",
"vite-plugin-image-optimizer": "^2.0.3"
}
}

3827
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

53
proxy/factsai-worker.js Normal file
View file

@ -0,0 +1,53 @@
/**
* Cloudflare Worker proxy for the FactsAI research API.
*
* Why: a static site can't call FactsAI directly the API sends no
* Access-Control-Allow-Origin (browser blocks it) and a client key would be
* public. This worker holds the key server-side and adds CORS headers.
*
* Deploy:
* 1) npm i -g wrangler && wrangler login
* 2) wrangler secret put FACTSAI_KEY # paste the polyd_... key
* 3) wrangler deploy proxy/factsai-worker.js --name quant-factsai
* 4) set VITE_FACTSAI_PROXY=https://quant-factsai.<you>.workers.dev in .env.local, rebuild.
*
* Note: FactsAI must also fix their upstream 500 ("Service configuration error")
* before real answers come back this proxy only fixes CORS + key exposure.
*/
const ALLOW_ORIGIN = '*' // tighten to your domain in production
export default {
async fetch(request, env) {
const cors = {
'Access-Control-Allow-Origin': ALLOW_ORIGIN,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
}
if (request.method === 'OPTIONS') return new Response(null, { headers: cors })
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405, headers: cors })
}
try {
const body = await request.text()
const upstream = await fetch('https://deep-research-api.degodmode3-33.workers.dev/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.FACTSAI_KEY}`,
},
body,
})
const text = await upstream.text()
return new Response(text, {
status: upstream.status,
headers: { ...cors, 'Content-Type': 'application/json' },
})
} catch (e) {
return new Response(JSON.stringify({ success: false, error: String(e) }), {
status: 502,
headers: { ...cors, 'Content-Type': 'application/json' },
})
}
},
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

15
public/favicon.svg Normal file
View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#0b0c0e" />
<g transform="translate(50 50)">
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round">
<circle r="34" />
<path d="M0 -34 C -26 -18 -26 18 0 34" />
<path d="M0 -34 C 26 -18 26 18 0 34" />
<path d="M0 -34 C -13 -16 -13 16 0 34" />
<path d="M0 -34 C 13 -16 13 16 0 34" />
<path d="M-34 0 H 34" />
</g>
<ellipse cx="0" cy="-12" rx="12" ry="8.5" fill="#ffffff" />
<ellipse cx="0" cy="12" rx="12" ry="8.5" fill="#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 629 B

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

60
scripts/gen-icons.mjs Normal file
View file

@ -0,0 +1,60 @@
import sharp from 'sharp'
import toIco from 'to-ico'
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const pub = join(root, 'public')
const svg = readFileSync(join(pub, 'favicon.svg'))
// favicon.ico (16/32/48)
const pngs = await Promise.all(
[16, 32, 48].map((s) => sharp(svg, { density: 384 }).resize(s, s).png().toBuffer()),
)
writeFileSync(join(pub, 'favicon.ico'), await toIco(pngs))
// apple touch + pwa icon
await sharp(svg, { density: 512 }).resize(180, 180).png().toFile(join(pub, 'apple-touch-icon.png'))
await sharp(svg, { density: 512 }).resize(512, 512).png().toFile(join(pub, 'icon-512.png'))
// social card (og:image) — Quant Agent (wireframe globe)
const globe = (cx, cy, r, sw) =>
`<g transform="translate(${cx} ${cy})" fill="none" stroke="#ffffff" stroke-width="${sw}" stroke-linecap="round">
<circle r="${r}"/>
<path d="M0 ${-r} C ${-r * 0.76} ${-r * 0.53} ${-r * 0.76} ${r * 0.53} 0 ${r}"/>
<path d="M0 ${-r} C ${r * 0.76} ${-r * 0.53} ${r * 0.76} ${r * 0.53} 0 ${r}"/>
<path d="M0 ${-r} C ${-r * 0.38} ${-r * 0.47} ${-r * 0.38} ${r * 0.47} 0 ${r}"/>
<path d="M0 ${-r} C ${r * 0.38} ${-r * 0.47} ${r * 0.38} ${r * 0.47} 0 ${r}"/>
<path d="M${-r} 0 H ${r}"/>
</g>
<g transform="translate(${cx} ${cy})" fill="#ffffff">
<ellipse cx="0" cy="${-r * 0.35}" rx="${r * 0.35}" ry="${r * 0.25}"/>
<ellipse cx="0" cy="${r * 0.35}" rx="${r * 0.35}" ry="${r * 0.25}"/>
</g>`
const og = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#0b0c0e"/>
<g stroke="#ffffff" stroke-opacity="0.04">
${Array.from({ length: 12 }, (_, i) => `<line x1="${i * 100}" y1="0" x2="${i * 100}" y2="630"/>`).join('')}
${Array.from({ length: 7 }, (_, i) => `<line x1="0" y1="${i * 100}" x2="1200" y2="${i * 100}"/>`).join('')}
</g>
<g opacity="0.95">${globe(955, 315, 210, 5)}</g>
<g stroke="#ffffff" stroke-opacity="0.3" fill="#ffffff" fill-opacity="0.85">
<line x1="760" y1="150" x2="900" y2="240"/><circle cx="760" cy="150" r="5"/>
<line x1="1150" y1="420" x2="1010" y2="360"/><circle cx="1150" cy="420" r="5"/>
<line x1="820" y1="520" x2="930" y2="450"/><circle cx="820" cy="520" r="5"/>
</g>
<g transform="translate(99 116)">${globe(0, 0, 19, 2.6)}</g>
<text x="128" y="124" font-family="Arial, sans-serif" font-size="28" font-weight="800" fill="#e8eaed">Quant Agent</text>
<text x="80" y="322" font-family="Arial, sans-serif" font-size="74" font-weight="800" fill="#e8eaed">Four agents argue.</text>
<text x="80" y="408" font-family="Arial, sans-serif" font-size="74" font-weight="800" fill="#ffb020">One verdict.</text>
<text x="80" y="478" font-family="Arial, sans-serif" font-size="28" fill="#828a94">A multi-agent quant terminal for Hyperliquid perps live data, TA, LONG / SHORT / SKIP.</text>
<g transform="translate(80 528)">
<circle cx="8" cy="8" r="6" fill="#2bd974"/>
<text x="26" y="14" font-family="monospace" font-size="21" fill="#2bd974" letter-spacing="2">LIVE · HYPERLIQUID</text>
<text x="320" y="14" font-family="monospace" font-size="21" fill="#ffb020" letter-spacing="2">PAPER · DRY-RUN</text>
</g>
</svg>`
await sharp(Buffer.from(og)).png().toFile(join(pub, 'og.png'))
console.log('✓ icons + og generated')

65
skills-lock.json Normal file
View file

@ -0,0 +1,65 @@
{
"version": 1,
"skills": {
"add-x-tweet": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/add-x-tweet/SKILL.md",
"computedHash": "8c8da25f1134f021daace5c537b933ec47e5e9f7c1e068c043f57666b7f6fe64"
},
"bklit-playground": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/bklit-playground/SKILL.md",
"computedHash": "0ef6bb543bcb471fb57727216a28020d3da266f5d21be0b27b1163aa0e6f3588"
},
"bklit-ship": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/bklit-ship/SKILL.md",
"computedHash": "08ebf48f1b79f567a0a93606cfc77ea2a7dc13009f23e9d1bf51b656faeb89b5"
},
"bklit-studio": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/bklit-studio/SKILL.md",
"computedHash": "caba1f2d3b6e6588095df0f604289d7bb2f8eb84942b8436ffa4cdd97df8cf1a"
},
"bklit-studio-chart-performance": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/bklit-studio-chart-performance/SKILL.md",
"computedHash": "093d2ee09ed0a6ca307e2091ccc393896c49c8b0d614cc119b1222ba49dce988"
},
"bklit-ui": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": "skills/bklit-ui/SKILL.md",
"computedHash": "ace89f8b9a6d5d8f0367307f886cb9654065356db537df7b57b3d849aa503534"
},
"pr-open": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/pr-open/SKILL.md",
"computedHash": "90e2abbb893c87e951d1f3952d87c51e2c23c9d64423129f9b038ffc070cdb55"
},
"turborepo": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/turborepo/SKILL.md",
"computedHash": "65c73e2cbc0a64c52f8af30421789d59ba06365c27818beaf3e0f75ab72a28e0"
},
"unit-tests": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/unit-tests/SKILL.md",
"computedHash": "f304a5182f12945cc697ada0e81c0e349c6b1ea53876b094071f180294cbaa41"
},
"wiki-llms-txt": {
"source": "bklit/bklit-ui",
"sourceType": "github",
"skillPath": ".agents/skills/wiki-llms-text/SKILL.md",
"computedHash": "34cc980ec4dce21191b75d15bfd40818306f878dd37fd558a6e65d7883b7dd9d"
}
}
}

20
src/App.tsx Normal file
View file

@ -0,0 +1,20 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { Landing } from '@/pages/Landing'
const Terminal = lazy(() => import('@/pages/Terminal').then((m) => ({ default: m.Terminal })))
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div className="min-h-dvh bg-paper" />}>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/terminal" element={<Terminal />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
export default App

View file

@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { LogoMark } from '@/components/brand/Logo'
/** Fire this anywhere to replay the intro tour: window.dispatchEvent(new Event('quant:tour')) */
export const TOUR_EVENT = 'quant:tour'
const STEPS = [
{
title: 'Quant Agent',
body: 'A transparent quant terminal for Hyperliquid perps. Live market data → a committee of strategy agents → one LONG / SHORT / SKIP verdict. Paper-traded — no real orders.',
},
{
title: 'The orchestra',
body: 'Six layers run end to end: data → analysis → signal → decision → execution → comms. Hover any agent node to see what it does and whether its live, computed or planned.',
},
{
title: 'Operate it',
body: 'Switch coin & timeframe up top · hover any field to inspect it · click an agent to pin it across the views · ↻ to refresh. Keys: 1 / 2 / 3 coin · r refresh · Esc.',
},
]
export function Onboarding() {
const [open, setOpen] = useState(() => {
try {
return localStorage.getItem('quant.onboarded.v1') !== '1'
} catch {
return true
}
})
const [step, setStep] = useState(0)
const done = () => {
try {
localStorage.setItem('quant.onboarded.v1', '1')
} catch {
/* ignore */
}
setOpen(false)
}
// allow the Help panel (or anything) to replay the tour
useEffect(() => {
const replay = () => {
setStep(0)
setOpen(true)
}
window.addEventListener(TOUR_EVENT, replay)
return () => window.removeEventListener(TOUR_EVENT, replay)
}, [])
// Esc to dismiss
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
done()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
const last = step === STEPS.length - 1
const s = STEPS[step]
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label="Welcome to Quant Agent">
<div className="absolute inset-0 bg-paper/80 backdrop-blur-sm" onClick={done} />
<div className="reveal relative w-full max-w-md overflow-hidden rounded-lg border border-line bg-panel" style={{ animationDuration: '0.4s' }}>
<div className="absolute inset-x-0 top-0 h-0.5" style={{ background: 'var(--color-accent)' }} />
<div className="flex items-center gap-2.5 border-b border-line-soft px-5 py-3.5">
<LogoMark className="h-6 w-6" />
<span className="font-display text-base font-bold tracking-tight text-bone">
Quant<span className="text-muted"> Agent</span>
</span>
<span className="label-sm ml-auto !text-faint">
{step + 1} / {STEPS.length}
</span>
</div>
<div className="px-5 py-6">
<h2 className="font-display text-xl font-bold tracking-tight text-bone">{s.title}</h2>
<p key={step} className="reveal mt-3 font-mono text-read leading-relaxed text-bone-dim" style={{ animationDuration: '0.35s' }}>
{s.body}
</p>
</div>
<div className="flex items-center justify-between gap-3 border-t border-line-soft px-5 py-3.5">
<div className="flex items-center gap-1.5" aria-hidden>
{STEPS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setStep(i)}
className="h-1.5 w-5 cursor-pointer rounded-full transition-colors"
style={{ background: i === step ? 'var(--color-accent)' : 'var(--color-track)' }}
aria-label={`Step ${i + 1}`}
/>
))}
</div>
<div className="flex items-center gap-3">
{!last && (
<button type="button" onClick={done} className="cursor-pointer font-mono text-micro uppercase tracking-[0.08em] text-muted transition-colors hover:text-bone">
Skip
</button>
)}
<button
type="button"
onClick={() => (last ? done() : setStep(step + 1))}
className="cursor-pointer rounded-sm px-4 py-2 font-mono text-label font-semibold uppercase tracking-[0.06em] text-ink transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--color-accent)' }}
>
{last ? 'Enter terminal' : 'Next →'}
</button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
/** Wireframe-globe mark — the Quant Agent identity (white line-art globe + two cores). */
export function LogoMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 100 100" className={cn('h-6 w-6', className)} aria-hidden>
<g transform="translate(50 50)">
<g fill="none" stroke="var(--color-bone)" strokeWidth="3.4" strokeLinecap="round">
<circle r="34" />
<path d="M0 -34 C -26 -18 -26 18 0 34" />
<path d="M0 -34 C 26 -18 26 18 0 34" />
<path d="M0 -34 C -13 -16 -13 16 0 34" />
<path d="M0 -34 C 13 -16 13 16 0 34" />
<path d="M-34 0 H 34" />
</g>
<ellipse cx="0" cy="-12" rx="12" ry="8.5" fill="var(--color-bone)" />
<ellipse cx="0" cy="12" rx="12" ry="8.5" fill="var(--color-bone)" />
</g>
</svg>
)
}
export function Wordmark({ className }: { className?: string }) {
return (
<span className={cn('flex items-center gap-2.5', className)}>
<LogoMark />
<span className="font-display text-base font-bold tracking-tight text-bone">
Quant<span className="text-muted"> Agent</span>
</span>
</span>
)
}

View file

@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import { ConfidenceRing } from '@/components/viz/ConfidenceRing'
/**
* Clear, narrated walkthrough of one decision 5 numbered steps with a plain-language
* caption that updates as it plays. Deterministic canned LONG scenario; mirrors the real
* terminal so the explainer never overstates what the system does.
*/
const STEPS = [
{ n: 1, name: 'Data', say: 'Pull live candles, funding and open interest from Hyperliquid.' },
{ n: 2, name: 'Analysis', say: 'Recompute the indicators — RSI, MACD, EMA, ATR, Bollinger, funding-Z.' },
{ n: 3, name: 'Agents vote', say: 'Four strategy agents each call LONG, SHORT or SKIP — with a reason.' },
{ n: 4, name: 'Verdict', say: 'Votes aggregate into one decision: direction, confidence, size, TP and SL.' },
{ n: 5, name: 'Telegram', say: 'The call is paper-traded and pushed to Telegram. No real orders.' },
]
const AGENTS = [
{ label: 'Trend', reads: 'EMA 9/21 · RSI', dir: 'LONG', score: 0.82, reason: 'EMA9 above EMA21, RSI 58' },
{ label: 'Breakout', reads: 'Bollinger %B', dir: 'SKIP', score: 0.12, reason: 'Price mid-band (%B 0.55)' },
{ label: 'Mean-Rev', reads: 'RSI extremes', dir: 'SKIP', score: 0.05, reason: 'RSI 58 not extreme' },
{ label: 'Funding', reads: 'funding-Z', dir: 'LONG', score: 0.61, reason: 'Funding Z, shorts crowded' },
] as const
const col = (d: string) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
const bg = (d: string) =>
d === 'LONG' ? 'var(--color-long-dim)' : d === 'SHORT' ? 'var(--color-short-dim)' : 'var(--color-track)'
export function AgentFlow() {
const [step, setStep] = useState(0)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setStep(4)
return
}
const id = setInterval(() => setStep((s) => (s + 1) % STEPS.length), 2000)
return () => clearInterval(id)
}, [])
const agentsOn = step >= 2
const verdictOn = step >= 3
const tgOn = step >= 4
return (
<div className="overflow-hidden rounded-lg border border-line bg-panel">
{/* numbered step rail */}
<div className="flex flex-wrap items-center gap-2 border-b border-line-soft p-3 sm:gap-1">
{STEPS.map((s, i) => {
const on = i === step
const done = i < step
return (
<div key={s.n} className="flex items-center gap-2 sm:gap-1">
<button
type="button"
onClick={() => setStep(i)}
className="group flex cursor-pointer items-center gap-2 rounded-sm px-2.5 py-1.5 transition-colors"
style={{ background: on ? 'var(--color-accent-dim)' : 'transparent' }}
aria-current={on}
>
<span
className="tnum flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-mono text-[0.6rem] font-bold transition-all"
style={{
background: on ? 'var(--color-accent)' : done ? 'var(--color-track)' : 'transparent',
color: on ? 'var(--color-ink)' : done ? 'var(--color-bone)' : 'var(--color-muted)',
border: on || done ? 'none' : '1px solid var(--color-line)',
boxShadow: on ? '0 0 12px rgba(255,176,32,0.5)' : 'none',
}}
>
{s.n}
</span>
<span
className="font-mono text-micro font-medium uppercase tracking-[0.08em] transition-colors"
style={{ color: on ? 'var(--color-accent)' : done ? 'var(--color-bone-dim)' : 'var(--color-muted)' }}
>
{s.name}
</span>
</button>
{i < STEPS.length - 1 && <span className="font-mono text-xs text-faint"></span>}
</div>
)
})}
</div>
{/* plain-language narration for the active step */}
<div className="flex items-start gap-3 border-b border-line-soft bg-inset px-4 py-3.5">
<span className="label-sm shrink-0 pt-0.5 !text-accent">Step {STEPS[step].n}/5</span>
<p key={step} className="reveal font-mono text-read leading-relaxed text-bone" style={{ animationDuration: '0.5s' }}>
{STEPS[step].say}
</p>
</div>
{/* visual: committee → verdict (syncs to the steps) */}
<div className="grid gap-px bg-line-soft lg:grid-cols-[1.3fr_1fr]">
{/* committee */}
<div className="bg-panel p-4">
<div className="label mb-3 !text-bone-dim">Agent committee · 4 strategies</div>
<div className="space-y-3.5">
{AGENTS.map((a) => {
const signal = a.dir !== 'SKIP'
return (
<div key={a.label} className="grid grid-cols-[5.5rem_1fr] items-center gap-x-3">
<div className="self-center">
<div className="font-mono text-data font-medium text-bone">{a.label}</div>
<div className="label-sm !text-faint">{a.reads}</div>
</div>
<div className="flex items-center gap-3">
<div className="relative h-2.5 flex-1 overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div className="absolute inset-y-0 left-1/2 w-px" style={{ background: 'var(--color-line-strong)' }} />
<div
className="absolute inset-y-0.5 rounded-r-full"
style={{
left: '50%',
width: agentsOn && signal ? `${a.score * 50}%` : '0%',
background: col(a.dir),
transition: 'width .7s cubic-bezier(.22,1,.36,1)',
}}
/>
{!signal && (
<div
className="absolute inset-y-0.5 left-1/2 w-1 -translate-x-1/2 rounded-full transition-opacity duration-500"
style={{ background: 'var(--color-muted)', opacity: agentsOn ? 1 : 0 }}
/>
)}
</div>
<span
className="w-14 shrink-0 rounded-sm px-1.5 py-0.5 text-center font-mono text-micro font-semibold uppercase tracking-[0.04em] transition-opacity duration-500"
style={{ color: col(a.dir), background: bg(a.dir), opacity: agentsOn ? 1 : 0.25 }}
>
{signal ? a.dir : 'skip'}
</span>
</div>
</div>
)
})}
</div>
</div>
{/* verdict */}
<div className="relative bg-panel p-4">
<div className="absolute inset-x-0 top-0 h-0.5 transition-opacity duration-500" style={{ background: 'var(--color-accent)', opacity: verdictOn ? 1 : 0 }} />
<div className="label mb-3 !text-bone-dim">Verdict</div>
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<div
className="font-display text-[2.6rem] font-bold leading-none tracking-tight transition-all duration-500"
style={{ color: 'var(--color-long)', opacity: verdictOn ? 1 : 0.15 }}
>
{verdictOn ? 'LONG' : '—'}
</div>
<div className="mt-2 label-sm transition-opacity duration-500" style={{ opacity: verdictOn ? 1 : 0 }}>
2 of 4 agents align · the rest skip
</div>
</div>
<ConfidenceRing value={verdictOn ? 0.81 : 0} tone="var(--color-long)" size={92} label="CONF" />
</div>
<div className="mt-4 grid grid-cols-3 gap-px overflow-hidden rounded-sm border border-line-soft bg-line-soft transition-opacity duration-500" style={{ opacity: verdictOn ? 1 : 0.25 }}>
{[
['Size', '2.6%', 'var(--color-bone)'],
['TP', '+1.7%', 'var(--color-long)'],
['SL', '0.9%', 'var(--color-short)'],
].map(([l, v, c]) => (
<div key={l} className="bg-inset px-3 py-2.5">
<div className="label-sm">{l}</div>
<div className="tnum mt-1 font-mono text-data font-medium" style={{ color: c }}>{v}</div>
</div>
))}
</div>
<div
className="mt-3 flex items-center gap-2 rounded-sm border border-line-soft bg-inset px-3 py-2 font-mono text-micro text-bone-dim transition-all duration-500"
style={{ opacity: tgOn ? 1 : 0, transform: tgOn ? 'none' : 'translateY(6px)' }}
>
<span aria-hidden>🟢</span>
<span className="tnum">LONG BTC-PERP @ $63,540 · TP +1.7% · SL 0.9% · 81%</span>
<span className="ml-auto label-sm !text-accent"> telegram</span>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,175 @@
import { useEffect, useRef } from 'react'
/**
* Rotating wireframe globe with a live network overlay the "global AI agent" hero,
* matching the brand banner. Latitude/longitude lines (depth-shaded) + pulsing network
* nodes. Performance-first: thin lines, no shadowBlur, ~30fps, DPR capped, paused offscreen.
*/
export function GlobeCanvas({ className }: { className?: string }) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = ref.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const tilt = -0.42
let w = 0
let h = 0
let raf = 0
let angle = 0
let visible = true
const NODES = [
{ lat: 30, lon: 18 },
{ lat: -14, lon: 72 },
{ lat: 52, lon: 134 },
{ lat: -36, lon: 205 },
{ lat: 10, lon: 262 },
{ lat: 42, lon: 318 },
]
const EDGES: [number, number][] = [
[0, 1],
[1, 4],
[2, 5],
[0, 5],
[3, 4],
[2, 3],
]
const project = (latDeg: number, lonDeg: number, R: number, cx: number, cy: number) => {
const lat = (latDeg * Math.PI) / 180
const lon = ((lonDeg + angle) * Math.PI) / 180
const x = Math.cos(lat) * Math.sin(lon)
const y = Math.sin(lat)
const z = Math.cos(lat) * Math.cos(lon)
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt)
const z2 = y * Math.sin(tilt) + z * Math.cos(tilt)
return { px: cx + x * R, py: cy - y2 * R, z: z2 }
}
const resize = () => {
const r = canvas.getBoundingClientRect()
w = r.width
h = r.height
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
// stroke a polyline of projected points, alpha shaded by depth per segment
const arc = (pts: { px: number; py: number; z: number }[]) => {
ctx.lineWidth = 1
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1]
const b = pts[i]
const z = (a.z + b.z) / 2
ctx.strokeStyle = `rgba(255,255,255,${z > 0 ? 0.1 + 0.4 * z : 0.05})`
ctx.beginPath()
ctx.moveTo(a.px, a.py)
ctx.lineTo(b.px, b.py)
ctx.stroke()
}
}
const draw = () => {
ctx.clearRect(0, 0, w, h)
const R = Math.min(w, h) * 0.5
const cx = w * 0.52
const cy = h * 0.5
// parallels (latitude rings)
for (let la = -80; la <= 80; la += 20) {
const pts = []
for (let lo = 0; lo <= 360; lo += 10) pts.push(project(la, lo, R, cx, cy))
arc(pts)
}
// meridians (longitude)
for (let lo = 0; lo < 360; lo += 30) {
const pts = []
for (let la = -90; la <= 90; la += 9) pts.push(project(la, lo, R, cx, cy))
arc(pts)
}
// silhouette
ctx.strokeStyle = 'rgba(255,255,255,0.16)'
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.arc(cx, cy, R, 0, Math.PI * 2)
ctx.stroke()
// network edges
const proj = NODES.map((n) => project(n.lat, n.lon, R, cx, cy))
ctx.lineWidth = 1
for (const [a, b] of EDGES) {
const pa = proj[a]
const pb = proj[b]
if (pa.z > -0.1 && pb.z > -0.1) {
ctx.strokeStyle = `rgba(255,255,255,${0.12 + 0.22 * Math.min(pa.z, pb.z)})`
ctx.beginPath()
ctx.moveTo(pa.px, pa.py)
ctx.lineTo(pb.px, pb.py)
ctx.stroke()
}
}
// node markers + pulse
const pulse = (Math.sin(angle * 0.05) + 1) / 2
proj.forEach((p) => {
if (p.z <= 0) return
ctx.fillStyle = `rgba(255,255,255,${0.55 + 0.45 * p.z})`
ctx.beginPath()
ctx.arc(p.px, p.py, 2.6, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = `rgba(255,255,255,${0.22 * p.z * (1 - pulse)})`
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(p.px, p.py, 3 + pulse * 8, 0, Math.PI * 2)
ctx.stroke()
})
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
const io = new IntersectionObserver((e) => {
visible = e[0]?.isIntersecting ?? true
})
io.observe(canvas)
if (reduce) {
draw()
return () => {
ro.disconnect()
io.disconnect()
}
}
const FRAME = 1000 / 30
let last = 0
const loop = (t: number) => {
raf = requestAnimationFrame(loop)
if (!visible) {
last = t
return
}
const dt = t - last
if (dt < FRAME) return
last = t
angle += Math.min(dt, 60) * 0.0045
draw()
}
raf = requestAnimationFrame(loop)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
io.disconnect()
}
}, [])
return <canvas ref={ref} className={className} aria-hidden />
}

View file

@ -0,0 +1,146 @@
import { useEffect, useRef } from 'react'
/**
* Living candlestick stream behind the hero. Performance-first: NO per-candle
* shadowBlur (the canvas perf killer) crisp bright candles on black read as neon,
* with a cheap 2-pass glow only on the amber price line. Throttled to ~30fps,
* DPR capped, paused offscreen + on prefers-reduced-motion.
*/
export function HeroCanvas({ className }: { className?: string }) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = ref.current
if (!canvas) return
const ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
const CW = 16
let w = 0
let h = 0
let raf = 0
let offset = 0
let visible = true
type C = { o: number; h: number; l: number; c: number }
let candles: C[] = []
let price = 100
const next = (): C => {
const o = price
const c = Math.max(20, o + (Math.random() - 0.485) * 3)
const hi = Math.max(o, c) + Math.random() * 2
const lo = Math.min(o, c) - Math.random() * 2
price = c
return { o, h: hi, l: lo, c }
}
const resize = () => {
const r = canvas.getBoundingClientRect()
w = r.width
h = r.height
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const need = Math.ceil(w / CW) + 3
while (candles.length < need) candles.push(next())
if (candles.length > need) candles = candles.slice(-need)
}
const draw = () => {
ctx.clearRect(0, 0, w, h)
let min = Infinity
let max = -Infinity
for (const k of candles) {
if (k.l < min) min = k.l
if (k.h > max) max = k.h
}
const pad = (max - min) * 0.18 || 1
min -= pad
max += pad
const Y = (p: number) => h - ((p - min) / (max - min)) * h
const bw = CW * 0.52
ctx.lineWidth = 1.5
for (let i = 0; i < candles.length; i++) {
const k = candles[i]
const x = i * CW - offset + CW / 2
const col = k.c >= k.o ? '#2bd974' : '#f2444d'
ctx.strokeStyle = col
ctx.fillStyle = col
ctx.beginPath()
ctx.moveTo(x, Y(k.h))
ctx.lineTo(x, Y(k.l))
ctx.stroke()
const yo = Y(k.o)
const yc = Y(k.c)
ctx.fillRect(x - bw / 2, Math.min(yo, yc), bw, Math.max(2, Math.abs(yc - yo)))
}
// amber "current price" line — cheap glow via wide-faint + thin-bright (no shadowBlur)
const ly = Y(price)
ctx.setLineDash([2, 7])
ctx.strokeStyle = 'rgba(255,176,32,0.18)'
ctx.lineWidth = 6
ctx.beginPath()
ctx.moveTo(0, ly)
ctx.lineTo(w, ly)
ctx.stroke()
ctx.strokeStyle = 'rgba(255,176,32,0.9)'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(0, ly)
ctx.lineTo(w, ly)
ctx.stroke()
ctx.setLineDash([])
}
resize()
const ro = new ResizeObserver(resize)
ro.observe(canvas)
const io = new IntersectionObserver((e) => {
visible = e[0]?.isIntersecting ?? true
})
io.observe(canvas)
if (reduce) {
draw()
return () => {
ro.disconnect()
io.disconnect()
}
}
const FRAME = 1000 / 30 // throttle to ~30fps
let last = 0
const loop = (t: number) => {
raf = requestAnimationFrame(loop)
if (!visible) {
last = t
return
}
const dt = t - last
if (dt < FRAME) return
last = t
offset += Math.min(dt, 60) * 0.024
if (offset >= CW) {
offset -= CW
candles.push(next())
candles.shift()
}
draw()
}
raf = requestAnimationFrame(loop)
return () => {
cancelAnimationFrame(raf)
ro.disconnect()
io.disconnect()
}
}, [])
return <canvas ref={ref} className={className} aria-hidden />
}

View file

@ -0,0 +1,54 @@
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
export const Accordion = AccordionPrimitive.Root
export const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b border-line-soft', className)}
{...props}
/>
))
AccordionItem.displayName = 'AccordionItem'
export const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'group flex flex-1 items-center justify-between gap-4 py-5 text-left font-display text-lg font-semibold text-bone transition-colors hover:text-bone [&[data-state=open]]:text-bone cursor-pointer',
className,
)}
{...props}
>
{children}
<Plus className="h-5 w-5 shrink-0 text-muted transition-transform duration-300 group-data-[state=open]:rotate-45 group-data-[state=open]:text-bone" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = 'AccordionTrigger'
export const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden data-[state=closed]:animate-[accordion-up_200ms_ease] data-[state=open]:animate-[accordion-down_240ms_ease]"
{...props}
>
<div className={cn('max-w-2xl pb-6 font-mono text-[0.9rem] leading-relaxed text-bone-dim', className)}>
{children}
</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = 'AccordionContent'

View file

@ -0,0 +1,38 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1.5 font-mono text-[0.65rem] uppercase tracking-[0.14em] px-2 py-0.5 border',
{
variants: {
tone: {
lime: 'border-lime/50 text-lime bg-lime/[0.08]',
amber: 'border-bone/30 text-bone bg-bone/[0.04]',
coral: 'border-coral/50 text-coral bg-coral/[0.08]',
muted: 'border-line text-muted bg-transparent',
},
},
defaultVariants: { tone: 'muted' },
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
dot?: boolean
}
export function Badge({ className, tone, dot, children, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ tone }), className)} {...props}>
{dot && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
)}
{children}
</span>
)
}

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-mono font-bold uppercase tracking-[0.08em] transition-colors duration-150 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bone focus-visible:ring-offset-2 focus-visible:ring-offset-paper',
{
variants: {
variant: {
// Swiss: solid ink button, inverts to outline-on-hover
primary: 'bg-bone text-ink border border-bone hover:bg-paper hover:text-bone',
// green LONG-signal button
amber: 'bg-lime text-ink border border-lime hover:bg-paper hover:text-lime',
outline: 'border border-bone text-bone hover:bg-bone hover:text-ink',
ghost: 'text-muted hover:text-bone hover:bg-bone/[0.06]',
link: 'text-bone underline underline-offset-4 decoration-1 hover:decoration-2 p-0 h-auto',
},
size: {
sm: 'h-9 px-4 text-[0.7rem]',
md: 'h-11 px-6 text-xs',
lg: 'h-14 px-8 text-sm',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
)
},
)
Button.displayName = 'Button'
export { buttonVariants }

View file

@ -0,0 +1,34 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[110] bg-bone/40 backdrop-blur-[2px] data-[state=open]:animate-[fade-in_200ms_ease]" />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-[111] w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 border border-rule bg-paper p-6 shadow-[0_24px_60px_-20px_rgba(10,10,10,0.35)] outline-none data-[state=open]:animate-[fade-in_240ms_ease]',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 text-muted transition-colors hover:text-bone focus-visible:outline-none">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
DialogContent.displayName = 'DialogContent'
export const DialogTitle = DialogPrimitive.Title
export const DialogDescription = DialogPrimitive.Description

View file

@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
/** Shimmer placeholder for live cards while data loads. */
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'relative overflow-hidden bg-bone/[0.06]',
'after:absolute after:inset-0 after:-translate-x-full after:animate-[shimmer_1.6s_infinite]',
'after:bg-gradient-to-r after:from-transparent after:via-bone/10 after:to-transparent',
className,
)}
{...props}
/>
)
}

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
export const Tabs = TabsPrimitive.Root
export const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('inline-flex items-center gap-1 border border-line-soft bg-surface/60 p-1', className)}
{...props}
/>
))
TabsList.displayName = 'TabsList'
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center gap-1.5 px-3 py-1.5 font-mono text-xs font-medium uppercase tracking-wider text-muted transition-all cursor-pointer',
'hover:text-bone data-[state=active]:bg-bone data-[state=active]:text-ink',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bone',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = 'TabsTrigger'
export const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('focus-visible:outline-none data-[state=active]:animate-[fade-in_300ms_ease]', className)}
{...props}
/>
))
TabsContent.displayName = 'TabsContent'

View file

@ -0,0 +1,396 @@
import { useEffect, useState } from 'react'
import type { CoinScreen } from '@/hooks/useHyperliquid'
import type { Direction } from '@/lib/signals'
import { fmtPrice, fmtCompact } from '@/lib/format'
/**
* Agent workflow the pipeline as a spatial directed graph. Real market data
* enters at the left (Market TA engine), fans out to the 4 committee agents,
* converges through the Risk manager into the Verdict (the call), then flows on
* to paper Execution and Telegram. Data "packets" animate edge-by-edge so you
* watch information pass from one agent to the next, like a workflow canvas.
*
* Every node is clickable a detail panel (its role + live output). Hovering a
* node traces its connected path and cross-links the Orchestra + Committee. The
* 4 agents stay in sync with `activeAgent`. Honest Carbon-Desk palette: amber is
* only "the call" / selection; green/red only direction; structure stays carbon.
*/
const sign = (n: number) => (n >= 0 ? '+' : '')
const dirColor = (d?: Direction) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
const BONE = 'var(--color-bone)'
const MUTED = 'var(--color-muted)'
const FAINT = 'var(--color-faint)'
const ACCENT = 'var(--color-accent)'
const AGENT_KEYS = ['TrendAgent', 'BreakoutAgent', 'MeanReversionAgent', 'FundingAgent']
const AGENT_RULE: Record<string, string> = {
TrendAgent: 'LONG when EMA9 > EMA21 and RSI > 50; SHORT on the reverse; else SKIP.',
BreakoutAgent: 'LONG when price pushes the upper Bollinger band (%B > 0.92); SHORT at the lower (< 0.08).',
MeanReversionAgent: 'Fade extremes — LONG when RSI < 32 in the lower band; SHORT when RSI > 68 at the upper.',
FundingAgent: 'Fade crowded funding — SHORT when funding-Z > 1.5; LONG when < 1.5.',
}
/* ── graph model (viewBox space 1000 × 300) ───────────────────────────────── */
const VB_W = 1000
const VB_H = 300
type Kind = 'data' | 'agent' | 'risk' | 'call' | 'paper' | 'comms'
type GNode = { key: string; label: string; code: string; cx: number; cy: number; w: number; h: number; kind: Kind }
const NODES: GNode[] = [
{ key: 'market', label: 'Market', code: 'L1', cx: 70, cy: 150, w: 96, h: 46, kind: 'data' },
{ key: 'ta', label: 'TA engine', code: 'L2', cx: 198, cy: 150, w: 114, h: 46, kind: 'data' },
{ key: 'TrendAgent', label: 'Trend', code: 'L3', cx: 366, cy: 50, w: 132, h: 46, kind: 'agent' },
{ key: 'BreakoutAgent', label: 'Breakout', code: 'L3', cx: 366, cy: 116, w: 132, h: 46, kind: 'agent' },
{ key: 'MeanReversionAgent', label: 'Mean-Rev', code: 'L3', cx: 366, cy: 182, w: 132, h: 46, kind: 'agent' },
{ key: 'FundingAgent', label: 'Funding', code: 'L3', cx: 366, cy: 248, w: 132, h: 46, kind: 'agent' },
{ key: 'risk', label: 'Risk mgr', code: 'L3', cx: 544, cy: 150, w: 112, h: 46, kind: 'risk' },
{ key: 'verdict', label: 'Verdict', code: 'L4', cx: 698, cy: 150, w: 128, h: 58, kind: 'call' },
{ key: 'exec', label: 'Execution', code: 'L5', cx: 846, cy: 150, w: 112, h: 46, kind: 'paper' },
{ key: 'telegram', label: 'Telegram', code: 'L6', cx: 958, cy: 150, w: 90, h: 46, kind: 'comms' },
]
const NODE = Object.fromEntries(NODES.map((n) => [n.key, n])) as Record<string, GNode>
type EdgeTone = 'data' | 'agent' | 'call' | 'comms'
type GEdge = { from: string; to: string; tone: EdgeTone; agent?: string }
const EDGES: GEdge[] = [
{ from: 'market', to: 'ta', tone: 'data' },
{ from: 'ta', to: 'TrendAgent', tone: 'data' },
{ from: 'ta', to: 'BreakoutAgent', tone: 'data' },
{ from: 'ta', to: 'MeanReversionAgent', tone: 'data' },
{ from: 'ta', to: 'FundingAgent', tone: 'data' },
{ from: 'TrendAgent', to: 'risk', tone: 'agent', agent: 'TrendAgent' },
{ from: 'BreakoutAgent', to: 'risk', tone: 'agent', agent: 'BreakoutAgent' },
{ from: 'MeanReversionAgent', to: 'risk', tone: 'agent', agent: 'MeanReversionAgent' },
{ from: 'FundingAgent', to: 'risk', tone: 'agent', agent: 'FundingAgent' },
{ from: 'risk', to: 'verdict', tone: 'data' },
{ from: 'verdict', to: 'exec', tone: 'call' },
{ from: 'exec', to: 'telegram', tone: 'comms' },
]
const COL: Record<string, number> = { market: 0, ta: 1, TrendAgent: 2, BreakoutAgent: 2, MeanReversionAgent: 2, FundingAgent: 2, risk: 3, verdict: 4, exec: 5, telegram: 6 }
function edgePath(a: GNode, b: GNode) {
const x1 = a.cx + a.w / 2
const y1 = a.cy
const x2 = b.cx - b.w / 2
const y2 = b.cy
const dx = Math.max(26, (x2 - x1) * 0.5)
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
}
function useReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const m = window.matchMedia('(prefers-reduced-motion: reduce)')
const on = () => setReduced(m.matches)
on()
m.addEventListener?.('change', on)
return () => m.removeEventListener?.('change', on)
}, [])
return reduced
}
/* ── live detail per node ─────────────────────────────────────────────────── */
type Detail = { code: string; title: string; tag: string; tagColor: string; role: string; output: string }
function detailFor(key: string, d: CoinScreen | null, decision: Direction): Detail | null {
const node = NODE[key]
if (!node) return null
const ta = d?.ta
const stat = d?.stat
const v = d?.verdict
if (AGENT_KEYS.includes(key)) {
const vote = d?.votes.find((x) => x.agent === key)
const dir = vote?.direction ?? 'SKIP'
return {
code: 'L3 · SIGNAL', title: node.label, tag: dir === 'SKIP' ? 'skip' : dir, tagColor: dirColor(dir),
role: AGENT_RULE[key] ?? '', output: vote ? `${dir} · score ${(vote.score * 100).toFixed(0)}${vote.reason}` : '—',
}
}
switch (key) {
case 'market':
return { code: 'L1 · DATA', title: 'Market data', tag: 'live', tagColor: BONE,
role: 'Polls OHLCV candles, mark price, funding & open interest from the Hyperliquid public API.',
output: stat ? `mark $${fmtPrice(stat.mark)} · 24h ${sign(ta?.changePct ?? 0)}${(ta?.changePct ?? 0).toFixed(2)}% · OI $${fmtCompact(stat.oiUsd)}` : 'connecting…' }
case 'ta':
return { code: 'L2 · ANALYSIS', title: 'TA engine', tag: 'calc', tagColor: MUTED,
role: 'Recomputes RSI / MACD / EMA / ATR / Bollinger / funding-Z in-browser on every candle.',
output: ta ? `RSI ${ta.rsi.toFixed(1)} · %B ${ta.pctB.toFixed(2)} · ATR ${ta.atrPct.toFixed(2)}% · funding-Z ${ta.fundingZ.toFixed(2)}` : 'connecting…' }
case 'risk':
return { code: 'L3 · RISK', title: 'Risk manager', tag: 'calc', tagColor: MUTED,
role: 'Caps size, drawdown & correlation — confidence-floor gate plus one position per coin.',
output: v ? `gate ${v.confidence >= 0.55 ? 'open' : 'hold'} · conf floor 0.55 · 1 position / coin` : '—' }
case 'verdict':
return { code: 'L4 · DECISION', title: 'Screener — the call', tag: decision, tagColor: dirColor(decision),
role: 'Aggregates the committee votes into the final decision: confidence, size, TP and SL (deterministic).',
output: v ? `${v.decision} · conf ${(v.confidence * 100).toFixed(0)}% · size ${v.sizePct.toFixed(1)}% · TP +${v.tpPct.toFixed(1)}% / SL -${Math.abs(v.slPct).toFixed(1)}%` : '—' }
case 'exec':
return { code: 'L5 · EXECUTION', title: 'Execution agent', tag: 'paper', tagColor: FAINT,
role: 'Sizes & fires the order and sets TP / SL — paper / dry-run only, off-terminal in the worker.',
output: v ? (v.decision === 'SKIP' ? 'no position · SKIP (paper / dry-run)' : `paper · ${v.decision} ${v.sizePct.toFixed(1)}% · TP +${v.tpPct.toFixed(1)}% / SL -${Math.abs(v.slPct).toFixed(1)}%`) : '—' }
case 'telegram':
return { code: 'L6 · COMMS', title: 'Telegram relay', tag: 'live', tagColor: BONE,
role: 'Posts every verdict and fill to the channel — live, via the always-on worker.',
output: v ? `verdict ${v.decision} relayed to channel` : '—' }
default:
return null
}
}
/* ── node visual derivation ───────────────────────────────────────────────── */
function nodeVisual(n: GNode, d: CoinScreen | null, decision: Direction) {
if (n.kind === 'agent') {
const vote = d?.votes.find((x) => x.agent === n.key)
const dir = vote?.direction ?? 'SKIP'
return { dot: dirColor(dir), tag: dir === 'SKIP' ? 'SKIP' : dir, tagColor: dirColor(dir), nameColor: BONE, dim: false }
}
switch (n.kind) {
case 'data':
return { dot: BONE, tag: n.key === 'ta' ? 'CALC' : 'LIVE', tagColor: FAINT, nameColor: BONE, dim: false }
case 'risk':
return { dot: MUTED, tag: 'CALC', tagColor: FAINT, nameColor: 'var(--color-bone-dim)', dim: false }
case 'call':
return { dot: dirColor(decision), tag: decision, tagColor: dirColor(decision), nameColor: dirColor(decision), dim: false }
case 'paper':
return { dot: FAINT, tag: 'PAPER', tagColor: FAINT, nameColor: 'var(--color-bone-dim)', dim: true }
case 'comms':
return { dot: BONE, tag: 'LIVE', tagColor: FAINT, nameColor: BONE, dim: false }
default:
return { dot: BONE, tag: '', tagColor: FAINT, nameColor: BONE, dim: false }
}
}
/* ── component ────────────────────────────────────────────────────────────── */
export function AgentWorkflow({
data,
decision,
activeAgent,
onSelect,
onHover,
onInspect,
updatedAt,
live,
loading,
}: {
data: CoinScreen | null
decision: Direction
activeAgent?: string | null
onSelect?: (a: string | null) => void
onHover?: (a: string | null) => void
onInspect?: (s: string | null) => void
updatedAt: number
live: boolean
loading?: boolean
}) {
const reduced = useReducedMotion()
const [hoverKey, setHoverKey] = useState<string | null>(null)
const [selKey, setSelKey] = useState<string | null>(null)
// committee selection (activeAgent) also lights / details the matching agent node
const externalAgent = activeAgent && AGENT_KEYS.includes(activeAgent) ? activeAgent : null
const litKey = hoverKey ?? selKey ?? externalAgent
const detailKey = selKey ?? externalAgent
const detail = detailKey ? detailFor(detailKey, data, decision) : null
const onNode = (key: string) => {
const next = selKey === key ? null : key
setSelKey(next)
// keep the committee in one shared state: agent node pins that agent, any other node clears it
onSelect?.(AGENT_KEYS.includes(key) ? next : null)
}
const onEnter = (key: string) => {
setHoverKey(key)
if (AGENT_KEYS.includes(key)) onHover?.(key)
const dt = detailFor(key, data, decision)
if (dt) onInspect?.(`${dt.code} · ${dt.title}${dt.output}`)
}
const onLeave = () => {
setHoverKey(null)
onHover?.(null)
onInspect?.(null)
}
const onKey = (e: React.KeyboardEvent, key: string) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onNode(key)
}
}
const edgeColor = (e: GEdge) => {
if (e.tone === 'call') return ACCENT
if (e.tone === 'agent') return dirColor(data?.votes.find((v) => v.agent === e.agent)?.direction)
if (e.tone === 'comms') return BONE
return 'rgba(232, 234, 237, 0.45)'
}
return (
<section className="overflow-hidden rounded-md border border-line bg-panel">
<header className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<div className="flex items-baseline gap-2.5">
<span className="label !text-[0.625rem]">Agent flow</span>
<span className="hidden font-mono text-micro text-faint md:inline">click a node · hover to trace the path</span>
</div>
<span className="flex items-center gap-1.5 font-mono text-[0.5625rem] uppercase tracking-[0.1em]" style={{ color: live ? 'var(--color-long)' : 'var(--color-faint)' }}>
<span className={live ? 'blink inline-block h-1.5 w-1.5 rounded-full' : 'inline-block h-1.5 w-1.5 rounded-full'} style={{ background: live ? 'var(--color-long)' : 'var(--color-faint)' }} />
{live ? 'flowing' : updatedAt > 0 ? 'paused' : 'connecting'}
</span>
</header>
<div className="overflow-x-auto bg-inset px-2 py-3 sm:px-3">
<div className="mx-auto" style={{ maxWidth: 1140 }}>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
preserveAspectRatio="xMidYMid meet"
role="group"
aria-label="Agent workflow graph"
style={{ display: 'block', width: '100%', minWidth: 760, height: 'auto' }}
>
{/* edges */}
<g fill="none">
{EDGES.map((e) => {
const a = NODE[e.from]
const b = NODE[e.to]
const d = edgePath(a, b)
const col = edgeColor(e)
const lit = litKey != null && (e.from === litKey || e.to === litKey)
const dimmed = litKey != null && !lit
return (
<g key={`${e.from}-${e.to}`}>
<path
className="wf-edge"
d={d}
stroke={col}
strokeWidth={lit ? 2 : 1.25}
strokeLinecap="round"
style={{ opacity: dimmed ? 0.18 : lit ? 0.95 : 0.5 }}
/>
{/* arrowhead at the target end (edges arrive ~horizontally) */}
<path
d={`M ${b.cx - b.w / 2 - 7} ${b.cy - 3.5} L ${b.cx - b.w / 2 - 0.5} ${b.cy} L ${b.cx - b.w / 2 - 7} ${b.cy + 3.5} Z`}
fill={col}
style={{ opacity: dimmed ? 0.18 : lit ? 0.95 : 0.55 }}
/>
</g>
)
})}
</g>
{/* animated data packets (SMIL — scales with viewBox; off under reduced-motion) */}
{!reduced && !loading && (
<g key={updatedAt || 'idle'}>
{EDGES.map((e) => {
const a = NODE[e.from]
const b = NODE[e.to]
const d = edgePath(a, b)
const col = edgeColor(e)
const lit = litKey != null && (e.from === litKey || e.to === litKey)
const dimmed = litKey != null && !lit
if (dimmed) return null
const dur = lit ? 1.5 : 2.6
const base = (COL[e.from] ?? 0) * 0.16
return [0, dur / 2].map((off, k) => (
<circle key={`${e.from}-${e.to}-${k}`} r={lit ? 3.4 : 2.6} cx={0} cy={0} fill={col}>
<animateMotion dur={`${dur}s`} begin={`${base + off}s`} repeatCount="indefinite" path={d} rotate="0" />
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.12;0.85;1" dur={`${dur}s`} begin={`${base + off}s`} repeatCount="indefinite" />
</circle>
))
})}
</g>
)}
{/* nodes */}
{NODES.map((n) => {
const vis = nodeVisual(n, data, decision)
const on = detailKey === n.key
const hov = hoverKey === n.key
const x = n.cx - n.w / 2
const y = n.cy - n.h / 2
const stroke = on ? ACCENT : hov ? 'var(--color-line-strong)' : 'var(--color-line-soft)'
const fill = on || hov ? 'var(--color-panel-2)' : 'var(--color-inset)'
const padL = x + 13
const isCall = n.kind === 'call'
return (
<g
key={n.key}
className="wf-node"
role="button"
tabIndex={0}
aria-pressed={on}
aria-label={`${n.code} ${n.label}`}
onClick={() => onNode(n.key)}
onKeyDown={(ev) => onKey(ev, n.key)}
onMouseEnter={() => onEnter(n.key)}
onMouseLeave={onLeave}
style={{ opacity: vis.dim && !on && !hov ? 0.62 : 1, outline: 'none' }}
>
<rect x={x} y={y} width={n.w} height={n.h} rx={5} fill={fill} stroke={stroke} strokeWidth={on ? 1.6 : 1} />
{/* top row: status dot + layer code (left) · tag (right) */}
<circle cx={padL} cy={y + 13} r={3} fill={vis.dot} />
<text x={padL + 9} y={y + 13} dominantBaseline="central" fontFamily="var(--font-mono)" fontSize="8" letterSpacing="0.5" fill={FAINT}>
{n.code}
</text>
{isCall ? (
<>
<text x={n.cx + n.w / 2 - 11} y={y + 13} textAnchor="end" dominantBaseline="central" fontFamily="var(--font-mono)" fontSize="8.5" letterSpacing="0.5" fill={FAINT}>
{data?.verdict ? `${(data.verdict.confidence * 100).toFixed(0)}%` : 'VERDICT'}
</text>
<text x={padL} y={y + 36} dominantBaseline="central" fontFamily="var(--font-display)" fontSize="18" fontWeight="700" fill={vis.nameColor}>
{vis.tag}
</text>
</>
) : (
<>
<text x={n.cx + n.w / 2 - 11} y={y + 13} textAnchor="end" dominantBaseline="central" fontFamily="var(--font-mono)" fontSize="8.5" letterSpacing="0.5" fill={vis.tagColor}>
{vis.tag}
</text>
<text x={padL} y={y + 32} dominantBaseline="central" fontFamily="var(--font-mono)" fontSize="13" fontWeight="500" fill={vis.nameColor}>
{n.label}
</text>
</>
)}
</g>
)
})}
</svg>
</div>
</div>
{/* detail strip — updates on every node click */}
<div className="border-t border-line-soft bg-panel px-3 py-2.5">
{detail ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2.5">
<span className="label-sm !text-[0.5625rem] !text-faint">{detail.code}</span>
<span className="font-mono text-data font-semibold text-bone">{detail.title}</span>
<span className="rounded-sm px-1.5 py-0.5 font-mono text-[0.5625rem] font-semibold uppercase tracking-[0.04em]" style={{ color: detail.tagColor, background: 'var(--color-track)' }}>
{detail.tag}
</span>
<button
type="button"
onClick={() => { setSelKey(null); onSelect?.(null) }}
className="ml-auto rounded-sm border border-line px-1.5 py-0.5 font-mono text-[0.5625rem] uppercase tracking-[0.06em] text-muted transition-colors hover:border-line-strong hover:text-bone"
>
clear
</button>
</div>
<p className="font-mono text-micro leading-snug text-bone-dim">{detail.role}</p>
<p className="tnum font-mono text-micro leading-snug text-faint"> {detail.output}</p>
</div>
) : (
<p className="font-mono text-micro text-muted">
<span className="text-faint"></span> Click any node to inspect that agent its role and live output. Hover to trace the data path.
</p>
)}
</div>
</section>
)
}

View file

@ -0,0 +1,57 @@
import { clamp } from '@/lib/seed'
type Props = {
value: number // 0..1
size?: number
tone?: string
label?: string
}
/** Circular confidence gauge. */
export function ConfidenceRing({ value, size = 84, tone = 'var(--color-lime)', label = 'CONF' }: Props) {
const v = clamp(value, 0, 1)
const stroke = 6
const r = (size - stroke - 2) / 2
const c = 2 * Math.PI * r
const off = c * (1 - v)
const cx = size / 2
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0">
<circle cx={cx} cy={cx} r={r} fill="none" stroke="var(--color-track)" strokeWidth={stroke} />
<circle
cx={cx}
cy={cx}
r={r}
fill="none"
stroke={tone}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={off}
transform={`rotate(-90 ${cx} ${cx})`}
style={{ transition: 'stroke-dashoffset .6s cubic-bezier(.22,1,.36,1)' }}
/>
<text
x="50%"
y="47%"
textAnchor="middle"
dominantBaseline="middle"
className="font-mono tnum"
fill="var(--color-bone)"
style={{ fontSize: size * 0.27, fontWeight: 700 }}
>
{Math.round(v * 100)}
</text>
<text
x="50%"
y="67%"
textAnchor="middle"
className="font-mono"
fill="var(--color-muted)"
style={{ fontSize: size * 0.12, letterSpacing: 1 }}
>
{label}
</text>
</svg>
)
}

View file

@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from 'react'
import type { CoinScreen } from '@/hooks/useHyperliquid'
import type { Direction } from '@/lib/signals'
import { fmtPrice, fmtCompact } from '@/lib/format'
/**
* Live feed the agent chain streaming in real time. On every poll the whole
* pipeline re-runs and each layer's output cascades into the tape L1 L6:
* data ingest TA the 4 agent votes verdict paper execution comms.
* Every line is built from REAL screen data and carries an honest status tag
* (live / calc / vote / the call / paper). New rows flash + slide in, so you
* literally watch the agents receive the new candle and react.
*/
type Tone = 'live' | 'calc' | 'vote' | 'call' | 'paper'
type FeedEvent = { code: string; agent: string; msg: string; tag: string; tone: Tone; dir?: Direction }
type Row = FeedEvent & { id: string; ts: number; coin: string; fresh: boolean; i: number }
const sign = (n: number) => (n >= 0 ? '+' : '')
const hhmmss = (ts: number) => new Date(ts).toISOString().slice(11, 19)
const shortAgent = (a: string) =>
a.replace('Agent', '').replace('MeanReversion', 'Mean-Rev')
const dirColor = (d?: Direction) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
function toneDot(e: FeedEvent): string {
switch (e.tone) {
case 'vote': return dirColor(e.dir)
case 'call': return 'var(--color-accent)'
case 'live': return 'var(--color-bone)'
case 'calc': return 'var(--color-muted)'
default: return 'var(--color-faint)'
}
}
function toneTag(e: FeedEvent): string {
if (e.tone === 'vote') return dirColor(e.dir)
if (e.tone === 'call') return 'var(--color-accent)'
return 'var(--color-faint)'
}
/** Build one batch of feed events for a poll — pure, from real data, L1 → L6. */
function buildBatch(d: CoinScreen, tf: string): FeedEvent[] {
const { candles, ta, stat, votes, verdict } = d
const TF = tf.toUpperCase()
const out: FeedEvent[] = []
const lastC = candles[candles.length - 1]
// L1 · data ingest
out.push({ code: 'L1', agent: 'Candle', tone: 'live', tag: 'live', msg: `+${candles.length} ${TF} candles · close $${fmtPrice(lastC?.c ?? ta.price)}` })
out.push({ code: 'L1', agent: 'Ticker', tone: 'live', tag: 'live', msg: `mark $${fmtPrice(stat?.mark ?? ta.price)} · 24h ${sign(ta.changePct)}${ta.changePct.toFixed(2)}%` })
if (stat) out.push({ code: 'L1', agent: 'Funding·OI', tone: 'live', tag: 'live', msg: `funding ${sign(stat.funding)}${(stat.funding * 100).toFixed(4)}% · OI $${fmtCompact(stat.oiUsd)}` })
// L2 · analysis
out.push({
code: 'L2', agent: 'TA engine', tone: 'calc', tag: 'calc',
msg: `RSI ${ta.rsi.toFixed(1)} · MACD ${ta.macdHist >= 0 ? '▲' : '▼'} · EMA9 ${ta.ema9 >= ta.ema21 ? '>' : '<'} EMA21 · ATR ${ta.atrPct.toFixed(2)}% · %B ${ta.pctB.toFixed(2)}`,
})
// L3 · the 4 agent votes (real reasons)
for (const v of votes) {
out.push({ code: 'L3', agent: shortAgent(v.agent), tone: 'vote', tag: v.direction === 'SKIP' ? 'skip' : v.direction, dir: v.direction, msg: v.reason })
}
// L4 · decision (the call)
out.push({
code: 'L4', agent: 'Screener', tone: 'call', tag: verdict.decision, dir: verdict.decision,
msg: `verdict ${verdict.decision} · conf ${(verdict.confidence * 100).toFixed(0)}% · net ${sign(verdict.net)}${verdict.net.toFixed(2)} · size ${verdict.sizePct.toFixed(1)}%`,
})
// L5 · execution (paper / dry-run — honest)
out.push({
code: 'L5', agent: 'Execution', tone: 'paper', tag: 'paper',
msg: verdict.decision === 'SKIP'
? 'no position · SKIP (paper / dry-run)'
: `paper · ${verdict.decision} ${verdict.sizePct.toFixed(1)}% · TP +${verdict.tpPct.toFixed(1)}% / SL -${Math.abs(verdict.slPct).toFixed(1)}%`,
})
// L6 · comms
out.push({ code: 'L6', agent: 'Telegram', tone: 'live', tag: 'live', msg: `verdict ${verdict.decision} relayed to channel (worker)` })
return out
}
export function LiveFeed({
data,
coin,
tf,
updatedAt,
live,
loading,
onInspect,
}: {
data: CoinScreen | null
coin: string
tf: string
updatedAt: number
live: boolean
loading?: boolean
onInspect?: (s: string | null) => void
}) {
const [rows, setRows] = useState<Row[]>([])
const lastTs = useRef(0)
const seq = useRef(0)
useEffect(() => {
if (!data || !updatedAt || updatedAt === lastTs.current) return
lastTs.current = updatedAt
const bid = ++seq.current
const batch = buildBatch(data, tf).map((e, i) => ({
...e,
id: `${bid}-${i}`,
ts: updatedAt,
coin,
fresh: true,
i,
}))
setRows((prev) => {
const aged = prev.length ? prev.map((r) => (r.fresh ? { ...r, fresh: false } : r)) : prev
return [...batch, ...aged].slice(0, 60)
})
}, [updatedAt, data, coin, tf])
return (
<section className="overflow-hidden rounded-md border border-line bg-panel">
<header className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<div className="flex items-baseline gap-2.5">
<span className="label !text-[0.625rem]">Live feed</span>
<span className="hidden font-mono text-micro text-faint sm:inline">data analysis signal decision execution comms</span>
</div>
<span className="flex items-center gap-1.5 font-mono text-[0.5625rem] uppercase tracking-[0.1em]" style={{ color: live ? 'var(--color-long)' : 'var(--color-faint)' }}>
<span className={live ? 'blink inline-block h-1.5 w-1.5 rounded-full' : 'inline-block h-1.5 w-1.5 rounded-full'} style={{ background: live ? 'var(--color-long)' : 'var(--color-faint)' }} />
{live ? 'streaming' : updatedAt > 0 ? 'paused' : 'connecting'}
</span>
</header>
<div className="h-[208px] overflow-y-auto bg-inset px-1 py-1" role="log" aria-label="Live agent pipeline feed" aria-live="off">
{loading || rows.length === 0 ? (
<div className="space-y-1 px-2 py-1.5">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="skeleton h-2.5 w-12" />
<div className="skeleton h-2.5 w-8" />
<div className="skeleton h-2.5" style={{ width: `${38 + ((i * 13) % 44)}%` }} />
</div>
))}
</div>
) : (
rows.map((r) => {
const dot = toneDot(r)
return (
<div
key={r.id}
className={[
'group flex items-center gap-2 whitespace-nowrap border-l-2 px-2 py-[3px] font-mono text-micro transition-colors hover:bg-panel-2',
r.fresh ? 'feed-in' : '',
r.tone === 'paper' ? 'opacity-60' : '',
].join(' ')}
style={{ borderLeftColor: dot, ...(r.fresh ? { animationDelay: `${r.i * 55}ms` } : null) }}
onMouseEnter={() => onInspect?.(`${r.code} · ${r.agent}${r.msg}`)}
onMouseLeave={() => onInspect?.(null)}
>
<span className="hidden shrink-0 tabular-nums text-faint sm:inline">{hhmmss(r.ts)}</span>
<span className="shrink-0 text-[0.5625rem] text-faint">{r.code}</span>
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<span className="shrink-0 font-medium text-bone" style={r.tone === 'call' ? { color: 'var(--color-accent)' } : undefined}>{r.agent}</span>
<span className="min-w-0 flex-1 truncate text-bone-dim">{r.msg}</span>
<span className="hidden shrink-0 text-[0.5rem] text-faint md:inline">{r.coin}</span>
<span className="shrink-0 text-[0.5rem] uppercase tracking-[0.06em]" style={{ color: toneTag(r) }}>{r.tag}</span>
</div>
)
})
)}
</div>
</section>
)
}

View file

@ -0,0 +1,239 @@
import { cn } from '@/lib/utils'
import type { Direction, Vote } from '@/lib/signals'
/**
* The orchestra the full 6-layer agent pipeline as a clean layered flow.
* Data Analysis Signal Decision Execution Comms. Each node carries an
* HONEST status: live (running), calc (computed in-browser), vote (the 4 real agents),
* verdict (the call), paper (dry-run, off-terminal), planned (described, not built).
* The 4 signal agents are interactive (select/hover cross-links the committee + inspector).
*/
type Status = 'live' | 'calc' | 'paper' | 'planned'
type Node = { label: string; status: Status; desc: string; agent?: string }
type Layer = { code: string; name: string; nodes: Node[] }
const STATIC: Layer[] = [
{
code: 'L1',
name: 'Data',
nodes: [
{ label: 'Candle', status: 'live', desc: 'L1 · CandleAgent — polls OHLCV candles from Hyperliquid.' },
{ label: 'Ticker', status: 'live', desc: 'L1 · TickerAgent — live mark / mid price.' },
{ label: 'Funding·OI', status: 'live', desc: 'L1 · FundingOIAgent — funding rate & open interest.' },
{ label: 'Orderbook', status: 'planned', desc: 'L1 · OrderbookAgent — top-of-book depth (planned).' },
{ label: 'Liquidations', status: 'planned', desc: 'L1 · LiquidationsAgent — liquidation stream (planned).' },
],
},
{
code: 'L2',
name: 'Analysis',
nodes: [
{ label: 'TA engine', status: 'live', desc: 'L2 · recomputes RSI / MACD / EMA / ATR / Bollinger / funding-Z in-browser.' },
{ label: 'Volatility', status: 'calc', desc: 'L2 · VolatilityAgent — ATR regime & volume Δ (computed).' },
],
},
// L3 (signal) nodes are injected from live votes; risk is static
{ code: 'L3', name: 'Signal', nodes: [] },
{
code: 'L4',
name: 'Decision',
nodes: [
{ label: 'Screener', status: 'live', desc: 'L4 · aggregates the votes into the verdict — confidence, size, TP, SL (deterministic; LLM planned).' },
{ label: 'Conf gate', status: 'calc', desc: 'L4 · ConfidenceGate — forces SKIP below the confidence floor.' },
],
},
{
code: 'L5',
name: 'Execution',
nodes: [
{ label: 'Execution', status: 'paper', desc: 'L5 · ExecutionAgent — sizes & fires the order, sets TP/SL (paper / dry-run, off-terminal).' },
{ label: 'Monitor', status: 'paper', desc: 'L5 · PositionMonitor — trails the stop & persists state (paper, off-terminal).' },
],
},
{
code: 'L6',
name: 'Comms',
nodes: [
{ label: 'Telegram', status: 'live', desc: 'L6 · posts every verdict & fill to Telegram (live, via the worker).' },
{ label: '/learn', status: 'planned', desc: 'L6 · retrospective over closed trades (planned; LLM).' },
],
},
]
const RISK: Node = { label: 'Risk mgr', status: 'calc', desc: 'L3 · RiskManager — caps size, drawdown & correlation (conf-floor + 1 position/coin).' }
const dirColor = (d: Direction) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
const AGENT_RULE: Record<string, string> = {
TrendAgent: 'L3 · Trend — LONG when EMA9 > EMA21 & RSI > 50; SHORT on the reverse.',
BreakoutAgent: 'L3 · Breakout — LONG when %B > 0.92 (upper band); SHORT when %B < 0.08.',
MeanReversionAgent: 'L3 · Mean-Rev — fade extremes: LONG RSI < 32 lower band; SHORT RSI > 68 upper.',
FundingAgent: 'L3 · Funding — fade crowded funding: SHORT funding-Z > 1.5; LONG < 1.5.',
}
// status → dot + tag (never colour-only: every node carries a text tag too)
const STATUS_TAG: Record<Status, string> = { live: 'live', calc: 'calc', paper: 'paper', planned: 'soon' }
const dotFor = (s: Status) =>
s === 'live' ? 'var(--color-bone)' : s === 'calc' ? 'var(--color-muted)' : 'var(--color-faint)'
function Chip({
dot,
name,
nameTone,
tag,
tagTone,
dim,
outline,
selectable,
selected,
onClick,
onEnter,
onLeave,
}: {
dot: string
name: string
nameTone?: string
tag: string
tagTone?: string
dim?: boolean
outline?: boolean
selectable?: boolean
selected?: boolean
onClick?: () => void
onEnter?: () => void
onLeave?: () => void
}) {
const Comp = selectable ? 'button' : 'div'
return (
<Comp
type={selectable ? 'button' : undefined}
onClick={onClick}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
aria-pressed={selectable ? selected : undefined}
className={cn(
'flex w-full items-center gap-1.5 rounded-sm border bg-inset px-2 py-1 text-left transition-colors',
selectable ? 'cursor-pointer hover:border-line-strong hover:bg-panel-2' : '',
selected ? 'border-accent bg-panel-2' : 'border-line-soft',
dim ? 'opacity-60' : '',
)}
>
<span
className="inline-block h-1.5 w-1.5 shrink-0 rounded-full"
style={outline ? { border: `1px solid ${dot}` } : { background: dot }}
aria-hidden
/>
<span className="truncate font-mono text-[0.625rem] font-medium" style={{ color: nameTone ?? 'var(--color-bone)' }}>
{name}
</span>
<span className="ml-auto shrink-0 font-mono text-[0.5rem] uppercase tracking-[0.04em]" style={{ color: tagTone ?? 'var(--color-faint)' }}>
{tag}
</span>
</Comp>
)
}
export function Orchestra({
votes,
decision,
activeAgent,
onSelect,
onHover,
onInspect,
loading,
}: {
votes: Vote[]
decision: Direction
activeAgent?: string | null
onSelect?: (a: string | null) => void
onHover?: (a: string | null) => void
onInspect?: (s: string | null) => void
loading?: boolean
}) {
const decCol = dirColor(decision)
return (
<section className="overflow-hidden rounded-md border border-line bg-panel">
<header className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<span className="label !text-[0.625rem]">Orchestra</span>
<span className="hidden font-mono text-micro text-faint sm:inline">
data analysis signal decision execution comms
</span>
</header>
<div className="grid grid-cols-2 gap-px bg-line-soft sm:grid-cols-3 lg:grid-cols-6">
{STATIC.map((layer) => (
<div key={layer.code} className="flex flex-col gap-1.5 bg-panel px-2.5 py-2.5">
<div className="flex items-baseline gap-1.5 pb-0.5">
<span className="font-mono text-[0.5625rem] text-faint">{layer.code}</span>
<span className="label-sm !text-[0.5625rem] !text-bone-dim">{layer.name}</span>
</div>
{layer.code === 'L3' ? (
<>
{loading
? [0, 1, 2, 3].map((i) => <div key={i} className="skeleton h-[26px] w-full" />)
: votes.slice(0, 4).map((v) => {
const c = dirColor(v.direction)
const on = activeAgent === v.agent
const meta = AGENT_RULE[v.agent] ?? `L3 · ${v.agent}`
return (
<Chip
key={v.agent}
dot={c}
name={(v.agent.replace('Agent', '').replace('MeanReversion', 'Mean-Rev'))}
tag={v.direction === 'SKIP' ? 'skip' : v.direction}
tagTone={c}
selectable
selected={on}
onClick={() => onSelect?.(on ? null : v.agent)}
onEnter={() => {
onHover?.(v.agent)
onInspect?.(meta)
}}
onLeave={() => {
onHover?.(null)
onInspect?.(null)
}}
/>
)
})}
<Chip dot={dotFor(RISK.status)} name={RISK.label} nameTone="var(--color-bone-dim)" tag={STATUS_TAG[RISK.status]}
onEnter={() => onInspect?.(RISK.desc)} onLeave={() => onInspect?.(null)} />
</>
) : (
layer.nodes.map((n) => {
const isVerdict = layer.code === 'L4' && n.label === 'Screener'
return (
<Chip
key={n.label}
dot={isVerdict ? decCol : dotFor(n.status)}
name={n.label}
nameTone={isVerdict ? decCol : n.status === 'live' ? 'var(--color-bone)' : 'var(--color-bone-dim)'}
tag={isVerdict ? decision : STATUS_TAG[n.status]}
tagTone={isVerdict ? decCol : undefined}
dim={n.status === 'paper' || n.status === 'planned'}
outline={n.status === 'planned'}
onEnter={() => onInspect?.(n.desc)}
onLeave={() => onInspect?.(null)}
/>
)
})
)}
</div>
))}
</div>
{/* legend (status is never colour-only) */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-line-soft px-3 py-2 font-mono text-[0.5625rem] uppercase tracking-[0.06em] text-faint">
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--color-bone)' }} /> live</span>
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--color-muted)' }} /> computed</span>
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--color-accent)' }} /> the call</span>
<span className="flex items-center gap-1.5"><span className="h-1.5 w-1.5 rounded-full opacity-60" style={{ border: '1px solid var(--color-faint)' }} /> paper / planned</span>
</div>
</section>
)
}

View file

@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react'
import {
createChart,
CandlestickSeries,
ColorType,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type UTCTimestamp,
} from 'lightweight-charts'
import type { Candle } from '@/lib/hyperliquid'
/**
* Price chart powered by TradingView's Lightweight Charts, fed with our real
* Hyperliquid candles. Professional candlesticks with built-in zoom / pan /
* crosshair. Carbon Desk themed. Re-fits only on instrument/timeframe change so
* a user's zoom survives the live poll.
*/
export function TVChart({ candles, height = 540, fitKey }: { candles: Candle[]; height?: number; fitKey: string }) {
const wrapRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const fitKeyRef = useRef(fitKey)
const fittedRef = useRef('')
fitKeyRef.current = fitKey
useEffect(() => {
const el = wrapRef.current
if (!el) return
const chart = createChart(el, {
autoSize: true,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: '#828a94',
fontFamily: "'Geist Mono', ui-monospace, monospace",
fontSize: 11,
attributionLogo: false,
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.04)' },
horzLines: { color: 'rgba(255,255,255,0.05)' },
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,176,32,0.5)', width: 1, style: 3, labelBackgroundColor: '#181b20' },
horzLine: { color: 'rgba(255,176,32,0.5)', width: 1, style: 3, labelBackgroundColor: '#181b20' },
},
})
const series = chart.addSeries(CandlestickSeries, {
upColor: '#2bd974',
downColor: '#f2444d',
borderVisible: false,
wickUpColor: '#2bd974',
wickDownColor: '#f2444d',
})
chartRef.current = chart
seriesRef.current = series
return () => {
chart.remove()
chartRef.current = null
seriesRef.current = null
}
}, [])
// Replace data on every poll (preserves the user's zoom). Auto-fit ONCE per
// instrument/timeframe, deferred to a frame so autoSize has measured the
// container first — otherwise the candles bunch up on the right edge.
useEffect(() => {
const series = seriesRef.current
if (!series || candles.length < 2) return
series.setData(
candles.map((c) => ({
time: Math.floor(c.t / 1000) as UTCTimestamp,
open: c.o,
high: c.h,
low: c.l,
close: c.c,
})),
)
if (fittedRef.current !== fitKeyRef.current) {
fittedRef.current = fitKeyRef.current
const id = requestAnimationFrame(() => chartRef.current?.timeScale().fitContent())
return () => cancelAnimationFrame(id)
}
}, [candles])
return <div ref={wrapRef} style={{ height, width: '100%' }} />
}
// deterministic faux-candle heights (%) for the connecting skeleton — no random, no real data
const SKELETON_BARS = Array.from({ length: 60 }, (_, i) => {
const h = 48 + 26 * Math.sin(i * 0.55) + 14 * Math.sin(i * 1.7 + 1) + 8 * Math.sin(i * 0.21)
return Math.max(14, Math.min(90, h))
})
/** Chart placeholder shown until live data for the instrument arrives. */
export function ChartSkeleton({ height = 540 }: { height?: number }) {
return (
<div className="relative w-full overflow-hidden" style={{ height }} aria-busy="true" aria-label="Connecting to market data">
<div className="absolute inset-x-1 bottom-0 top-1 flex items-end gap-[3px] animate-pulse">
{SKELETON_BARS.map((h, i) => (
<div key={i} className="flex-1 rounded-[1px]" style={{ height: `${h}%`, background: 'var(--color-track)' }} />
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="label-sm flex items-center gap-2 rounded-sm border border-line-soft bg-paper/70 px-2.5 py-1 !text-muted backdrop-blur-sm">
<span className="blink inline-block h-1.5 w-1.5 rounded-full" style={{ background: 'var(--color-muted)' }} />
Connecting · Hyperliquid
</span>
</div>
</div>
)
}

63
src/data/seedMarket.ts Normal file
View file

@ -0,0 +1,63 @@
/**
* Deterministic seed market data. Renders instantly so cards never hang on a
* skeleton (slow network / backgrounded tab), then live Hyperliquid data replaces
* it. Seed is labelled non-live in the UI.
*/
import type { Candle, MarketStat } from '@/lib/hyperliquid'
import { mulberry32, hashStr, clamp } from '@/lib/seed'
const BASES: Record<string, { base: number; vol: number; fund: number; trend: number }> = {
BTC: { base: 63_000, vol: 1.4, fund: 0.000042, trend: 0.6 },
ETH: { base: 2_480, vol: 1.9, fund: 0.000031, trend: 0.3 },
SOL: { base: 148, vol: 2.8, fund: -0.000026, trend: -0.4 },
}
function baseFor(coin: string) {
if (BASES[coin]) return BASES[coin]
const h = hashStr(coin)
return { base: 5 + (h % 400), vol: 2 + (h % 5), fund: ((h % 7) - 3) / 100000, trend: ((h % 5) - 2) / 4 }
}
const HOUR = 3_600_000
export function seedCandles(coin: string): Candle[] {
const { base, vol, trend } = baseFor(coin)
const rnd = mulberry32(hashStr(coin) + 99)
const out: Candle[] = []
let p = base * (1 - vol / 100 - 0.02)
const now = 1_700_000_000_000 // fixed epoch so seed is fully deterministic
for (let i = 0; i < 120; i++) {
const wave = Math.sin(i / 9) * vol * 0.35 + trend * 0.04
const shock = (rnd() - 0.5) * vol
p = Math.max(p * (1 + (wave + shock) / 100), base * 0.7)
const o = p * (1 - (rnd() - 0.5) / 220)
const c = p
const h = Math.max(o, c) * (1 + rnd() / 320)
const l = Math.min(o, c) * (1 - rnd() / 320)
out.push({ t: now + i * HOUR, o, h, l, c, v: 400 + rnd() * 900 })
}
// ease final close toward base for a believable "current" price
out[out.length - 1].c = base
return out
}
export function seedStat(coin: string): MarketStat {
const { base, fund } = baseFor(coin)
const rnd = mulberry32(hashStr(coin) + 7)
const changePct = (rnd() - 0.4) * 4
const prev = base / (1 + changePct / 100)
const oi = base * (800 + rnd() * 4000)
return {
coin,
mark: base,
mid: base,
prevDayPx: prev,
changePct,
funding: fund,
openInterest: oi / base,
oiUsd: oi,
dayVolumeUsd: base * (5000 + rnd() * 40000) * 100,
}
}
export const clampUnit = (n: number) => clamp(n, 0, 1)

135
src/hooks/useHyperliquid.ts Normal file
View file

@ -0,0 +1,135 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { getCandles, getMarketStats, type Candle, type MarketStat } from '@/lib/hyperliquid'
import { summarizeTA, type TASnapshot } from '@/lib/ta'
import { screen as screenTA, type Vote, type Verdict } from '@/lib/signals'
import { seedCandles, seedStat } from '@/data/seedMarket'
type Async<T> = {
data: T | null
loading: boolean
live: boolean
error: string | null
updatedAt: number
}
/** Live market stats for a set of perps; polls politely and keeps last-good on error. */
export function useMarketStats(coins: string[], pollMs = 10_000): Async<MarketStat[]> {
const key = coins.join(',')
const [state, setState] = useState<Async<MarketStat[]>>(() => ({
data: coins.map(seedStat),
loading: false,
live: false,
error: null,
updatedAt: 0,
}))
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
const tick = async () => {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 12_000)
try {
const data = await getMarketStats(coins, ctrl.signal)
if (alive)
setState({ data, loading: false, live: true, error: null, updatedAt: Date.now() })
} catch (e) {
if (alive)
setState((s) => ({ ...s, loading: false, live: false, error: (e as Error).message }))
} finally {
clearTimeout(t)
if (alive) timer = setTimeout(tick, pollMs)
}
}
tick()
return () => {
alive = false
clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, pollMs])
return state
}
export type CoinScreen = {
candles: Candle[]
ta: TASnapshot
stat: MarketStat | null
votes: Vote[]
verdict: Verdict
}
/** Live candle screen for one coin: real candles → TA → agent votes → verdict. */
export function useScreen(
coin: string,
interval = '1h',
pollMs = 15_000,
): Async<CoinScreen> & { reload: () => void } {
const seed = useMemo<CoinScreen>(() => {
const candles = seedCandles(coin)
const stat = seedStat(coin)
const ta = summarizeTA(candles, stat.funding)
const { votes, verdict } = screenTA(ta, stat)
return { candles, ta, stat, votes, verdict }
}, [coin])
const [state, setState] = useState<Async<CoinScreen>>(() => ({
data: seed,
loading: false,
live: false,
error: null,
updatedAt: 0,
}))
const lastGood = useRef<CoinScreen | null>(seed)
const reloadRef = useRef<() => void>(() => {})
useEffect(() => {
// show this coin's seed immediately, then chase live data.
// reset updatedAt so the UI knows this coin has no live data yet (→ chart shows skeleton,
// not the instant seed snapshot) until the first successful fetch for THIS coin lands.
lastGood.current = seed
setState((s) => ({ ...s, data: seed, live: false, updatedAt: 0 }))
let alive = true
let timer: ReturnType<typeof setTimeout>
const tick = async () => {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 12_000)
try {
const [candles, stats] = await Promise.all([
getCandles(coin, interval, 120, ctrl.signal),
getMarketStats([coin], ctrl.signal),
])
const stat = stats[0] ?? null
const ta = summarizeTA(candles, stat?.funding ?? 0)
const { votes, verdict } = screenTA(ta, stat ?? undefined)
const data: CoinScreen = { candles, ta, stat, votes, verdict }
lastGood.current = data
if (alive)
setState({ data, loading: false, live: true, error: null, updatedAt: Date.now() })
} catch (e) {
if (alive)
setState((s) => ({
...s,
data: s.data ?? lastGood.current,
loading: false,
live: false,
error: (e as Error).message,
}))
} finally {
clearTimeout(t)
if (alive) timer = setTimeout(tick, pollMs)
}
}
reloadRef.current = () => {
if (alive) tick()
}
tick()
return () => {
alive = false
clearTimeout(timer)
}
}, [coin, interval, pollMs, seed])
return { ...state, reload: () => reloadRef.current() }
}

70
src/hooks/useInView.ts Normal file
View file

@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react'
type Opts = { once?: boolean; threshold?: number; rootMargin?: string }
/**
* Returns a ref + whether it has entered the viewport.
* Primary: IntersectionObserver. Fallback: a rAF-throttled rect check on
* scroll/resize so content never stays stuck hidden if IO is throttled
* (offscreen tab, some headless/embedded webviews).
*/
export function useInView<T extends Element = HTMLDivElement>(opts: Opts = {}) {
const { once = true, threshold = 0.18, rootMargin = '0px 0px -8% 0px' } = opts
const ref = useRef<T>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
let revealed = false
let raf = 0
const apply = (visible: boolean) => {
if (once) {
if (visible && !revealed) {
revealed = true
setInView(true)
cleanup()
}
} else {
setInView(visible)
}
}
const rectCheck = () => {
const r = el.getBoundingClientRect()
const vh = window.innerHeight || document.documentElement.clientHeight
apply(r.top < vh * 0.9 && r.bottom > vh * 0.04)
}
let io: IntersectionObserver | null = null
if (typeof IntersectionObserver !== 'undefined') {
io = new IntersectionObserver(
(entries) => {
for (const e of entries) apply(e.isIntersecting)
},
{ threshold, rootMargin },
)
io.observe(el)
}
const onScroll = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(rectCheck)
}
rectCheck()
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
function cleanup() {
cancelAnimationFrame(raf)
io?.disconnect()
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
}
return cleanup
}, [once, threshold, rootMargin])
return [ref, inView] as const
}

View file

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { getBrief, FACTSAI_ENABLED, type Brief } from '@/lib/factsai'
type State = { data: Brief | null; loading: boolean; live: boolean; error: string | null }
/** Fetches the research-agent brief once (cached). live=false → caller may hide the section. */
export function useMarketBrief(query: string): State {
const [state, setState] = useState<State>({
data: null,
loading: FACTSAI_ENABLED,
live: false,
error: FACTSAI_ENABLED ? null : 'disabled',
})
useEffect(() => {
if (!FACTSAI_ENABLED) return
let alive = true
const ctrl = new AbortController()
getBrief(query, ctrl.signal)
.then((b) => alive && setState({ data: b, loading: false, live: true, error: null }))
.catch((e) => alive && setState({ data: null, loading: false, live: false, error: String(e?.message ?? e) }))
return () => {
alive = false
ctrl.abort()
}
}, [query])
return state
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'
/** Tracks the user's reduced-motion preference, reactively. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const on = () => setReduced(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return reduced
}

View file

@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
/** Whole-page scroll progress 0..1 (for the nav progress hairline). */
export function useScrollProgress(): number {
const [p, setP] = useState(0)
useEffect(() => {
let raf = 0
const on = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
const h = document.documentElement.scrollHeight - window.innerHeight
setP(h > 0 ? Math.min(1, Math.max(0, window.scrollY / h)) : 0)
})
}
on()
window.addEventListener('scroll', on, { passive: true })
window.addEventListener('resize', on)
return () => {
window.removeEventListener('scroll', on)
window.removeEventListener('resize', on)
cancelAnimationFrame(raf)
}
}, [])
return p
}

85
src/lib/factsai.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* FactsAI deep-research API https://factsai.org/docs
* POST /answer { query } -> { success, data: { answer, citations[] } }
*
* The "research agent": real AI-generated market brief with cited web sources.
* Direct browser calls need CORS + an exposed key; prefer a proxy in production
* (set VITE_FACTSAI_PROXY). Successful answers are cached in localStorage (TTL) to
* keep cost down (each call is billed).
*/
const env = import.meta.env as Record<string, string | undefined>
const KEY = env.VITE_FACTSAI_KEY
const PROXY = env.VITE_FACTSAI_PROXY
const BASE = PROXY || 'https://deep-research-api.degodmode3-33.workers.dev'
const ENDPOINT = `${BASE.replace(/\/$/, '')}/answer`
const CACHE_KEY = 'quant.brief.v1'
const TTL = 30 * 60 * 1000 // 30 min
export type Citation = {
id: string
url: string
title: string
author?: string
publishedDate?: string
favicon?: string
}
export type Brief = { answer: string; citations: Citation[]; at: number }
export const FACTSAI_ENABLED = Boolean(KEY || PROXY)
function readCache(query: string): Brief | null {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (!raw) return null
const c = JSON.parse(raw) as Brief & { q?: string }
if (c.q === query && Date.now() - c.at < TTL) return c
} catch {
/* ignore */
}
return null
}
export async function getBrief(query: string, signal?: AbortSignal): Promise<Brief> {
const cached = readCache(query)
if (cached) return cached
if (!FACTSAI_ENABLED) throw new Error('FactsAI not configured')
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// when using a proxy, the proxy injects the key; only send it on direct calls
...(KEY && !PROXY ? { Authorization: `Bearer ${KEY}` } : {}),
},
body: JSON.stringify({ query }),
signal,
})
if (!res.ok) throw new Error(`FactsAI ${res.status}`)
const json = (await res.json()) as {
success?: boolean
error?: string
data?: { answer: string; citations?: Citation[] }
}
if (!json.success || !json.data) throw new Error(json.error || 'FactsAI error')
const brief: Brief = {
answer: json.data.answer,
citations: (json.data.citations || []).slice(0, 6).map((c) => ({
id: c.id,
url: c.url,
title: c.title,
author: c.author,
publishedDate: c.publishedDate,
favicon: c.favicon,
})),
at: Date.now(),
}
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ...brief, q: query }))
} catch {
/* ignore */
}
return brief
}

45
src/lib/format.ts Normal file
View file

@ -0,0 +1,45 @@
/** Locale-aware formatters. Tabular figures recommended (see .tnum utility). */
export function fmtUsd(n: number, maxFrac = 2): string {
if (!isFinite(n)) return '—'
return n.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac,
})
}
export function fmtPrice(n: number): string {
if (!isFinite(n)) return '—'
const frac = n >= 1000 ? 0 : n >= 1 ? 2 : 4
return n.toLocaleString('en-US', {
minimumFractionDigits: frac,
maximumFractionDigits: frac,
})
}
/** 1_234_567 → "1.23M" */
export function fmtCompact(n: number, digits = 2): string {
if (!isFinite(n)) return '—'
const abs = Math.abs(n)
const sign = n < 0 ? '-' : ''
if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(digits)}B`
if (abs >= 1e6) return `${sign}${(abs / 1e6).toFixed(digits)}M`
if (abs >= 1e3) return `${sign}${(abs / 1e3).toFixed(digits)}K`
return `${sign}${abs.toFixed(0)}`
}
export function fmtPct(n: number, digits = 2, signed = true): string {
if (!isFinite(n)) return '—'
const s = signed && n > 0 ? '+' : ''
return `${s}${n.toFixed(digits)}%`
}
export function fmtNum(n: number, digits = 0): string {
if (!isFinite(n)) return '—'
return n.toLocaleString('en-US', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
}

112
src/lib/hyperliquid.ts Normal file
View file

@ -0,0 +1,112 @@
/**
* Hyperliquid public market data (no auth, CORS-enabled verified).
* Docs: https://hyperliquid.gitbook.io · POST https://api.hyperliquid.xyz/info
*/
const INFO_URL = 'https://api.hyperliquid.xyz/info'
export type Candle = { t: number; o: number; h: number; l: number; c: number; v: number }
export type MarketStat = {
coin: string
mark: number
mid: number
prevDayPx: number
changePct: number
funding: number // hourly funding rate
openInterest: number // in base asset
oiUsd: number
dayVolumeUsd: number
}
type RawCandle = { t: number; T: number; o: string; c: string; h: string; l: string; v: string; n: number }
type Universe = { universe: { name: string; szDecimals: number; maxLeverage: number; isDelisted?: boolean }[] }
type AssetCtx = {
funding: string
openInterest: string
prevDayPx: string
dayNtlVlm: string
markPx: string
midPx: string | null
oraclePx: string
}
async function info<T>(body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(INFO_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
})
if (!res.ok) throw new Error(`Hyperliquid ${res.status}`)
return res.json() as Promise<T>
}
export async function getAllMids(signal?: AbortSignal): Promise<Record<string, string>> {
return info<Record<string, string>>({ type: 'allMids' }, signal)
}
/** Live mark/mid/funding/OI/volume for the requested coins (perp names like "BTC"). */
export async function getMarketStats(coins: string[], signal?: AbortSignal): Promise<MarketStat[]> {
const [meta, ctxs] = await info<[Universe, AssetCtx[]]>({ type: 'metaAndAssetCtxs' }, signal)
const out: MarketStat[] = []
for (const coin of coins) {
const idx = meta.universe.findIndex((u) => u.name === coin)
if (idx < 0) continue
const c = ctxs[idx]
if (!c) continue
const mark = +c.markPx
const mid = c.midPx ? +c.midPx : mark
const prev = +c.prevDayPx
const oi = +c.openInterest
out.push({
coin,
mark,
mid,
prevDayPx: prev,
changePct: prev ? ((mark - prev) / prev) * 100 : 0,
funding: +c.funding,
openInterest: oi,
oiUsd: oi * mark,
dayVolumeUsd: +c.dayNtlVlm,
})
}
return out
}
const INTERVAL_MS: Record<string, number> = {
'1m': 60_000,
'5m': 300_000,
'15m': 900_000,
'1h': 3_600_000,
'4h': 14_400_000,
'1d': 86_400_000,
}
/** Most recent `lookback` candles for a coin/interval. */
export async function getCandles(
coin: string,
interval = '1h',
lookback = 120,
signal?: AbortSignal,
): Promise<Candle[]> {
const step = INTERVAL_MS[interval] ?? INTERVAL_MS['1h']
const endTime = Date.now()
const startTime = endTime - step * (lookback + 2)
const raw = await info<RawCandle[]>(
{ type: 'candleSnapshot', req: { coin, interval, startTime, endTime } },
signal,
)
return raw.map((r) => ({
t: r.t,
o: +r.o,
h: +r.h,
l: +r.l,
c: +r.c,
v: +r.v,
}))
}
/** Default tradeable universe for the site. */
export const SYMBOLS = ['BTC', 'ETH', 'SOL'] as const
export type Symbol = (typeof SYMBOLS)[number]

26
src/lib/seed.ts Normal file
View file

@ -0,0 +1,26 @@
/** Deterministic helpers — so "live" agent decisions are stable per render, not random flicker. */
/** Seeded PRNG (mulberry32). Same seed → same stream. */
export function mulberry32(seed: number) {
let a = seed >>> 0
return function () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** FNV-1a string hash → uint32 seed. */
export function hashStr(s: string): number {
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t

151
src/lib/signals.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* Signal layer. Each agent reads the (real) TA snapshot and votes. The aggregate
* produces the final LONG / SHORT / SKIP verdict with confidence and risk params.
* Deterministic: same market state same decision (no random flicker).
*/
import type { TASnapshot } from './ta'
import type { MarketStat } from './hyperliquid'
import { clamp } from './seed'
export type Direction = 'LONG' | 'SHORT' | 'SKIP'
export type Vote = {
agent: string
layer: number
direction: Direction
score: number // 0..1 conviction
reason: string
}
export type Verdict = {
decision: Direction
confidence: number // 0..1
sizePct: number
tpPct: number
slPct: number
reasoning: string
net: number
}
const round = (n: number, d = 2) => Math.round(n * 10 ** d) / 10 ** d
export function agentVotes(ta: TASnapshot, mkt?: MarketStat): Vote[] {
const votes: Vote[] = []
// ── TrendAgent: EMA9/21 crossover + RSI bias (1h)
{
const long = ta.ema9 > ta.ema21 && ta.rsi > 50
const short = ta.ema9 < ta.ema21 && ta.rsi < 50
const dir: Direction = long ? 'LONG' : short ? 'SHORT' : 'SKIP'
const score = clamp(Math.abs(ta.emaDiffPct) * 14 + Math.abs(ta.rsi - 50) / 60, 0, 1)
votes.push({
agent: 'TrendAgent',
layer: 3,
direction: dir,
score: round(dir === 'SKIP' ? score * 0.4 : score),
reason:
dir === 'SKIP'
? `EMA9/21 flat, RSI ${ta.rsi.toFixed(0)} — no trend`
: `EMA9 ${ta.ema9 > ta.ema21 ? 'above' : 'below'} EMA21, RSI ${ta.rsi.toFixed(0)}`,
})
}
// ── BreakoutAgent: Bollinger band push
{
const dir: Direction = ta.pctB > 0.92 ? 'LONG' : ta.pctB < 0.08 ? 'SHORT' : 'SKIP'
const score = clamp(Math.abs(ta.pctB - 0.5) * 1.7, 0, 1)
votes.push({
agent: 'BreakoutAgent',
layer: 3,
direction: dir,
score: round(dir === 'SKIP' ? score * 0.3 : score),
reason:
dir === 'SKIP'
? `Price mid-band (%B ${ta.pctB.toFixed(2)})`
: `%B ${ta.pctB.toFixed(2)}${dir === 'LONG' ? 'upside' : 'downside'} breakout`,
})
}
// ── MeanReversionAgent: RSI extremes + band
{
const long = ta.rsi < 32 && ta.pctB < 0.15
const short = ta.rsi > 68 && ta.pctB > 0.85
const dir: Direction = long ? 'LONG' : short ? 'SHORT' : 'SKIP'
const score = long
? clamp((32 - ta.rsi) / 22, 0, 1)
: short
? clamp((ta.rsi - 68) / 22, 0, 1)
: 0
votes.push({
agent: 'MeanReversionAgent',
layer: 3,
direction: dir,
score: round(score),
reason:
dir === 'SKIP'
? `RSI ${ta.rsi.toFixed(0)} not extreme`
: `RSI ${ta.rsi.toFixed(0)} ${dir === 'LONG' ? 'oversold' : 'overbought'}, fade`,
})
}
// ── FundingAgent: crowded funding → fade
{
const dir: Direction = ta.fundingZ > 1.5 ? 'SHORT' : ta.fundingZ < -1.5 ? 'LONG' : 'SKIP'
const score = clamp(Math.abs(ta.fundingZ) / 3, 0, 1)
const fr = mkt ? (mkt.funding * 100).toFixed(4) : '—'
votes.push({
agent: 'FundingAgent',
layer: 3,
direction: dir,
score: round(dir === 'SKIP' ? score * 0.4 : score),
reason:
dir === 'SKIP'
? `Funding neutral (z ${ta.fundingZ.toFixed(1)})`
: `Funding ${fr}% (z ${ta.fundingZ.toFixed(1)}) — ${dir === 'SHORT' ? 'longs crowded' : 'shorts crowded'}`,
})
}
return votes
}
export function aggregate(votes: Vote[], ta: TASnapshot): Verdict {
let net = 0
let weight = 0
for (const v of votes) {
if (v.direction === 'SKIP') continue
net += (v.direction === 'LONG' ? 1 : -1) * v.score
weight += v.score
}
const active = votes.filter((v) => v.direction !== 'SKIP')
const agreeing = active.filter((v) => Math.sign(net) === (v.direction === 'LONG' ? 1 : -1))
const agreement = active.length ? agreeing.length / active.length : 0
const thr = 0.5
const decision: Direction = net > thr ? 'LONG' : net < -thr ? 'SHORT' : 'SKIP'
const confidence =
decision === 'SKIP'
? clamp(0.3 + Math.abs(net) * 0.2, 0.18, 0.5)
: clamp(0.45 + Math.abs(net) * 0.18 + agreement * 0.18, 0, 0.97)
// risk params scale with volatility (ATR%)
const vol = clamp(ta.atrPct, 0.4, 6)
const tpPct = round(clamp(vol * 1.6, 0.8, 6), 1)
const slPct = round(clamp(vol * 0.9, 0.6, 3.5), 1)
const sizePct = round(clamp(confidence * 3.2, 0.5, 3.2), 1)
const lead = active.sort((a, b) => b.score - a.score)[0]
const reasoning =
decision === 'SKIP'
? `Signals split (net ${net.toFixed(2)}). RiskManager holds — no edge above threshold.`
: `${agreeing.length}/${active.length} agents align ${decision}. ${lead?.reason ?? ''}. RR ${(tpPct / slPct).toFixed(1)}.`
return { decision, confidence: round(confidence), sizePct, tpPct, slPct, reasoning, net: round(net) }
}
/** Convenience: full screen from a TA snapshot. */
export function screen(ta: TASnapshot, mkt?: MarketStat) {
const votes = agentVotes(ta, mkt)
const verdict = aggregate(votes, ta)
return { votes, verdict }
}

132
src/lib/ta.ts Normal file
View file

@ -0,0 +1,132 @@
/**
* Technical-analysis engine. These are *genuinely correct* indicators computed
* from real Hyperliquid candles the credible backbone under the agent theatre.
*/
import type { Candle } from './hyperliquid'
import { clamp } from './seed'
const avg = (a: number[]) => a.reduce((s, x) => s + x, 0) / (a.length || 1)
function stdev(a: number[], mean = avg(a)): number {
if (a.length < 2) return 0
return Math.sqrt(a.reduce((s, x) => s + (x - mean) ** 2, 0) / a.length)
}
export function ema(values: number[], period: number): number[] {
const k = 2 / (period + 1)
const out: number[] = []
let prev = values[0] ?? 0
for (let i = 0; i < values.length; i++) {
prev = i === 0 ? values[0] : values[i] * k + prev * (1 - k)
out.push(prev)
}
return out
}
/** Wilder's RSI. */
export function rsi(values: number[], period = 14): number[] {
const out: number[] = new Array(values.length).fill(NaN)
if (values.length < period + 1) return out
let gain = 0
let loss = 0
for (let i = 1; i <= period; i++) {
const d = values[i] - values[i - 1]
if (d >= 0) gain += d
else loss -= d
}
let avgGain = gain / period
let avgLoss = loss / period
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss)
for (let i = period + 1; i < values.length; i++) {
const d = values[i] - values[i - 1]
const g = d > 0 ? d : 0
const l = d < 0 ? -d : 0
avgGain = (avgGain * (period - 1) + g) / period
avgLoss = (avgLoss * (period - 1) + l) / period
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss)
}
return out
}
export function macd(values: number[], fast = 12, slow = 26, signalP = 9) {
const emaFast = ema(values, fast)
const emaSlow = ema(values, slow)
const macdLine = values.map((_, i) => emaFast[i] - emaSlow[i])
const signalLine = ema(macdLine, signalP)
const hist = macdLine.map((v, i) => v - signalLine[i])
return { macdLine, signalLine, hist }
}
export function atr(candles: Candle[], period = 14): number {
if (candles.length < 2) return 0
const tr: number[] = []
for (let i = 0; i < candles.length; i++) {
const c = candles[i]
if (i === 0) tr.push(c.h - c.l)
else {
const pc = candles[i - 1].c
tr.push(Math.max(c.h - c.l, Math.abs(c.h - pc), Math.abs(c.l - pc)))
}
}
return ema(tr, period).at(-1) ?? 0
}
/** Bollinger %B — position of last price inside the band (0 = lower, 1 = upper). */
export function bollingerPctB(values: number[], period = 20, mult = 2): number {
if (values.length < period) return 0.5
const slice = values.slice(-period)
const mean = avg(slice)
const sd = stdev(slice, mean)
const upper = mean + mult * sd
const lower = mean - mult * sd
const last = values.at(-1) as number
const span = upper - lower
return span ? clamp((last - lower) / span, -0.2, 1.2) : 0.5
}
export type TASnapshot = {
price: number
rsi: number
ema9: number
ema21: number
ema50: number
emaDiffPct: number
macdHist: number
atr: number
atrPct: number
pctB: number
changePct: number
fundingZ: number
closes: number[]
}
/** Summarize a candle series into the latest indicator readings. */
export function summarizeTA(candles: Candle[], funding = 0): TASnapshot {
const closes = candles.map((c) => c.c)
const price = closes.at(-1) ?? 0
const e9 = ema(closes, 9).at(-1) ?? price
const e21 = ema(closes, 21).at(-1) ?? price
const e50 = ema(closes, 50).at(-1) ?? price
const rsiArr = rsi(closes)
const rsiVal = rsiArr.at(-1)
const { hist } = macd(closes)
const a = atr(candles)
const first = closes[0] ?? price
// funding hourly rate → pseudo z-score against a typical band (illustrative)
const fundingZ = clamp(funding / 0.00005, -4, 4)
return {
price,
rsi: isFinite(rsiVal as number) ? (rsiVal as number) : 50,
ema9: e9,
ema21: e21,
ema50: e50,
emaDiffPct: price ? ((e9 - e21) / price) * 100 : 0,
macdHist: hist.at(-1) ?? 0,
atr: a,
atrPct: price ? (a / price) * 100 : 0,
pctB: bollingerPctB(closes),
changePct: first ? ((price - first) / first) * 100 : 0,
fundingZ,
closes,
}
}

7
src/lib/utils.ts Normal file
View file

@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Merge conditional + Tailwind classes with conflict resolution. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/globals.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

210
src/pages/Landing.tsx Normal file
View file

@ -0,0 +1,210 @@
import { Link } from 'react-router-dom'
import { LogoMark } from '@/components/brand/Logo'
import { GlobeCanvas } from '@/components/landing/GlobeCanvas'
import { AgentFlow } from '@/components/landing/AgentFlow'
const REAL = [
{ k: 'L1', t: 'Live Hyperliquid data', d: 'Real mark price, funding, open interest, volume and OHLC candles — straight from the public API.' },
{ k: 'L2', t: 'In-browser technical analysis', d: 'RSI · MACD · EMA · ATR · Bollinger · funding-Z, recomputed every poll. No black box.' },
{ k: 'L3', t: 'A committee of 4 agents', d: 'Trend, Breakout, Mean-Rev and Funding each vote LONG / SHORT / SKIP with a reason.' },
{ k: 'L4', t: 'One transparent verdict', d: 'Votes aggregate into a deterministic call with confidence, size, TP and SL. Same market → same decision.' },
]
function CTA() {
return (
<div className="flex flex-wrap items-center gap-3">
<Link
to="/terminal"
className="group inline-flex items-center gap-2 rounded-sm px-5 py-2.5 font-mono text-label font-semibold uppercase tracking-[0.08em] text-ink transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--color-accent)', boxShadow: '0 0 28px rgba(255,176,32,0.35)' }}
>
Open terminal
<span className="transition-transform group-hover:translate-x-0.5"></span>
</Link>
<a
href="#how"
className="inline-flex items-center gap-2 rounded-sm border border-line px-5 py-2.5 font-mono text-label font-semibold uppercase tracking-[0.08em] text-bone-dim transition-colors hover:border-line-strong hover:text-bone"
>
How it works
</a>
</div>
)
}
export function Landing() {
return (
<div className="bg-paper">
{/* ── top nav ── */}
<header className="absolute inset-x-0 top-0 z-30">
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-5 sm:px-8">
<span className="flex items-center gap-2.5">
<LogoMark className="h-6 w-6" />
<span className="font-display text-base font-bold tracking-tight text-bone">Quant<span className="text-muted"> Agent</span></span>
</span>
<Link
to="/terminal"
className="flex items-center gap-1.5 font-mono text-micro uppercase tracking-[0.14em] text-muted transition-colors hover:text-accent"
>
<span className="inline-block h-1.5 w-1.5 rounded-full blink" style={{ background: 'var(--color-long)' }} />
Terminal
</Link>
</div>
</header>
{/* ── hero ── */}
<section className="relative flex min-h-dvh items-center overflow-hidden">
<GlobeCanvas className="pointer-events-none absolute inset-y-0 right-0 h-full w-[92%] opacity-80 sm:w-[58%]" />
{/* left scrim so the headline stays legible over the globe */}
<div className="absolute inset-0" style={{ background: 'linear-gradient(90deg, var(--color-paper) 0%, var(--color-paper) 26%, rgba(11,12,14,0.5) 58%, rgba(11,12,14,0) 100%)' }} />
<div className="absolute inset-x-0 bottom-0 h-32" style={{ background: 'linear-gradient(to top, var(--color-paper), transparent)' }} />
<div className="relative mx-auto w-full max-w-[1280px] px-5 sm:px-8">
<div className="max-w-2xl">
<div className="reveal label-sm flex items-center gap-2 !text-faint" style={{ animationDelay: '0.05s' }}>
<span className="inline-block h-1.5 w-1.5 rounded-full pulse-glow" style={{ background: 'var(--color-accent)' }} />
Multi-agent · Hyperliquid perps · paper · dry-run
</div>
<h1 className="reveal mt-5 font-display text-[clamp(2.6rem,8vw,6rem)] font-bold leading-[0.95] tracking-tight text-bone" style={{ animationDelay: '0.12s' }}>
Four agents argue.
<br />
<span className="glow-amber" style={{ color: 'var(--color-accent)' }}>
One verdict.
</span>
</h1>
<p className="reveal mt-6 max-w-xl font-mono text-sm leading-relaxed text-bone-dim sm:text-base" style={{ animationDelay: '0.2s' }}>
A real-time quant terminal for Hyperliquid perps. Live market data in-browser
technical analysis a committee of strategy agents <span className="text-bone">LONG / SHORT / SKIP</span>{' '}
with confidence and risk parameters. Paper-traded, fully transparent.
</p>
<div className="reveal mt-9" style={{ animationDelay: '0.28s' }}>
<CTA />
</div>
</div>
</div>
</section>
{/* ── how agents work ── */}
<section id="how" className="mx-auto max-w-[1280px] px-5 py-20 sm:px-8 sm:py-28">
<div className="flex flex-col gap-2 border-b border-line-soft pb-6">
<span className="label !text-faint">How it works</span>
<h2 className="font-display text-[clamp(1.8rem,4vw,3rem)] font-bold tracking-tight text-bone">
Data in. <span style={{ color: 'var(--color-accent)' }}>The call</span> out.
</h2>
<p className="max-w-2xl font-mono text-sm leading-relaxed text-muted">
Every cycle runs the same five steps: pull the data, compute the analysis, let four
agents vote, resolve one verdict, push it to Telegram. It plays below on a loop
tap any step to jump to it.
</p>
</div>
<div className="mt-8">
<AgentFlow />
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<p className="font-mono text-micro text-faint">
Looping demo. Execution is paper (dry-run) no orders are signed.
</p>
<Link to="/terminal" className="font-mono text-micro uppercase tracking-[0.12em] text-accent hover:underline">
See it live on the terminal
</Link>
</div>
</section>
{/* ── what's real ── */}
<section className="mx-auto max-w-[1280px] px-5 pb-20 sm:px-8 sm:pb-28">
<span className="label !text-faint">What's real</span>
<div className="mt-5 grid grid-cols-1 gap-px overflow-hidden rounded-lg border border-line bg-line-soft sm:grid-cols-2 lg:grid-cols-4">
{REAL.map((r) => (
<div key={r.k} className="bg-panel p-5">
<div className="font-mono text-micro text-faint">{r.k}</div>
<div className="mt-1.5 font-display text-base font-semibold text-bone">{r.t}</div>
<p className="mt-2 font-mono text-micro leading-relaxed text-muted">{r.d}</p>
</div>
))}
</div>
</section>
{/* ── footer (strict Carbon Desk terminal style) ── */}
<footer className="border-t border-line bg-panel">
<div className="mx-auto max-w-[1280px] px-5 py-12 sm:px-8">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div className="lg:col-span-1">
<span className="flex items-center gap-2.5">
<LogoMark className="h-6 w-6" />
<span className="font-display text-base font-bold tracking-tight text-bone">Quant<span className="text-muted"> Agent</span></span>
<span className="label-sm !text-faint">Terminal</span>
</span>
<p className="mt-4 max-w-xs font-mono text-micro leading-relaxed text-muted">
A transparent multi-agent quant terminal on Hyperliquid. Live data verdict Telegram.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="flex items-center gap-1.5 rounded-sm border border-line-soft px-2 py-1 font-mono text-micro text-long">
<span className="inline-block h-1.5 w-1.5 rounded-full blink" style={{ background: 'var(--color-long)' }} />
LIVE · HL
</span>
<span className="rounded-sm border px-2 py-1 font-mono text-micro" style={{ borderColor: 'var(--color-line-amber)', color: 'var(--color-accent)' }}>
PAPER · DRY-RUN
</span>
</div>
</div>
<FooterCol title="Product" links={[
{ label: 'Terminal', to: '/terminal' },
{ label: 'Agent committee', href: '#how' },
{ label: "What's real", href: '#how' },
]} />
<FooterCol title="System" links={[
{ label: 'L1 · Data', href: '#how' },
{ label: 'L2 · Analysis', href: '#how' },
{ label: 'L3 · Signal', href: '#how' },
{ label: 'L4 · Decision', href: '#how' },
]} />
<FooterCol title="Network" links={[
{ label: 'Hyperliquid ↗', href: 'https://hyperliquid.xyz' },
{ label: 'Hyperliquid docs ↗', href: 'https://hyperliquid.gitbook.io' },
]} />
</div>
<div className="mt-12 flex flex-col gap-2 border-t border-line-soft pt-6 font-mono text-micro leading-relaxed text-muted sm:flex-row sm:items-center sm:justify-between">
<p className="max-w-2xl">
Market data is live from the Hyperliquid public API; indicators, agent votes and the
verdict are computed in-browser. Execution is paper (dry-run).{' '}
<span className="text-bone-dim">Not financial advice.</span>
</p>
<span className="label-sm shrink-0 !text-faint">© Quant Agent · pre-TGE</span>
</div>
</div>
</footer>
</div>
)
}
function FooterCol({
title,
links,
}: {
title: string
links: { label: string; to?: string; href?: string }[]
}) {
return (
<div>
<div className="label !text-bone-dim">{title}</div>
<ul className="mt-4 space-y-2.5">
{links.map((l) => (
<li key={l.label}>
{l.to ? (
<Link to={l.to} className="font-mono text-micro text-muted transition-colors hover:text-bone">
{l.label}
</Link>
) : (
<a href={l.href} className="font-mono text-micro text-muted transition-colors hover:text-bone">
{l.label}
</a>
)}
</li>
))}
</ul>
</div>
)
}

558
src/pages/Terminal.tsx Normal file
View file

@ -0,0 +1,558 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { LogoMark } from '@/components/brand/Logo'
import { TVChart, ChartSkeleton } from '@/components/viz/TVChart'
import { LiveFeed } from '@/components/viz/LiveFeed'
import { Orchestra } from '@/components/viz/Orchestra'
import { AgentWorkflow } from '@/components/viz/AgentWorkflow'
import { Onboarding, TOUR_EVENT } from '@/components/Onboarding'
import { useScreen } from '@/hooks/useHyperliquid'
import { fmtPrice, fmtPct, fmtCompact } from '@/lib/format'
import type { Direction, Vote } from '@/lib/signals'
const COINS = ['BTC', 'ETH', 'SOL']
const TIMEFRAMES = ['1m', '5m', '15m', '1h', '4h', '1d'] as const
const AGENT_META: Record<string, { label: string; reads: string }> = {
TrendAgent: { label: 'Trend', reads: 'EMA 9/21 · RSI' },
BreakoutAgent: { label: 'Breakout', reads: 'Bollinger %B' },
MeanReversionAgent: { label: 'Mean-Rev', reads: 'RSI extremes' },
FundingAgent: { label: 'Funding', reads: 'funding-Z' },
}
const AGENT_RULES: Record<string, string> = {
TrendAgent: 'Trend — LONG when EMA9 > EMA21 and RSI > 50; SHORT on the reverse; else SKIP.',
BreakoutAgent: 'Breakout — LONG when price pushes the upper Bollinger band (%B > 0.92); SHORT at the lower (< 0.08).',
MeanReversionAgent: 'Mean-Rev — fade extremes: LONG when RSI < 32 in the lower band; SHORT when RSI > 68 at the upper.',
FundingAgent: 'Funding — fade crowded funding: SHORT when funding-Z > 1.5; LONG when < 1.5.',
}
const METRIC_INFO: Record<string, string> = {
Mark: 'Mark price — the fair price Hyperliquid uses for PnL & liquidation, not the last trade.',
'24h Δ': 'Price change over the last 24 hours.',
Funding: 'Funding rate — periodic longs↔shorts payment. Positive = longs pay (crowded longs).',
ATR: 'Average True Range — volatility; it scales the verdict TP / SL.',
'Open interest': 'Open interest — total USD value of open perpetual contracts.',
'24h volume': '24-hour traded volume, in USD.',
'RSI (14)': 'RSI — momentum 0100; >70 overbought, <30 oversold. Feeds Mean-Rev.',
'Bollinger %B': 'Where price sits in the Bollinger band (0 = lower, 1 = upper). Feeds Breakout.',
'EMA 9/21': 'Fast vs slow moving average — trend direction. Feeds Trend.',
'Funding-Z': 'Normalised funding skew. Feeds the Funding agent.',
'MACD hist': 'MACD histogram — momentum (MACD line minus its signal).',
'Net vote': 'Aggregate of the 4 agents (LONG +, SHORT ). Drives the verdict.',
}
const INK = 'var(--color-bone)'
const clampN = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
const dirColor = (d: Direction) =>
d === 'LONG' ? 'var(--color-long)' : d === 'SHORT' ? 'var(--color-short)' : 'var(--color-muted)'
const dirBg = (d: Direction) =>
d === 'LONG' ? 'var(--color-long-dim)' : d === 'SHORT' ? 'var(--color-short-dim)' : 'var(--color-track)'
const sign = (n: number) => (n >= 0 ? '+' : '')
type Inspect = (s: string | null) => void
/* ── primitives ───────────────────────────────────────────────────────────── */
function Panel({
title,
meta,
className = '',
children,
}: {
title: React.ReactNode
meta?: React.ReactNode
className?: string
children: React.ReactNode
}) {
return (
<section className={`overflow-hidden rounded-md border border-line bg-panel ${className}`}>
<header className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<span className="label !text-[0.625rem]">{title}</span>
{meta != null && <span className="font-mono text-micro text-faint">{meta}</span>}
</header>
{children}
</section>
)
}
function Cell({
label,
value,
tone,
sub,
bar,
barTone,
info,
onInspect,
loading,
}: {
label: string
value: string
tone?: string
sub?: string
bar?: number
barTone?: string
info?: string
onInspect?: Inspect
loading?: boolean
}) {
return (
<div
className={`bg-inset px-3 py-2 transition-colors ${info && !loading ? 'cursor-help hover:bg-panel-2' : ''}`}
onMouseEnter={info && onInspect && !loading ? () => onInspect(info) : undefined}
onMouseLeave={info && onInspect && !loading ? () => onInspect(null) : undefined}
>
<div className="label-sm !text-[0.5625rem]">{label}</div>
{loading ? (
<div className="skeleton mt-1.5 h-3.5 w-3/5" />
) : (
<div className="tnum mt-1 font-mono text-data font-medium" style={{ color: tone ?? INK }}>
{value}
</div>
)}
{sub && !loading && <div className="mt-0.5 font-mono text-micro text-faint">{sub}</div>}
{bar != null && !loading && (
<div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div
className="h-full rounded-full"
style={{ width: `${clampN(bar, 0, 100)}%`, background: barTone ?? 'var(--color-bone-dim)', transition: 'width .5s cubic-bezier(.22,1,.36,1)' }}
/>
</div>
)}
</div>
)
}
function DivergeBar({ direction, score }: { direction: Direction; score: number }) {
const col = dirColor(direction)
return (
<div className="relative h-2 flex-1 overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div className="absolute inset-y-0 left-1/2 w-px" style={{ background: 'var(--color-line-strong)' }} />
{direction === 'LONG' && (
<div className="absolute inset-y-0.5 rounded-r-full" style={{ left: '50%', width: `${score * 50}%`, background: col, transition: 'width .5s cubic-bezier(.22,1,.36,1)' }} />
)}
{direction === 'SHORT' && (
<div className="absolute inset-y-0.5 rounded-l-full" style={{ right: '50%', width: `${score * 50}%`, background: col, transition: 'width .5s cubic-bezier(.22,1,.36,1)' }} />
)}
{direction === 'SKIP' && (
<div className="absolute inset-y-0.5 left-1/2 w-1 -translate-x-1/2 rounded-full" style={{ background: 'var(--color-muted)' }} />
)}
</div>
)
}
/* ── command bar ──────────────────────────────────────────────────────────── */
function StatusBar({ live, ago, stale, onReload }: { live: boolean; ago: number | null; stale: boolean; onReload: () => void }) {
const [now, setNow] = useState(() => new Date())
const [spin, setSpin] = useState(false)
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
const reload = () => {
onReload()
setSpin(true)
setTimeout(() => setSpin(false), 600)
}
const clock = now.toISOString().slice(11, 19)
const statusCol = stale ? 'var(--color-accent)' : live ? 'var(--color-long)' : 'var(--color-muted)'
const statusText = stale ? 'DATA STALE' : live ? 'LIVE · HL' : 'CONNECTING'
return (
<header className="sticky top-0 z-50 border-b border-line bg-paper/90 backdrop-blur-md">
<div className="mx-auto flex h-10 max-w-none items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-2.5">
<Link to="/" className="flex items-center gap-2.5" aria-label="Home">
<LogoMark className="h-5 w-5" />
<span className="font-display text-sm font-bold tracking-tight text-bone">
Quant<span className="text-muted"> Agent</span>
</span>
</Link>
<span className="label-sm hidden border-l border-line-soft pl-2.5 !text-faint sm:inline">Terminal</span>
</div>
<div className="flex items-center gap-3 font-mono text-micro uppercase tracking-[0.1em]">
<span className="flex items-center gap-1.5" style={{ color: statusCol }}>
<span className={stale ? 'inline-block h-1.5 w-1.5 rounded-full' : 'blink inline-block h-1.5 w-1.5 rounded-full'} style={{ background: statusCol }} />
{statusText}
</span>
<button type="button" onClick={reload} title="Refresh now (r)" aria-label="Refresh now" className="flex cursor-pointer items-center gap-1 text-faint transition-colors hover:text-bone">
<span className={spin ? 'inline-block animate-spin' : 'inline-block'} style={{ animationDuration: '0.6s' }} aria-hidden></span>
{ago != null && <span className="tnum hidden sm:inline">{ago}s</span>}
</button>
<span className="tnum hidden text-bone-dim md:inline">{clock} UTC</span>
<span className="rounded-sm border px-1.5 py-0.5 text-[0.55rem]" style={{ borderColor: 'var(--color-line-amber)', color: 'var(--color-accent)' }}>
PAPER · DRY-RUN
</span>
</div>
</div>
</header>
)
}
/* ── compact verdict bar (the call — dense, not a hero) ───────────────────── */
function VerdictBar({ decision, confidence, sizePct, tpPct, slPct, net, votes, reasoning, live, loading }: {
decision: Direction; confidence: number; sizePct: number; tpPct: number; slPct: number; net: number; votes: Vote[]; reasoning: string; live: boolean; loading: boolean
}) {
if (loading) {
return (
<div className="flex items-center gap-4 rounded-md border border-line bg-panel py-2.5 pl-3 pr-4" style={{ borderLeft: '2px solid var(--color-line-strong)' }} aria-busy="true">
<span className="label-sm !text-faint">Verdict</span>
<div className="skeleton h-6 w-24" />
<div className="skeleton h-3.5 w-12" />
<div className="skeleton h-3.5 w-14" />
<div className="skeleton hidden h-3.5 w-12 sm:block" />
<div className="skeleton hidden h-3.5 w-14 sm:block" />
<div className="skeleton hidden h-3.5 w-14 sm:block" />
</div>
)
}
const active = votes.filter((v) => v.direction !== 'SKIP')
const agree = active.filter((v) => v.direction === decision)
const rr = slPct ? tpPct / slPct : 0
const col = dirColor(decision)
const Sep = () => <span className="hidden h-4 w-px bg-line-soft sm:block" />
const Field = ({ k, v, c }: { k: string; v: string; c?: string }) => (
<span className="flex items-baseline gap-1.5">
<span className="label-sm !text-[0.5625rem] !text-faint">{k}</span>
<span className="tnum font-mono text-data font-medium" style={{ color: c ?? INK }}>{v}</span>
</span>
)
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-line bg-panel py-2.5 pl-3 pr-4" style={{ borderLeft: `2px solid ${col}` }}>
<span className="label-sm !text-faint">Verdict</span>
<span className="font-display text-2xl font-bold leading-none tracking-tight" style={{ color: col }}>{decision}</span>
<Field k="conf" v={`${Math.round(confidence * 100)}%`} c={col} />
<Field k="net" v={`${sign(net)}${net.toFixed(2)}`} c={net > 0 ? 'var(--color-long)' : net < 0 ? 'var(--color-short)' : INK} />
<span className="font-mono text-micro text-muted">{decision === 'SKIP' ? 'no consensus' : `${agree.length}/${active.length} agree`}</span>
<Sep />
<Field k="size" v={`${sizePct}%`} />
<Field k="TP" v={fmtPct(tpPct)} c="var(--color-long)" />
<Field k="SL" v={fmtPct(-slPct)} c="var(--color-short)" />
<Field k="R:R" v={rr ? rr.toFixed(1) : '—'} />
{!live && (
<span className="label-sm rounded-sm px-1.5 py-0.5 !text-accent" style={{ background: 'var(--color-accent-dim)' }}>seed</span>
)}
<span className="ml-auto hidden max-w-sm truncate font-mono text-micro text-muted xl:block">{reasoning}</span>
</div>
)
}
/* ── committee (selectable, dense) ────────────────────────────────────────── */
function Committee({ votes, net, activeAgent, onSelect, onHover, loading }: {
votes: Vote[]; net: number; activeAgent?: string | null; onSelect?: (a: string | null) => void; onHover?: (a: string | null) => void; loading?: boolean
}) {
if (loading) {
return (
<Panel title="Committee · analytics" meta="4 · L3">
<div className="divide-y divide-line-soft" aria-busy="true">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="grid grid-cols-[5rem_1fr] gap-x-3 px-3 py-2.5">
<div className="space-y-1.5 self-center">
<div className="skeleton h-3 w-12" />
<div className="skeleton h-2 w-16" />
</div>
<div className="flex items-center gap-2.5">
<div className="skeleton h-2 flex-1" />
<div className="skeleton h-3 w-10" />
</div>
</div>
))}
</div>
</Panel>
)
}
const active = votes.filter((v) => v.direction !== 'SKIP')
const lean = active.length ? clampN(net / active.length, -1, 1) : 0
return (
<Panel title="Committee · analytics" meta={`${votes.length} · L3`}>
<div className="divide-y divide-line-soft">
{votes.map((v) => {
const meta = AGENT_META[v.agent] ?? { label: v.agent.replace('Agent', ''), reads: '' }
const signal = v.direction !== 'SKIP'
const col = dirColor(v.direction)
const on = activeAgent === v.agent
return (
<button
key={v.agent}
type="button"
onClick={() => onSelect?.(on ? null : v.agent)}
onMouseEnter={() => onHover?.(v.agent)}
onMouseLeave={() => onHover?.(null)}
aria-pressed={on}
className={['grid w-full cursor-pointer grid-cols-[5rem_1fr] gap-x-3 border-l-2 px-3 py-2.5 text-left transition-colors', on ? 'border-accent bg-panel-2' : 'border-transparent hover:bg-panel-2'].join(' ')}
>
<div className="self-center">
<div className="font-mono text-data font-medium text-bone">{meta.label}</div>
<div className="label-sm !text-[0.5625rem] !text-faint">{meta.reads}</div>
</div>
<div className="flex items-center gap-2.5">
<DivergeBar direction={v.direction} score={v.score} />
<span className="tnum w-6 shrink-0 text-right font-mono text-micro text-muted">{Math.round(v.score * 100)}</span>
<span className="w-12 shrink-0 rounded-sm px-1 py-0.5 text-center font-mono text-[0.5625rem] font-semibold uppercase tracking-[0.04em]" style={{ color: signal ? col : 'var(--color-muted)', background: dirBg(v.direction) }}>
{signal ? v.direction : 'skip'}
</span>
</div>
<p className="col-start-2 mt-1 font-mono text-micro leading-snug text-bone-dim">{v.reason}</p>
{on && AGENT_RULES[v.agent] && (
<p className="reveal col-start-2 mt-1 font-mono text-micro leading-snug text-faint" style={{ animationDuration: '0.4s' }}>{AGENT_RULES[v.agent]}</p>
)}
</button>
)
})}
</div>
<div className="grid grid-cols-[5rem_1fr] items-center gap-x-3 border-t border-line-strong bg-inset px-3 py-2.5">
<span className="label-sm !text-[0.5625rem] !text-bone-dim">net lean</span>
<div className="flex items-center gap-2.5">
<div className="relative h-2 flex-1 overflow-hidden rounded-full" style={{ background: 'var(--color-track)' }}>
<div className="absolute inset-y-0 left-1/2 w-px" style={{ background: 'var(--color-line-strong)' }} />
{lean >= 0 ? (
<div className="absolute inset-y-0.5 rounded-r-full" style={{ left: '50%', width: `${lean * 50}%`, background: 'var(--color-long)' }} />
) : (
<div className="absolute inset-y-0.5 rounded-l-full" style={{ right: '50%', width: `${-lean * 50}%`, background: 'var(--color-short)' }} />
)}
<div className="absolute inset-y-0 w-0.5" style={{ left: `${50 + lean * 50}%`, background: 'var(--color-accent)' }} />
</div>
<span className="tnum w-6 shrink-0 text-right font-mono text-micro text-bone">{net.toFixed(1)}</span>
<span className="w-12 shrink-0" />
</div>
</div>
</Panel>
)
}
/* ── help card (opened from the ? in the toolbar) ─────────────────────────── */
const GUIDE = [
{ t: 'The verdict', d: "The committee's single call — LONG (up), SHORT (down) or SKIP (no edge), with confidence and the suggested size / TP / SL." },
{ t: 'The committee', d: 'Four strategies each vote. The graph shows them feeding the verdict; the list shows each vote and why.' },
{ t: 'Interact', d: 'Switch timeframe & coin up top, hover any field to inspect it, click an agent to pin it, ↻ to refresh. Keys: 1/2/3 coin · r refresh · Esc clear.' },
]
function GuideCard({ onClose }: { onClose: () => void }) {
return (
<section className="overflow-hidden rounded-md border border-line bg-panel">
<div className="flex items-center justify-between gap-3 border-b border-line-soft px-3 py-2">
<span className="label !text-bone">How to read this terminal</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => { onClose(); window.dispatchEvent(new Event(TOUR_EVENT)) }}
className="rounded-sm border border-line px-2 py-0.5 font-mono text-micro uppercase tracking-[0.08em] text-muted transition-colors hover:border-accent hover:text-accent"
>
replay intro
</button>
<button type="button" onClick={onClose} className="rounded-sm border border-line px-2 py-0.5 font-mono text-micro uppercase tracking-[0.08em] text-muted transition-colors hover:border-line-strong hover:text-bone">close </button>
</div>
</div>
<div className="grid grid-cols-1 gap-px bg-line-soft sm:grid-cols-3">
{GUIDE.map((g, i) => (
<div key={g.t} className="bg-inset px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="tnum flex h-4 w-4 items-center justify-center rounded-full font-mono text-[0.6rem] font-bold" style={{ background: 'var(--color-accent)', color: 'var(--color-ink)' }}>{i + 1}</span>
<span className="font-mono text-data font-semibold text-bone">{g.t}</span>
</div>
<p className="mt-1.5 font-mono text-micro leading-relaxed text-bone-dim">{g.d}</p>
</div>
))}
</div>
</section>
)
}
/* ── page ─────────────────────────────────────────────────────────────────── */
export function Terminal() {
const [coin, setCoin] = useState('BTC')
const [tf, setTf] = useState<string>('1h')
const [selectedAgent, setSelectedAgent] = useState<string | null>(null)
const [hoverAgent, setHoverAgent] = useState<string | null>(null)
const [inspect, setInspect] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const activeAgent = hoverAgent ?? selectedAgent
const { data, live, updatedAt, error, reload } = useScreen(coin, tf, 12_000)
const ta = data?.ta
const v = data?.verdict
const stat = data?.stat
const votes = data?.votes ?? []
const candles = data?.candles ?? []
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === '1' || e.key === '2' || e.key === '3') setCoin(COINS[+e.key - 1])
else if (e.key.toLowerCase() === 'r') reload()
else if (e.key === 'Escape') {
setSelectedAgent(null)
setGuideOpen(false)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [reload])
const ago = updatedAt ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : null
const stale = Boolean(error) || (ago != null && ago > 40)
const loading = updatedAt === 0 // no live data for this instrument yet → show skeletons
const changePct = stat?.changePct ?? 0
const changeTone = changePct >= 0 ? 'var(--color-long)' : 'var(--color-short)'
const funding = stat?.funding ?? 0
const fundingTone = funding >= 0 ? 'var(--color-short)' : 'var(--color-long)'
const macdHist = ta?.macdHist ?? 0
const macdTone = macdHist >= 0 ? 'var(--color-long)' : 'var(--color-short)'
const emaBull = (ta?.ema9 ?? 0) >= (ta?.ema21 ?? 0)
const emaTone = emaBull ? 'var(--color-long)' : 'var(--color-short)'
const net = v?.net ?? 0
const netTone = net > 0.001 ? 'var(--color-long)' : net < -0.001 ? 'var(--color-short)' : INK
const rsi = ta?.rsi ?? 50
const rsiTone = rsi < 32 ? 'var(--color-long)' : rsi > 68 ? 'var(--color-short)' : INK
const decision = (v?.decision ?? 'SKIP') as Direction
const marketMeta = live ? 'LIVE · HL' : updatedAt > 0 ? 'STALE' : 'CONNECTING…'
const onAgentHover = (a: string | null) => {
setHoverAgent(a)
setInspect(a ? AGENT_RULES[a] ?? null : null)
}
return (
<div className="min-h-dvh bg-paper">
<StatusBar live={live} ago={ago} stale={stale} onReload={reload} />
<main className="mx-auto max-w-none space-y-2.5 px-4 py-3 sm:px-6">
{/* ── command bar — terminal grid: identity · instrument · timeframe ── */}
<div className="overflow-hidden rounded-md border border-line bg-panel">
<div className="grid grid-cols-1 gap-px bg-line-soft sm:grid-cols-[minmax(0,1fr)_auto_auto]">
{/* identity + help */}
<div className="flex items-center gap-2.5 bg-panel px-3 py-2">
<h1 className="font-display text-lg font-bold leading-none tracking-tight text-bone">
{coin}<span className="text-muted">-PERP</span>
</h1>
<span className="tnum font-mono text-data text-bone-dim">${fmtPrice(ta?.price ?? 0)}</span>
<span className="tnum font-mono text-micro" style={{ color: changeTone }}>
{sign(changePct)}{changePct.toFixed(2)}%
</span>
<button type="button" onClick={() => setGuideOpen((o) => !o)} aria-label="Help"
className="ml-auto flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-line font-mono text-micro text-muted transition-colors hover:border-line-strong hover:text-bone">?</button>
</div>
{/* instrument switch — uniform grid cells */}
<div className="flex flex-col justify-center bg-panel px-3 py-2">
<div className="label-sm mb-1.5 !text-[0.5625rem] !text-faint">Instrument</div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-sm bg-line-soft" role="tablist" aria-label="Instrument">
{COINS.map((c) => {
const on = c === coin
return (
<button key={c} role="tab" aria-selected={on} onClick={() => setCoin(c)}
style={on ? { boxShadow: 'inset 0 1.5px 0 var(--color-accent)' } : undefined}
className={['cursor-pointer px-3 py-1.5 text-center font-mono text-[0.625rem] font-semibold uppercase tracking-[0.06em] transition-colors', on ? 'bg-panel-2 text-accent' : 'bg-inset text-muted hover:bg-panel-2 hover:text-bone'].join(' ')}>
{c}
</button>
)
})}
</div>
</div>
{/* timeframe switch — uniform grid cells */}
<div className="flex flex-col justify-center bg-panel px-3 py-2">
<div className="label-sm mb-1.5 !text-[0.5625rem] !text-faint">Timeframe</div>
<div className="grid grid-cols-6 gap-px overflow-hidden rounded-sm bg-line-soft sm:min-w-[18rem]" role="tablist" aria-label="Timeframe">
{TIMEFRAMES.map((t) => {
const on = t === tf
return (
<button key={t} role="tab" aria-selected={on} onClick={() => setTf(t)}
style={on ? { boxShadow: 'inset 0 1.5px 0 var(--color-accent)' } : undefined}
className={['cursor-pointer px-2 py-1.5 text-center font-mono text-[0.625rem] font-semibold uppercase transition-colors', on ? 'bg-panel-2 text-accent' : 'bg-inset text-muted hover:bg-panel-2 hover:text-bone'].join(' ')}>
{t}
</button>
)
})}
</div>
</div>
</div>
</div>
{guideOpen && <GuideCard onClose={() => setGuideOpen(false)} />}
{/* ── the call (compact) ── */}
<VerdictBar
decision={decision}
confidence={v?.confidence ?? 0.5}
sizePct={v?.sizePct ?? 1}
tpPct={v?.tpPct ?? 2}
slPct={v?.slPct ?? 1.2}
net={net}
votes={votes}
reasoning={v?.reasoning ?? 'Awaiting live market data…'}
live={live}
loading={loading}
/>
{/* ── orchestra · the full agent pipeline ── */}
<Orchestra votes={votes} decision={decision} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} onInspect={setInspect} loading={loading} />
{/* ── agent flow · the pipeline as an interactive workflow graph ── */}
<AgentWorkflow data={data} decision={decision} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} onInspect={setInspect} updatedAt={updatedAt} live={live} loading={loading} />
{/* ── workspace ── */}
<div className="grid items-start gap-2.5 lg:grid-cols-[250px_minmax(0,1fr)_330px]">
{/* LEFT — stats + pipeline */}
<div className="space-y-2.5">
<Panel title="Market" meta={marketMeta}>
<div className="grid grid-cols-2 gap-px bg-line-soft">
<Cell label="Mark" value={`$${fmtPrice(stat?.mark ?? ta?.price ?? 0)}`} info={METRIC_INFO.Mark} onInspect={setInspect} loading={loading} />
<Cell label="24h Δ" value={`${sign(changePct)}${changePct.toFixed(2)}%`} tone={changeTone} info={METRIC_INFO['24h Δ']} onInspect={setInspect} loading={loading} />
<Cell label="Funding" value={`${sign(funding)}${(funding * 100).toFixed(4)}%`} tone={fundingTone} info={METRIC_INFO.Funding} onInspect={setInspect} loading={loading} />
<Cell label="ATR" value={`${(ta?.atrPct ?? 0).toFixed(2)}%`} info={METRIC_INFO.ATR} onInspect={setInspect} loading={loading} />
<Cell label="Open interest" value={`$${fmtCompact(stat?.oiUsd ?? 0)}`} info={METRIC_INFO['Open interest']} onInspect={setInspect} loading={loading} />
<Cell label="24h volume" value={`$${fmtCompact(stat?.dayVolumeUsd ?? 0)}`} info={METRIC_INFO['24h volume']} onInspect={setInspect} loading={loading} />
</div>
</Panel>
<Panel title="Indicators" meta="→ committee">
<div className="grid grid-cols-2 gap-px bg-line-soft">
<Cell label="RSI (14)" value={rsi.toFixed(1)} tone={rsiTone} sub="→ Mean-Rev" bar={rsi} barTone={rsiTone} info={METRIC_INFO['RSI (14)']} onInspect={setInspect} loading={loading} />
<Cell label="Bollinger %B" value={(ta?.pctB ?? 0.5).toFixed(2)} sub="→ Breakout" bar={(ta?.pctB ?? 0.5) * 100} info={METRIC_INFO['Bollinger %B']} onInspect={setInspect} loading={loading} />
<Cell label="EMA 9/21" value={emaBull ? 'BULL ▲' : 'BEAR ▼'} tone={emaTone} sub="→ Trend" info={METRIC_INFO['EMA 9/21']} onInspect={setInspect} loading={loading} />
<Cell label="Funding-Z" value={(ta?.fundingZ ?? 0).toFixed(2)} sub="→ Funding" info={METRIC_INFO['Funding-Z']} onInspect={setInspect} loading={loading} />
<Cell label="MACD hist" value={`${macdHist >= 0 ? '▲' : '▼'} ${macdHist.toFixed(2)}`} tone={macdTone} info={METRIC_INFO['MACD hist']} onInspect={setInspect} loading={loading} />
<Cell label="Net vote" value={`${sign(net)}${net.toFixed(2)}`} tone={netTone} info={METRIC_INFO['Net vote']} onInspect={setInspect} loading={loading} />
</div>
</Panel>
</div>
{/* CENTER — chart */}
<Panel title={`${coin}-PERP · OHLC`} meta={live ? `LIVE · ${tf.toUpperCase()} · 120` : marketMeta}>
<div className="bg-inset px-2 py-2 sm:px-3">
{updatedAt > 0 ? <TVChart candles={candles} height={540} fitKey={`${coin}-${tf}`} /> : <ChartSkeleton height={540} />}
</div>
</Panel>
{/* RIGHT — committee */}
<div className="space-y-2.5">
<Committee votes={votes} net={net} activeAgent={activeAgent} onSelect={setSelectedAgent} onHover={onAgentHover} loading={loading} />
</div>
</div>
{/* ── live feed · the agent chain streaming in real time ── */}
<LiveFeed data={data} coin={coin} tf={tf} updatedAt={updatedAt} live={live} loading={loading} onInspect={setInspect} />
{/* ── inspector / status footer ── */}
<div className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 font-mono text-micro">
<span className="label-sm shrink-0 !text-[0.5625rem]" style={{ color: 'var(--color-accent)' }}>Inspect</span>
<span className="min-w-0 flex-1 truncate text-bone-dim">
{inspect ?? 'Hover any field, agent or pipeline stage to inspect it.'}
</span>
<span className="hidden shrink-0 text-faint md:inline">paper · dry-run · not advice</span>
</div>
</main>
<Onboarding />
</div>
)
}

273
src/styles/globals.css Normal file
View file

@ -0,0 +1,273 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
/* =================================================================
$QUANT design tokens · "Carbon Desk"
Dark institutional quant terminal. Carbon surfaces, elevation by
lightness, one amber accent for "the call", green/red for direction,
monospace for every number. Depth = borders + surface shifts (no shadows).
================================================================= */
@theme {
/* ---- surfaces (carbon — higher elevation = lighter; wells recede to inset) ---- */
--color-paper: #0b0c0e; /* page (bg-paper) */
--color-panel: #111316; /* panel chrome */
--color-panel-2: #181b20; /* raised: hover / active / dropdown */
--color-inset: #0e0f12; /* recessed: data cells, wells, tracks */
--color-ink: #0b0c0e; /* dark value for text-ink on light/accent fills */
/* ---- text hierarchy (4 levels, all AA on panel) ---- */
--color-bone: #e8eaed; /* primary */
--color-bone-dim: #aab1ba; /* secondary / supporting prose */
--color-muted: #828a94; /* labels (4.5:1+ on panel & panel-2) */
--color-faint: #727983; /* captions / ticks (4.5:1 on panel) */
/* ---- accent (single) — amber: the call / active / brand ---- */
--color-accent: #ffb020;
--color-accent-2: #ffc961;
--color-accent-dim: rgba(255, 176, 32, 0.12);
/* ---- trading status (semantic; the only other chroma) ---- */
--color-long: #2bd974;
--color-long-dim: rgba(43, 217, 116, 0.14);
--color-short: #f2444d;
--color-short-dim: rgba(242, 68, 77, 0.14);
--color-lime: #2bd974; /* alias kept for legacy ui/* primitives */
/* ---- lines / borders + recessed track ---- */
--color-line: rgba(255, 255, 255, 0.1); /* standard */
--color-line-soft: rgba(255, 255, 255, 0.06); /* soft separation */
--color-line-strong: rgba(255, 255, 255, 0.16); /* emphasis / zero-axis */
--color-line-amber: rgba(255, 176, 32, 0.45);
--color-track: rgba(255, 255, 255, 0.09); /* unfilled bar/ring channel */
/* ---- type ---- */
--font-display: 'Chakra Petch', 'Geist', ui-sans-serif, system-ui, sans-serif;
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, 'SF Mono', 'Roboto Mono', monospace;
/* type scale (a ratio, not eyeballed) */
--text-micro: 0.625rem; /* 10px — captions, codes, ticks */
--text-label: 0.6875rem; /* 11px — field labels */
--text-data: 0.8125rem; /* 13px — data values */
--text-read: 0.75rem; /* 12px — reasons / prose */
/* ---- radii ---- */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
}
@keyframes shimmer { 100% { transform: translateX(100%); } }
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes accordion-down {
from { height: 0; opacity: 0; }
to { height: var(--radix-accordion-content-height); opacity: 1; }
}
@keyframes accordion-up {
from { height: var(--radix-accordion-content-height); opacity: 1; }
to { height: 0; opacity: 0; }
}
@keyframes dash { to { stroke-dashoffset: 0; } }
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.15; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: none; }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* live-feed row reveal — flash + slide, opacity stays 1 (safe under reduced-motion) */
@keyframes feed-in {
0% { transform: translateX(-8px); background-color: var(--color-accent-dim); }
100% { transform: translateX(0); background-color: transparent; }
}
/* =================================================================
base
================================================================= */
:root { color-scheme: dark; --chart-1: oklch(0.32 0 none); --chart-2: oklch(0.41 0 none); --chart-3: oklch(0.54 0 none); --chart-4: oklch(0.71 0 none); --chart-5: oklch(0.89 0 none); --chart-background: oklch(1 0 0); --chart-foreground: oklch(0.145 0.004 285); --chart-foreground-muted: oklch(0.55 0.014 260); --chart-line-primary: var(--chart-1); --chart-line-secondary: var(--chart-2); --chart-crosshair: oklch(0.4 0.1828 274.34); --chart-grid: oklch(0.9 0 0); --chart-brush-border: var(--chart-grid); --chart-tooltip-background: oklch(0.21 0.006 285 / 0.8); --chart-tooltip-foreground: oklch(0.985 0 0); --chart-tooltip-muted: oklch(0.65 0.01 260); --chart-marker-background: oklch(0.97 0.005 260); --chart-marker-border: oklch(0.85 0.01 260); --chart-marker-foreground: oklch(0.3 0.01 260); --chart-ring-background: oklch(0.9 0.005 260 / 0.25); --chart-label: oklch(0.45 0.01 260); }
/* in @layer base so min-w-* / border-* utilities still win over this reset */
@layer base {
* {
border-color: var(--color-line-soft);
min-width: 0;
}
}
html {
background: var(--color-paper);
scrollbar-color: #2a2e34 #0e0f12;
scrollbar-width: thin;
-webkit-tap-highlight-color: transparent;
}
body {
margin: 0;
background: var(--color-paper);
color: var(--color-bone);
font-family: var(--font-sans);
font-synthesis-weight: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
overflow-x: clip;
}
/* tight technical display — Geist, weighty but not expanded */
h1, h2, h3, h4 {
font-family: var(--font-display);
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.02;
text-wrap: balance;
}
::selection {
background: rgba(255, 176, 32, 0.28);
color: #fff;
}
/* guaranteed amber focus indicator for every interactive element */
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ---- thin technical scrollbar ---- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #0e0f12; }
::-webkit-scrollbar-thumb {
background: #2a2e34;
border: 3px solid #0e0f12;
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover { background: #3a3f47; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.blink { animation: none !important; opacity: 1 !important; }
}
/* =================================================================
utilities
================================================================= */
@layer utilities {
/* tabular figures for any numeric data — prevents column jitter */
.tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
/* mono uppercase label — the single label primitive (+ -sm micro variant) */
.label {
font-family: var(--font-mono);
font-size: var(--text-label);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted);
}
.label-sm {
font-family: var(--font-mono);
font-size: var(--text-micro);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-muted);
}
.rule { border-top: 1px solid var(--color-line-strong); }
.rule-soft { border-top: 1px solid var(--color-line-soft); }
/* blinking caret/dot for "live" */
.blink { animation: blink 1.4s steps(2, end) infinite; }
/* live-feed new-row reveal (flash + slide) */
.feed-in { animation: feed-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* skeleton shimmer for not-yet-loaded data */
.skeleton {
position: relative;
overflow: hidden;
background: var(--color-track);
border-radius: 3px;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.07), transparent);
animation: shimmer 1.5s ease-in-out infinite;
}
/* ── agent workflow graph — smooth edge/node state transitions ── */
.wf-edge { transition: stroke 0.25s ease, stroke-width 0.25s ease, opacity 0.25s ease; }
.wf-node rect { transition: fill 0.2s ease, stroke 0.2s ease, stroke-width 0.2s ease; }
.wf-node { cursor: pointer; transform-box: fill-box; transform-origin: center; transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1); }
.wf-node:hover { transform: scale(1.035); }
@media (prefers-reduced-motion: reduce) { .wf-node:hover { transform: none; } }
/* ── landing (ACHI) — bold glow + staged reveal ── */
.reveal { opacity: 0; animation: rise 0.85s cubic-bezier(0.22, 1, 0.36, 1) both; }
.glow-amber { text-shadow: 0 0 24px rgba(255, 176, 32, 0.45), 0 0 64px rgba(255, 176, 32, 0.18); }
.glow-long { text-shadow: 0 0 24px rgba(43, 217, 116, 0.5), 0 0 64px rgba(43, 217, 116, 0.2); }
.pulse-glow { animation: pulse-glow 2.4s ease-in-out infinite; }
}
.dark {
--chart-1: oklch(1 0 none);
--chart-2: oklch(0.73 0 none);
--chart-3: oklch(0.51 0 none);
--chart-4: oklch(0.39 0 none);
--chart-5: oklch(0.32 0 none);
--chart-background: oklch(0.145 0 0);
--chart-foreground: oklch(0.45 0 0);
--chart-foreground-muted: oklch(0.65 0.01 260);
--chart-crosshair: oklch(0.45 0 0);
--chart-grid: oklch(0.25 0 0);
--chart-brush-border: var(--chart-grid);
--chart-marker-background: oklch(0.25 0.01 260);
--chart-marker-border: oklch(0.4 0.01 260);
--chart-marker-foreground: oklch(0.9 0 0);
--chart-ring-background: oklch(0.35 0.01 260 / 0.25);
--chart-label: oklch(0.75 0.01 260);
}
@theme inline {
--color-chart-1: var(----chart-1);
--color-chart-2: var(----chart-2);
--color-chart-3: var(----chart-3);
--color-chart-4: var(----chart-4);
--color-chart-5: var(----chart-5);
--color-chart-background: var(----chart-background);
--color-chart-foreground: var(----chart-foreground);
--color-chart-foreground-muted: var(----chart-foreground-muted);
--chart-line-primary: var(----chart-line-primary);
--chart-line-secondary: var(----chart-line-secondary);
--color-chart-crosshair: var(----chart-crosshair);
--color-chart-grid: var(----chart-grid);
--chart-brush-border: var(----chart-brush-border);
--color-chart-tooltip-background: var(----chart-tooltip-background);
--color-chart-tooltip-foreground: var(----chart-tooltip-foreground);
--color-chart-tooltip-muted: var(----chart-tooltip-muted);
--color-chart-marker-background: var(----chart-marker-background);
--color-chart-marker-border: var(----chart-marker-border);
--color-chart-marker-foreground: var(----chart-marker-foreground);
--color-chart-ring-background: var(----chart-ring-background);
--color-chart-label: var(----chart-label);
}

Some files were not shown because too many files have changed in this diff Show more