Quant Agent — multi-agent quant terminal on Hyperliquid
All checks were successful
deploy / deploy (push) Successful in 7s
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:
commit
f3b533b032
110 changed files with 15045 additions and 0 deletions
97
.agents/skills/add-x-tweet/SKILL.md
Normal file
97
.agents/skills/add-x-tweet/SKILL.md
Normal 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 0–2** = column 1 top 3, **3–5** = column 2 top 3, **6–8** = column 3 top 3.
|
||||
|
||||
- **Prioritize** when asked: place in the next open slot among indices 0–8 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",
|
||||
},
|
||||
```
|
||||
71
.agents/skills/add-x-tweet/scripts/fetch-tweet.mjs
Normal file
71
.agents/skills/add-x-tweet/scripts/fetch-tweet.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
30
.agents/skills/bklit-playground/SKILL.md
Normal file
30
.agents/skills/bklit-playground/SKILL.md
Normal 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/`.
|
||||
7
.agents/skills/bklit-playground/templates/page.tsx
Normal file
7
.agents/skills/bklit-playground/templates/page.tsx
Normal 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 {};
|
||||
115
.agents/skills/bklit-ship/SKILL.md
Normal file
115
.agents/skills/bklit-ship/SKILL.md
Normal 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 package’s 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` |
|
||||
134
.agents/skills/bklit-studio-chart-performance/SKILL.md
Normal file
134
.agents/skills/bklit-studio-chart-performance/SKILL.md
Normal 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 preview–specific 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` |
|
||||
107
.agents/skills/bklit-studio/SKILL.md
Normal file
107
.agents/skills/bklit-studio/SKILL.md
Normal 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` |
|
||||
136
.agents/skills/bklit-ui/SKILL.md
Normal file
136
.agents/skills/bklit-ui/SKILL.md
Normal 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)
|
||||
42
.agents/skills/bklit-ui/rules/animation.md
Normal file
42
.agents/skills/bklit-ui/rules/animation.md
Normal 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>
|
||||
57
.agents/skills/bklit-ui/rules/composition.md
Normal file
57
.agents/skills/bklit-ui/rules/composition.md
Normal 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 chart’s 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
|
||||
74
.agents/skills/bklit-ui/rules/installation.md
Normal file
74
.agents/skills/bklit-ui/rules/installation.md
Normal 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 project’s 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>
|
||||
55
.agents/skills/bklit-ui/rules/theming.md
Normal file
55
.agents/skills/bklit-ui/rules/theming.md
Normal 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
|
||||
71
.agents/skills/bklit-ui/rules/tooltips.md
Normal file
71
.agents/skills/bklit-ui/rules/tooltips.md
Normal 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
|
||||
299
.agents/skills/pr-open/SKILL.md
Normal file
299
.agents/skills/pr-open/SKILL.md
Normal 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 — 1–2 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
|
||||
|
||||
- <1–3 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
|
||||
914
.agents/skills/turborepo/SKILL.md
Normal file
914
.agents/skills/turborepo/SKILL.md
Normal 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
|
||||
70
.agents/skills/turborepo/command/turborepo.md
Normal file
70
.agents/skills/turborepo/command/turborepo.md
Normal 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>
|
||||
|
|
@ -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
|
||||
```
|
||||
335
.agents/skills/turborepo/references/best-practices/packages.md
Normal file
335
.agents/skills/turborepo/references/best-practices/packages.md
Normal 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.
|
||||
269
.agents/skills/turborepo/references/best-practices/structure.md
Normal file
269
.agents/skills/turborepo/references/best-practices/structure.md
Normal 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.
|
||||
169
.agents/skills/turborepo/references/caching/gotchas.md
Normal file
169
.agents/skills/turborepo/references/caching/gotchas.md
Normal 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
|
||||
127
.agents/skills/turborepo/references/caching/remote-cache.md
Normal file
127
.agents/skills/turborepo/references/caching/remote-cache.md
Normal 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
|
||||
162
.agents/skills/turborepo/references/ci/github-actions.md
Normal file
162
.agents/skills/turborepo/references/ci/github-actions.md
Normal 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
|
||||
```
|
||||
145
.agents/skills/turborepo/references/ci/patterns.md
Normal file
145
.agents/skills/turborepo/references/ci/patterns.md
Normal 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
|
||||
```
|
||||
103
.agents/skills/turborepo/references/ci/vercel.md
Normal file
103
.agents/skills/turborepo/references/ci/vercel.md
Normal 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
|
||||
```
|
||||
297
.agents/skills/turborepo/references/cli/commands.md
Normal file
297
.agents/skills/turborepo/references/cli/commands.md
Normal 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
|
||||
```
|
||||
|
|
@ -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.
|
||||
348
.agents/skills/turborepo/references/configuration/gotchas.md
Normal file
348
.agents/skills/turborepo/references/configuration/gotchas.md
Normal 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.
|
||||
285
.agents/skills/turborepo/references/configuration/tasks.md
Normal file
285
.agents/skills/turborepo/references/configuration/tasks.md
Normal 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 |
|
||||
145
.agents/skills/turborepo/references/environment/gotchas.md
Normal file
145
.agents/skills/turborepo/references/environment/gotchas.md
Normal 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
|
||||
101
.agents/skills/turborepo/references/environment/modes.md
Normal file
101
.agents/skills/turborepo/references/environment/modes.md
Normal 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'
|
||||
```
|
||||
152
.agents/skills/turborepo/references/filtering/patterns.md
Normal file
152
.agents/skills/turborepo/references/filtering/patterns.md
Normal 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...
|
||||
```
|
||||
111
.agents/skills/unit-tests/SKILL.md
Normal file
111
.agents/skills/unit-tests/SKILL.md
Normal 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 | Don’t 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; don’t over-deliver.
|
||||
5. How many cases? → **3–10 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
|
||||
129
.agents/skills/wiki-llms-txt/SKILL.md
Normal file
129
.agents/skills/wiki-llms-txt/SKILL.md
Normal 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
17
.claude/launch.json
Normal 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
1
.claude/skills/add-x-tweet
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/add-x-tweet
|
||||
1
.claude/skills/bklit-playground
Symbolic link
1
.claude/skills/bklit-playground
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/bklit-playground
|
||||
1
.claude/skills/bklit-ship
Symbolic link
1
.claude/skills/bklit-ship
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/bklit-ship
|
||||
1
.claude/skills/bklit-studio
Symbolic link
1
.claude/skills/bklit-studio
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/bklit-studio
|
||||
1
.claude/skills/bklit-studio-chart-performance
Symbolic link
1
.claude/skills/bklit-studio-chart-performance
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/bklit-studio-chart-performance
|
||||
1
.claude/skills/bklit-ui
Symbolic link
1
.claude/skills/bklit-ui
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/bklit-ui
|
||||
1
.claude/skills/pr-open
Symbolic link
1
.claude/skills/pr-open
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/pr-open
|
||||
1
.claude/skills/turborepo
Symbolic link
1
.claude/skills/turborepo
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/turborepo
|
||||
1
.claude/skills/unit-tests
Symbolic link
1
.claude/skills/unit-tests
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/unit-tests
|
||||
1
.claude/skills/wiki-llms-txt
Symbolic link
1
.claude/skills/wiki-llms-txt
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/wiki-llms-txt
|
||||
10
.env
Normal file
10
.env
Normal 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
23
.env.example
Normal 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
|
||||
22
.forgejo/workflows/deploy.yml
Normal file
22
.forgejo/workflows/deploy.yml
Normal 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
27
.gitignore
vendored
Normal 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?
|
||||
90
.interface-design/system.md
Normal file
90
.interface-design/system.md
Normal 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
86
README.md
Normal 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
BIN
bun.lockb
Executable file
Binary file not shown.
24
components.json
Normal file
24
components.json
Normal 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
348
docs/architecture.md
Normal 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.3–0.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** — осциллятор 0–100: >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
95
docs/token-strategy.md
Normal 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?" | M–L |
|
||||
| 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
22
eslint.config.js
Normal 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
43
index.html
Normal 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
52
package.json
Normal 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
3827
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
53
proxy/factsai-worker.js
Normal file
53
proxy/factsai-worker.js
Normal 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
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
15
public/favicon.svg
Normal file
15
public/favicon.svg
Normal 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
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
public/og.png
Normal file
BIN
public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
60
scripts/gen-icons.mjs
Normal file
60
scripts/gen-icons.mjs
Normal 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
65
skills-lock.json
Normal 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
20
src/App.tsx
Normal 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
|
||||
124
src/components/Onboarding.tsx
Normal file
124
src/components/Onboarding.tsx
Normal 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 it’s 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>
|
||||
)
|
||||
}
|
||||
32
src/components/brand/Logo.tsx
Normal file
32
src/components/brand/Logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
181
src/components/landing/AgentFlow.tsx
Normal file
181
src/components/landing/AgentFlow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/landing/GlobeCanvas.tsx
Normal file
175
src/components/landing/GlobeCanvas.tsx
Normal 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 />
|
||||
}
|
||||
146
src/components/landing/HeroCanvas.tsx
Normal file
146
src/components/landing/HeroCanvas.tsx
Normal 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 />
|
||||
}
|
||||
54
src/components/ui/accordion.tsx
Normal file
54
src/components/ui/accordion.tsx
Normal 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'
|
||||
38
src/components/ui/badge.tsx
Normal file
38
src/components/ui/badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/components/ui/button.tsx
Normal file
46
src/components/ui/button.tsx
Normal 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 }
|
||||
34
src/components/ui/dialog.tsx
Normal file
34
src/components/ui/dialog.tsx
Normal 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
|
||||
16
src/components/ui/skeleton.tsx
Normal file
16
src/components/ui/skeleton.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
46
src/components/ui/tabs.tsx
Normal file
46
src/components/ui/tabs.tsx
Normal 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'
|
||||
396
src/components/viz/AgentWorkflow.tsx
Normal file
396
src/components/viz/AgentWorkflow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/viz/ConfidenceRing.tsx
Normal file
57
src/components/viz/ConfidenceRing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/components/viz/LiveFeed.tsx
Normal file
177
src/components/viz/LiveFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
src/components/viz/Orchestra.tsx
Normal file
239
src/components/viz/Orchestra.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/components/viz/TVChart.tsx
Normal file
116
src/components/viz/TVChart.tsx
Normal 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
63
src/data/seedMarket.ts
Normal 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
135
src/hooks/useHyperliquid.ts
Normal 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
70
src/hooks/useInView.ts
Normal 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
|
||||
}
|
||||
29
src/hooks/useMarketBrief.ts
Normal file
29
src/hooks/useMarketBrief.ts
Normal 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
|
||||
}
|
||||
17
src/hooks/usePrefersReducedMotion.ts
Normal file
17
src/hooks/usePrefersReducedMotion.ts
Normal 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
|
||||
}
|
||||
25
src/hooks/useScrollProgress.ts
Normal file
25
src/hooks/useScrollProgress.ts
Normal 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
85
src/lib/factsai.ts
Normal 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
45
src/lib/format.ts
Normal 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
112
src/lib/hyperliquid.ts
Normal 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
26
src/lib/seed.ts
Normal 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
151
src/lib/signals.ts
Normal 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
132
src/lib/ta.ts
Normal 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
7
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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
210
src/pages/Landing.tsx
Normal 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
558
src/pages/Terminal.tsx
Normal 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 0–100; >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
273
src/styles/globals.css
Normal 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
Loading…
Add table
Add a link
Reference in a new issue