commit f3b533b032cb6fc91cb6ebed909398c051c252f9 Author: zefiroff Date: Tue Jun 9 19:00:13 2026 +0400 Quant Agent — multi-agent quant terminal on Hyperliquid 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) diff --git a/.agents/skills/add-x-tweet/SKILL.md b/.agents/skills/add-x-tweet/SKILL.md new file mode 100644 index 0000000..6e9a825 --- /dev/null +++ b/.agents/skills/add-x-tweet/SKILL.md @@ -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 `

` 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", +}, +``` diff --git a/.agents/skills/add-x-tweet/scripts/fetch-tweet.mjs b/.agents/skills/add-x-tweet/scripts/fetch-tweet.mjs new file mode 100644 index 0000000..4d18ad7 --- /dev/null +++ b/.agents/skills/add-x-tweet/scripts/fetch-tweet.mjs @@ -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...]"); + 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; + } +} diff --git a/.agents/skills/bklit-playground/SKILL.md b/.agents/skills/bklit-playground/SKILL.md new file mode 100644 index 0000000..f3acb52 --- /dev/null +++ b/.agents/skills/bklit-playground/SKILL.md @@ -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/`. diff --git a/.agents/skills/bklit-playground/templates/page.tsx b/.agents/skills/bklit-playground/templates/page.tsx new file mode 100644 index 0000000..0d3c715 --- /dev/null +++ b/.agents/skills/bklit-playground/templates/page.tsx @@ -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 {}; diff --git a/.agents/skills/bklit-ship/SKILL.md b/.agents/skills/bklit-ship/SKILL.md new file mode 100644 index 0000000..fdb26c8 --- /dev/null +++ b/.agents/skills/bklit-ship/SKILL.md @@ -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/.mdx` with frontmatter, ``, 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/.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` | diff --git a/.agents/skills/bklit-studio-chart-performance/SKILL.md b/.agents/skills/bklit-studio-chart-performance/SKILL.md new file mode 100644 index 0000000..95b7429 --- /dev/null +++ b/.agents/skills/bklit-studio-chart-performance/SKILL.md @@ -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=`: + +- [ ] 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` | diff --git a/.agents/skills/bklit-studio/SKILL.md b/.agents/skills/bklit-studio/SKILL.md new file mode 100644 index 0000000..1b118bd --- /dev/null +++ b/.agents/skills/bklit-studio/SKILL.md @@ -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=` — 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=` 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` | diff --git a/.agents/skills/bklit-ui/SKILL.md b/.agents/skills/bklit-ui/SKILL.md new file mode 100644 index 0000000..cd8ee94 --- /dev/null +++ b/.agents/skills/bklit-ui/SKILL.md @@ -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/` — 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/`. +5. **Browse variants.** Gallery: `https://ui.bklit.com/charts/` — Studio: `https://ui.bklit.com/studio?chart=`. + +## 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/`. +- **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/`. +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"; + + + + + + + +``` + +## 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) diff --git a/.agents/skills/bklit-ui/rules/animation.md b/.agents/skills/bklit-ui/rules/animation.md new file mode 100644 index 0000000..964488c --- /dev/null +++ b/.agents/skills/bklit-ui/rules/animation.md @@ -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); + + + {/* ... */} + + + +``` + +## 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/ +Tune motion interactively: https://ui.bklit.com/studio?chart= diff --git a/.agents/skills/bklit-ui/rules/composition.md b/.agents/skills/bklit-ui/rules/composition.md new file mode 100644 index 0000000..ccc9eaa --- /dev/null +++ b/.agents/skills/bklit-ui/rules/composition.md @@ -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 +

+ + +
+``` + +### Correct + +```tsx + + + + + + +``` + +## 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 diff --git a/.agents/skills/bklit-ui/rules/installation.md b/.agents/skills/bklit-ui/rules/installation.md new file mode 100644 index 0000000..b8afac0 --- /dev/null +++ b/.agents/skills/bklit-ui/rules/installation.md @@ -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/ diff --git a/.agents/skills/bklit-ui/rules/theming.md b/.agents/skills/bklit-ui/rules/theming.md new file mode 100644 index 0000000..6bbf8b5 --- /dev/null +++ b/.agents/skills/bklit-ui/rules/theming.md @@ -0,0 +1,55 @@ +# Theming + +## Use chartCssVars + +Prefer the typed `chartCssVars` export over raw CSS variable strings. + +### Incorrect + +```tsx + + +``` + +### Correct + +```tsx +import { chartCssVars } from "@bklitui/ui/charts"; + + + +``` + +## 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 diff --git a/.agents/skills/bklit-ui/rules/tooltips.md b/.agents/skills/bklit-ui/rules/tooltips.md new file mode 100644 index 0000000..3de29ca --- /dev/null +++ b/.agents/skills/bklit-ui/rules/tooltips.md @@ -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 + + + + + +``` + +## Custom content + +```tsx + ( +
+
{formattedDate}
+
{point.users as number} users
+
+ )} +/> +``` + +## indicatorColor (candlestick and crosshair) + +Match the crosshair to the hovered datum — e.g. green/red candles: + +```tsx + + (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 diff --git a/.agents/skills/pr-open/SKILL.md b/.agents/skills/pr-open/SKILL.md new file mode 100644 index 0000000..fa3c4ca --- /dev/null +++ b/.agents/skills/pr-open/SKILL.md @@ -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 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 # prefer explicit paths over blind git add -A +``` + +### Commit + +```bash +git commit -m "$(cat <<'EOF' + + + +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 ` +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 ` → `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 "" --body "$(cat <<'EOF' +## Summary + +- +- +- + +## 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) +- [ ] +- [ ] + +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) +- [ ] +- [ ] +``` + +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 diff --git a/.agents/skills/turborepo/SKILL.md b/.agents/skills/turborepo/SKILL.md new file mode 100644 index 0000000..85abe40 --- /dev/null +++ b/.agents/skills/turborepo/SKILL.md @@ -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 ` + +**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 ` 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 ` 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 diff --git a/.agents/skills/turborepo/command/turborepo.md b/.agents/skills/turborepo/command/turborepo.md new file mode 100644 index 0000000..615d4f1 --- /dev/null +++ b/.agents/skills/turborepo/command/turborepo.md @@ -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//`: + +| 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 ` - 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: +Files referenced: + + +``` + + +$ARGUMENTS + diff --git a/.agents/skills/turborepo/references/best-practices/dependencies.md b/.agents/skills/turborepo/references/best-practices/dependencies.md new file mode 100644 index 0000000..7a6fd2b --- /dev/null +++ b/.agents/skills/turborepo/references/best-practices/dependencies.md @@ -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 +``` diff --git a/.agents/skills/turborepo/references/best-practices/packages.md b/.agents/skills/turborepo/references/best-practices/packages.md new file mode 100644 index 0000000..29800a1 --- /dev/null +++ b/.agents/skills/turborepo/references/best-practices/packages.md @@ -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 ; +} +``` + +## 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. diff --git a/.agents/skills/turborepo/references/best-practices/structure.md b/.agents/skills/turborepo/references/best-practices/structure.md new file mode 100644 index 0000000..8e31de3 --- /dev/null +++ b/.agents/skills/turborepo/references/best-practices/structure.md @@ -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. diff --git a/.agents/skills/turborepo/references/caching/gotchas.md b/.agents/skills/turborepo/references/caching/gotchas.md new file mode 100644 index 0000000..17d4499 --- /dev/null +++ b/.agents/skills/turborepo/references/caching/gotchas.md @@ -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/.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/.json .turbo/runs/.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 diff --git a/.agents/skills/turborepo/references/caching/remote-cache.md b/.agents/skills/turborepo/references/caching/remote-cache.md new file mode 100644 index 0000000..da76458 --- /dev/null +++ b/.agents/skills/turborepo/references/caching/remote-cache.md @@ -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= +TURBO_TEAM= +``` + +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 diff --git a/.agents/skills/turborepo/references/ci/github-actions.md b/.agents/skills/turborepo/references/ci/github-actions.md new file mode 100644 index 0000000..7e5d4cc --- /dev/null +++ b/.agents/skills/turborepo/references/ci/github-actions.md @@ -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 +``` diff --git a/.agents/skills/turborepo/references/ci/patterns.md b/.agents/skills/turborepo/references/ci/patterns.md new file mode 100644 index 0000000..447509a --- /dev/null +++ b/.agents/skills/turborepo/references/ci/patterns.md @@ -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 +``` diff --git a/.agents/skills/turborepo/references/ci/vercel.md b/.agents/skills/turborepo/references/ci/vercel.md new file mode 100644 index 0000000..f21d41a --- /dev/null +++ b/.agents/skills/turborepo/references/ci/vercel.md @@ -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 +``` diff --git a/.agents/skills/turborepo/references/cli/commands.md b/.agents/skills/turborepo/references/cli/commands.md new file mode 100644 index 0000000..c1eb6b2 --- /dev/null +++ b/.agents/skills/turborepo/references/cli/commands.md @@ -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/.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 +``` diff --git a/.agents/skills/turborepo/references/configuration/global-options.md b/.agents/skills/turborepo/references/configuration/global-options.md new file mode 100644 index 0000000..8394c1a --- /dev/null +++ b/.agents/skills/turborepo/references/configuration/global-options.md @@ -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 (only logging errors)` +- Cache hit: `cache hit, replaying logs (no errors) ` + +## 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. diff --git a/.agents/skills/turborepo/references/configuration/gotchas.md b/.agents/skills/turborepo/references/configuration/gotchas.md new file mode 100644 index 0000000..225bd39 --- /dev/null +++ b/.agents/skills/turborepo/references/configuration/gotchas.md @@ -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 ` + +```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. diff --git a/.agents/skills/turborepo/references/configuration/tasks.md b/.agents/skills/turborepo/references/configuration/tasks.md new file mode 100644 index 0000000..0ccc7ac --- /dev/null +++ b/.agents/skills/turborepo/references/configuration/tasks.md @@ -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$/` | 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 | diff --git a/.agents/skills/turborepo/references/environment/gotchas.md b/.agents/skills/turborepo/references/environment/gotchas.md new file mode 100644 index 0000000..eff77a4 --- /dev/null +++ b/.agents/skills/turborepo/references/environment/gotchas.md @@ -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 diff --git a/.agents/skills/turborepo/references/environment/modes.md b/.agents/skills/turborepo/references/environment/modes.md new file mode 100644 index 0000000..eebbc2c --- /dev/null +++ b/.agents/skills/turborepo/references/environment/modes.md @@ -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' +``` diff --git a/.agents/skills/turborepo/references/filtering/patterns.md b/.agents/skills/turborepo/references/filtering/patterns.md new file mode 100644 index 0000000..17b9f1c --- /dev/null +++ b/.agents/skills/turborepo/references/filtering/patterns.md @@ -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... +``` diff --git a/.agents/skills/unit-tests/SKILL.md b/.agents/skills/unit-tests/SKILL.md new file mode 100644 index 0000000..6afd80d --- /dev/null +++ b/.agents/skills/unit-tests/SKILL.md @@ -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 diff --git a/.agents/skills/wiki-llms-txt/SKILL.md b/.agents/skills/wiki-llms-txt/SKILL.md new file mode 100644 index 0000000..a03652d --- /dev/null +++ b/.agents/skills/wiki-llms-txt/SKILL.md @@ -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} + + +{Full markdown content — frontmatter stripped, citations and diagrams preserved} + +``` + +### 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 `` 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 `` 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 \ No newline at end of file diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..0141646 --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.claude/skills/add-x-tweet b/.claude/skills/add-x-tweet new file mode 120000 index 0000000..09045bf --- /dev/null +++ b/.claude/skills/add-x-tweet @@ -0,0 +1 @@ +../../.agents/skills/add-x-tweet \ No newline at end of file diff --git a/.claude/skills/bklit-playground b/.claude/skills/bklit-playground new file mode 120000 index 0000000..775a288 --- /dev/null +++ b/.claude/skills/bklit-playground @@ -0,0 +1 @@ +../../.agents/skills/bklit-playground \ No newline at end of file diff --git a/.claude/skills/bklit-ship b/.claude/skills/bklit-ship new file mode 120000 index 0000000..b2c4b99 --- /dev/null +++ b/.claude/skills/bklit-ship @@ -0,0 +1 @@ +../../.agents/skills/bklit-ship \ No newline at end of file diff --git a/.claude/skills/bklit-studio b/.claude/skills/bklit-studio new file mode 120000 index 0000000..71511a7 --- /dev/null +++ b/.claude/skills/bklit-studio @@ -0,0 +1 @@ +../../.agents/skills/bklit-studio \ No newline at end of file diff --git a/.claude/skills/bklit-studio-chart-performance b/.claude/skills/bklit-studio-chart-performance new file mode 120000 index 0000000..f648c07 --- /dev/null +++ b/.claude/skills/bklit-studio-chart-performance @@ -0,0 +1 @@ +../../.agents/skills/bklit-studio-chart-performance \ No newline at end of file diff --git a/.claude/skills/bklit-ui b/.claude/skills/bklit-ui new file mode 120000 index 0000000..60b1881 --- /dev/null +++ b/.claude/skills/bklit-ui @@ -0,0 +1 @@ +../../.agents/skills/bklit-ui \ No newline at end of file diff --git a/.claude/skills/pr-open b/.claude/skills/pr-open new file mode 120000 index 0000000..d38a5ab --- /dev/null +++ b/.claude/skills/pr-open @@ -0,0 +1 @@ +../../.agents/skills/pr-open \ No newline at end of file diff --git a/.claude/skills/turborepo b/.claude/skills/turborepo new file mode 120000 index 0000000..b69a7d9 --- /dev/null +++ b/.claude/skills/turborepo @@ -0,0 +1 @@ +../../.agents/skills/turborepo \ No newline at end of file diff --git a/.claude/skills/unit-tests b/.claude/skills/unit-tests new file mode 120000 index 0000000..fd09d5b --- /dev/null +++ b/.claude/skills/unit-tests @@ -0,0 +1 @@ +../../.agents/skills/unit-tests \ No newline at end of file diff --git a/.claude/skills/wiki-llms-txt b/.claude/skills/wiki-llms-txt new file mode 120000 index 0000000..fb7a005 --- /dev/null +++ b/.claude/skills/wiki-llms-txt @@ -0,0 +1 @@ +../../.agents/skills/wiki-llms-txt \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..df4e0b1 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b15c7db --- /dev/null +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..e385e35 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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) }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa9271a --- /dev/null +++ b/.gitignore @@ -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? diff --git a/.interface-design/system.md b/.interface-design/system.md new file mode 100644 index 0000000..0396c74 --- /dev/null +++ b/.interface-design/system.md @@ -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 ``. Add more charts with +`npx shadcn@latest add @bklit/`. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea47308 --- /dev/null +++ b/README.md @@ -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://.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`. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..e47a757 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..55eff0b --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4a00c5b --- /dev/null +++ b/docs/architecture.md @@ -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). +``` diff --git a/docs/token-strategy.md b/docs/token-strategy.md new file mode 100644 index 0000000..4defa1c --- /dev/null +++ b/docs/token-strategy.md @@ -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.* diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..16b6cde --- /dev/null +++ b/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + Quant Agent — a multi-agent quant terminal on Hyperliquid + + + + + + + + + + + + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..a114ee6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..708f43e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3827 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@8.0.16) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lightweight-charts: + specifier: ^5.2.0 + version: 5.2.0 + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.7) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + react-router-dom: + specifier: ^7.17.0 + version: 7.17.0(react-dom@19.2.7)(react@19.2.7) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + +devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.1) + '@types/node': + specifier: ^24.12.3 + version: 24.13.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.16) + eslint: + specifier: ^10.3.0 + version: 10.4.1 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.1) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.1) + globals: + specifier: ^17.6.0 + version: 17.6.0 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + svgo: + specifier: ^4.0.1 + version: 4.0.1 + to-ico: + specifier: ^1.1.5 + version: 1.1.5 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.60.1(eslint@10.4.1)(typescript@6.0.3) + unplugin-fonts: + specifier: ^2.0.0 + version: 2.0.0(vite@8.0.16) + vite: + specifier: ^8.0.12 + version: 8.0.16(@types/node@24.13.0)(tsx@4.22.4) + vite-plugin-image-optimizer: + specifier: ^2.0.3 + version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.16) + +packages: + + /@babel/code-frame@7.29.7: + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + dev: true + + /@babel/compat-data@7.29.7: + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.29.7: + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.29.7: + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + dev: true + + /@babel/helper-compilation-targets@7.29.7: + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-globals@7.29.7: + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-module-imports@7.29.7: + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7): + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-string-parser@7.29.7: + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.29.7: + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-option@7.29.7: + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.29.7: + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + dev: true + + /@babel/parser@7.29.7: + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.29.7 + dev: true + + /@babel/template@7.29.7: + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + dev: true + + /@babel/traverse@7.29.7: + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/types@7.29.7: + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + dev: true + + /@capsizecss/unpack@4.0.0: + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + dependencies: + fontkitten: 1.0.3 + dev: true + + /@emnapi/core@1.10.0: + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + requiresBuild: true + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + /@emnapi/runtime@1.10.0: + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + optional: true + + /@emnapi/wasi-threads@1.2.1: + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + optional: true + + /@esbuild/aix-ppc64@0.28.0: + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + optional: true + + /@esbuild/android-arm64@0.28.0: + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-arm@0.28.0: + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-x64@0.28.0: + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/darwin-arm64@0.28.0: + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/darwin-x64@0.28.0: + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/freebsd-arm64@0.28.0: + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/freebsd-x64@0.28.0: + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/linux-arm64@0.28.0: + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-arm@0.28.0: + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ia32@0.28.0: + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-loong64@0.28.0: + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-mips64el@0.28.0: + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ppc64@0.28.0: + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-riscv64@0.28.0: + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-s390x@0.28.0: + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-x64@0.28.0: + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/netbsd-arm64@0.28.0: + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + optional: true + + /@esbuild/netbsd-x64@0.28.0: + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + + /@esbuild/openbsd-arm64@0.28.0: + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + optional: true + + /@esbuild/openbsd-x64@0.28.0: + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + + /@esbuild/openharmony-arm64@0.28.0: + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + optional: true + + /@esbuild/sunos-x64@0.28.0: + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + + /@esbuild/win32-arm64@0.28.0: + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-ia32@0.28.0: + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-x64@0.28.0: + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@eslint-community/eslint-utils@4.9.1(eslint@10.4.1): + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 10.4.1 + eslint-visitor-keys: 3.4.3 + dev: true + + /@eslint-community/regexpp@4.12.2: + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/config-array@0.23.5: + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/config-helpers@0.6.0: + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/core': 1.2.1 + dev: true + + /@eslint/core@1.2.1: + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@types/json-schema': 7.0.15 + dev: true + + /@eslint/js@10.0.1(eslint@10.4.1): + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + dependencies: + eslint: 10.4.1 + dev: true + + /@eslint/object-schema@3.0.5: + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dev: true + + /@eslint/plugin-kit@0.7.2: + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + dev: true + + /@humanfs/core@0.19.2: + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + dependencies: + '@humanfs/types': 0.15.0 + dev: true + + /@humanfs/node@0.16.8: + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + dev: true + + /@humanfs/types@0.15.0: + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/retry@0.4.3: + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + dev: true + + /@img/colour@1.1.0: + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + dev: true + + /@img/sharp-darwin-arm64@0.34.5: + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-darwin-x64@0.34.5: + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-libvips-darwin-arm64@1.2.4: + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-darwin-x64@1.2.4: + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm64@1.2.4: + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm@1.2.4: + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-ppc64@1.2.4: + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-riscv64@1.2.4: + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-s390x@1.2.4: + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-x64@1.2.4: + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-arm64@1.2.4: + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-x64@1.2.4: + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-linux-arm64@0.34.5: + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-arm@0.34.5: + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-ppc64@0.34.5: + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-riscv64@0.34.5: + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-s390x@0.34.5: + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + dev: true + optional: true + + /@img/sharp-linux-x64@0.34.5: + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-arm64@0.34.5: + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-x64@0.34.5: + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + dev: true + optional: true + + /@img/sharp-wasm32@0.34.5: + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/runtime': 1.10.0 + dev: true + optional: true + + /@img/sharp-win32-arm64@0.34.5: + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-win32-ia32@0.34.5: + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-win32-x64@0.34.5: + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@jridgewell/gen-mapping@0.3.13: + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + /@jridgewell/remapping@2.3.5: + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/sourcemap-codec@1.5.5: + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + /@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + requiresBuild: true + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + dev: true + + /@oxc-project/types@0.133.0: + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + /@radix-ui/primitive@1.1.3: + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + dev: false + + /@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) + dev: false + + /@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.16)(react-dom@19.2.7)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + dev: false + + /@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.7) + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + dev: false + + /@rolldown/binding-android-arm64@1.0.3: + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /@rolldown/binding-darwin-arm64@1.0.3: + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@rolldown/binding-darwin-x64@1.0.3: + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@rolldown/binding-freebsd-x64@1.0.3: + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-arm-gnueabihf@1.0.3: + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-arm64-gnu@1.0.3: + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-arm64-musl@1.0.3: + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-ppc64-gnu@1.0.3: + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-s390x-gnu@1.0.3: + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-x64-gnu@1.0.3: + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-linux-x64-musl@1.0.3: + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@rolldown/binding-openharmony-arm64@1.0.3: + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + optional: true + + /@rolldown/binding-wasm32-wasi@1.0.3: + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + /@rolldown/binding-win32-arm64-msvc@1.0.3: + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@rolldown/binding-win32-x64-msvc@1.0.3: + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@rolldown/pluginutils@1.0.1: + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + /@tailwindcss/node@4.3.0: + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + dev: false + + /@tailwindcss/oxide-android-arm64@4.3.0: + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-darwin-arm64@4.3.0: + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-darwin-x64@4.3.0: + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-freebsd-x64@4.3.0: + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0: + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-linux-arm64-gnu@4.3.0: + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-linux-arm64-musl@4.3.0: + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-linux-x64-gnu@4.3.0: + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-linux-x64-musl@4.3.0: + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-wasm32-wasi@4.3.0: + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + requiresBuild: true + dev: false + optional: true + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + /@tailwindcss/oxide-win32-arm64-msvc@4.3.0: + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide-win32-x64-msvc@4.3.0: + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@tailwindcss/oxide@4.3.0: + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + dev: false + + /@tailwindcss/vite@4.3.0(vite@8.0.16): + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.16(@types/node@24.13.0)(tsx@4.22.4) + dev: false + + /@tybys/wasm-util@0.10.2: + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + optional: true + + /@types/esrecurse@4.3.1: + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + dev: true + + /@types/estree@1.0.9: + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/node@24.13.0: + resolution: {integrity: sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg==} + dependencies: + undici-types: 7.18.2 + + /@types/react-dom@19.2.3(@types/react@19.2.16): + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + dependencies: + '@types/react': 19.2.16 + + /@types/react@19.2.16: + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + dependencies: + csstype: 3.2.3 + + /@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1)(eslint@10.4.1)(typescript@6.0.3): + resolution: {integrity: sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.60.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/type-utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + eslint: 10.4.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@8.60.1(eslint@10.4.1)(typescript@6.0.3): + resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/project-service@8.60.1(typescript@6.0.3): + resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@8.60.1: + resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + dev: true + + /@typescript-eslint/tsconfig-utils@8.60.1(typescript@6.0.3): + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + dependencies: + typescript: 6.0.3 + dev: true + + /@typescript-eslint/type-utils@8.60.1(eslint@10.4.1)(typescript@6.0.3): + resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@8.60.1: + resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dev: true + + /@typescript-eslint/typescript-estree@8.60.1(typescript@6.0.3): + resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@typescript-eslint/project-service': 8.60.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@6.0.3) + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/visitor-keys': 8.60.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.2 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@8.60.1(eslint@10.4.1)(typescript@6.0.3): + resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@typescript-eslint/scope-manager': 8.60.1 + '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/visitor-keys@8.60.1: + resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + dependencies: + '@typescript-eslint/types': 8.60.1 + eslint-visitor-keys: 5.0.1 + dev: true + + /@vitejs/plugin-react@6.0.2(vite@8.0.16): + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@24.13.0)(tsx@4.22.4) + dev: true + + /acorn-jsx@5.3.2(acorn@8.16.0): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.16.0 + dev: true + + /acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: true + + /aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + dependencies: + tslib: 2.8.1 + dev: false + + /arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + dev: true + + /asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + dev: true + + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: true + + /aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + dev: true + + /aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + dev: true + + /balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + dev: true + + /baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + dev: true + + /bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + dependencies: + tweetnacl: 0.14.5 + dev: true + + /bignumber.js@2.4.0: + resolution: {integrity: sha512-uw4ra6Cv483Op/ebM0GBKKfxZlSmn6NgFRby5L3yGTlunLj53KQgndDlqy2WVFOwgvurocApYkSud0aO+mvrpQ==} + dev: true + + /bmp-js@0.0.1: + resolution: {integrity: sha512-OS74Rlt0Aynu2mTPmY9RZOUOXlqWecFIILFXr70vv16/xCZnFxvri9IKkF1IGxQ8r9dOE62qGNpKxXx8Lko8bg==} + dev: true + + /bmp-js@0.0.3: + resolution: {integrity: sha512-epsm3Z92j5xwek9p97pVw3KbsNc0F4QnbYh+N93SpbJYuHFQQ/UAh6K+bKFGyLePH3Hudtl/Sa95Quqp0gX8IQ==} + dev: true + + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: true + + /brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + dependencies: + balanced-match: 4.0.4 + dev: true + + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.1.1 + dev: true + + /browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.367 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + dev: true + + /buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + dev: true + + /buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + dev: true + + /buffer-equal@0.0.1: + resolution: {integrity: sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==} + engines: {node: '>=0.4.0'} + dev: true + + /buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + dev: true + + /caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + dev: true + + /caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + dev: true + + /centra@2.7.0: + resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==} + dependencies: + follow-redirects: 1.16.0 + transitivePeerDependencies: + - debug + dev: true + + /class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + dependencies: + clsx: 2.1.1 + dev: false + + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: true + + /commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + dev: true + + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: true + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + + /cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + dev: false + + /core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + dev: true + + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + dev: true + + /css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + dev: true + + /css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + dev: true + + /css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + dev: true + + /csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + dependencies: + css-tree: 2.2.1 + dev: true + + /csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + /dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dependencies: + assert-plus: 1.0.0 + dev: true + + /debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: true + + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + /detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev: false + + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: true + + /dom-walk@0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + dev: true + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: true + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: true + + /ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + dev: true + + /electron-to-chromium@1.5.367: + resolution: {integrity: sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==} + dev: true + + /enhanced-resolve@5.22.2: + resolution: {integrity: sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + dev: false + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + dev: true + + /esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: true + + /eslint-plugin-react-hooks@7.1.1(eslint@10.4.1): + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.4.1 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-plugin-react-refresh@0.5.2(eslint@10.4.1): + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + dependencies: + eslint: 10.4.1 + dev: true + + /eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dev: true + + /eslint@10.4.1: + resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + dev: true + + /esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.9 + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + dev: true + + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: true + + /extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + dev: true + + /fancy-canvas@2.1.0: + resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} + dev: false + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + dependencies: + reusify: 1.1.0 + dev: true + + /fdir@6.5.0(picomatch@4.0.4): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.4 + + /file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + dependencies: + flat-cache: 4.0.1 + dev: true + + /file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + dev: true + + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + dev: true + + /flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + dev: true + + /follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: true + + /fontaine@0.8.0: + resolution: {integrity: sha512-eek1GbzOdWIj9FyQH/emqW1aEdfC3lYRCHepzwlFCm5T77fBSRSyNRKE6/antF1/B1M+SfJXVRQTY9GAr7lnDg==} + engines: {node: '>=18.12.0'} + dependencies: + '@capsizecss/unpack': 4.0.0 + css-tree: 3.2.1 + magic-regexp: 0.10.0 + magic-string: 0.30.21 + pathe: 2.0.3 + ufo: 1.6.4 + unplugin: 2.3.11 + dev: true + + /fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + dependencies: + tiny-inflate: 1.0.3 + dev: true + + /forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + dev: true + + /form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + optional: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + dev: false + + /get-stream@2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + dev: true + + /getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + dependencies: + assert-plus: 1.0.0 + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /global@4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} + dependencies: + min-document: 2.19.2 + process: 0.11.10 + dev: true + + /globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: false + + /har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + dev: true + + /har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + dependencies: + ajv: 6.15.0 + har-schema: 2.0.0 + dev: true + + /hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + dev: true + + /hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + dependencies: + hermes-estree: 0.25.1 + dev: true + + /http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + dev: true + + /ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + dev: true + + /ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + dev: true + + /image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /ip-regex@1.0.3: + resolution: {integrity: sha512-HjpCHTuxbR/6jWJroc/VN+npo5j0T4Vv2TAI5qdEHQx7hsL767MeccGFSsLtF694EiZKTSEqgoeU6DtGFCcuqQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-function@1.0.2: + resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + dev: true + + /jimp@0.2.28: + resolution: {integrity: sha512-9HT7DA279xkTlry2oG30s6AtOUglNiY2UdyYpj0yNI4/NBv8PmdNC0gcldgMU4HqvbUlrM3+v+6GaHnTkH23JQ==} + dependencies: + bignumber.js: 2.4.0 + bmp-js: 0.0.3 + es6-promise: 3.3.1 + exif-parser: 0.1.12 + file-type: 3.9.0 + jpeg-js: 0.2.0 + load-bmfont: 1.4.2 + mime: 1.6.0 + mkdirp: 0.5.1 + pixelmatch: 4.0.2 + pngjs: 3.4.0 + read-chunk: 1.0.1 + request: 2.88.2 + stream-to-buffer: 0.1.0 + tinycolor2: 1.6.0 + url-regex: 3.2.0 + transitivePeerDependencies: + - debug + dev: true + + /jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + dev: false + + /jpeg-js@0.1.2: + resolution: {integrity: sha512-CiRVjMKBUp6VYtGwzRjrdnro41yMwKGOWdP9ATXqmixdz2n7KHNwdqthTYSSbOKj/Ha79Gct1sA8ZQpse55TYA==} + dev: true + + /jpeg-js@0.2.0: + resolution: {integrity: sha512-Ni9PffhJtYtdD7VwxH6V2MnievekGfUefosGCHadog0/jAevRu6HPjYeMHbUemn0IPE8d4wGa8UsOGsX+iKy2g==} + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true + + /jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + dev: true + + /jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + dev: true + + /keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + /lightweight-charts@5.2.0: + resolution: {integrity: sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==} + dependencies: + fancy-canvas: 2.1.0 + dev: false + + /load-bmfont@1.4.2: + resolution: {integrity: sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog==} + dependencies: + buffer-equal: 0.0.1 + mime: 1.6.0 + parse-bmfont-ascii: 1.0.6 + parse-bmfont-binary: 1.0.6 + parse-bmfont-xml: 1.1.6 + phin: 3.7.1 + xhr: 2.6.0 + xtend: 4.0.2 + transitivePeerDependencies: + - debug + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + + /lucide-react@1.17.0(react@19.2.7): + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + react: 19.2.7 + dev: false + + /magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.2 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.4 + unplugin: 2.3.11 + dev: true + + /magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + /mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + dev: true + + /mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + dev: true + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + dev: true + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /min-document@2.19.2: + resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + dependencies: + dom-walk: 0.1.2 + dev: true + + /minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + dependencies: + brace-expansion: 5.0.6 + dev: true + + /minimist@0.0.8: + resolution: {integrity: sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==} + dev: true + + /mkdirp@0.5.1: + resolution: {integrity: sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==} + deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) + hasBin: true + dependencies: + minimist: 0.0.8 + dev: true + + /mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + dev: true + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: true + + /nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + dev: true + + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: true + + /oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + dev: true + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: true + + /optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /parse-bmfont-ascii@1.0.6: + resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==} + dev: true + + /parse-bmfont-binary@1.0.6: + resolution: {integrity: sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==} + dev: true + + /parse-bmfont-xml@1.1.6: + resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + dependencies: + xml-parse-from-string: 1.0.1 + xml2js: 0.5.0 + dev: true + + /parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + dev: true + + /parse-png@1.1.2: + resolution: {integrity: sha512-Ge6gDV9T5zhkWHmjvnNiyhPTCIoY7W+FC7qWPtuL2lIGZAFxxqTRG/ouEXsH9qkw+HzYiPEU/tFcxOCEDTP1Yw==} + engines: {node: '>=4'} + dependencies: + pngjs: 3.4.0 + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: true + + /phin@3.7.1: + resolution: {integrity: sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==} + engines: {node: '>= 8'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + dependencies: + centra: 2.7.0 + transitivePeerDependencies: + - debug + dev: true + + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + /picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + dev: true + + /picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + /pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + dependencies: + pinkie: 2.0.4 + dev: true + + /pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + dev: true + + /pixelmatch@4.0.2: + resolution: {integrity: sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==} + hasBin: true + dependencies: + pngjs: 3.4.0 + dev: true + + /pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + dev: true + + /pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + dev: true + + /postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + + /psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + dependencies: + punycode: 2.3.1 + dev: true + + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + dev: true + + /qs@6.5.5: + resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} + engines: {node: '>=0.6'} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /react-dom@19.2.7(react@19.2.7): + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + dev: false + + /react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + dev: false + + /react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) + dev: false + + /react-router-dom@7.17.0(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.17.0(react-dom@19.2.7)(react@19.2.7) + dev: false + + /react-router@7.17.0(react-dom@19.2.7)(react@19.2.7): + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + dependencies: + cookie: 1.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + set-cookie-parser: 2.7.2 + dev: false + + /react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + dev: false + + /react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + dev: false + + /read-chunk@1.0.1: + resolution: {integrity: sha512-5NLTTdX45dKFtG8CX5pKmvS9V5u9wBE+gkklN7xhDuhq3pA2I4O7ALfKxosCMcLHOhkxj6GNacZhfXtp5nlCdg==} + engines: {node: '>=0.10.0'} + dev: true + + /regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + dev: true + + /request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.5 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + dev: true + + /resize-img@1.1.2: + resolution: {integrity: sha512-/4nKUmuNPuM6gYTWad136ica81baOVjpesgv8FGaIvP0KWcbCWahOWBKaM4tFoM+aVcSA+qQDg28pcnIzFRpJw==} + engines: {node: '>=4'} + dependencies: + bmp-js: 0.0.1 + file-type: 3.9.0 + get-stream: 2.3.1 + jimp: 0.2.28 + jpeg-js: 0.1.2 + parse-png: 1.1.2 + transitivePeerDependencies: + - debug + dev: true + + /reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + dev: true + + /scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + dev: false + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + dev: true + + /semver@7.8.2: + resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + dev: false + + /sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + requiresBuild: true + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.2 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + /sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + dev: true + + /stream-to-buffer@0.1.0: + resolution: {integrity: sha512-Da4WoKaZyu3nf+bIdIifh7IPkFjARBnBK+pYqn0EUJqksjV9afojjaCCHUemH30Jmu7T2qcKvlZm2ykN38uzaw==} + engines: {node: '>= 0.8'} + dependencies: + stream-to: 0.2.2 + dev: true + + /stream-to@0.2.2: + resolution: {integrity: sha512-Kg1BSDTwgGiVMtTCJNlo7kk/xzL33ZuZveEBRt6rXw+f1WLK/8kmz2NVCT/Qnv0JkV85JOHcLhD82mnXsR3kPw==} + engines: {node: '>= 0.10.0'} + dev: true + + /svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + dev: true + + /tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + dev: false + + /tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + dev: false + + /tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + dev: false + + /tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + dev: true + + /tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + dev: true + + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + /to-ico@1.1.5: + resolution: {integrity: sha512-5kIh7m7bkIlqIESEZkL8gAMMzucXKfPe3hX2FoDY5HEAfD9OJU+Qh9b6Enp74w0qRcxVT5ejss66PHKqc3AVkg==} + engines: {node: '>=4'} + dependencies: + arrify: 1.0.1 + buffer-alloc: 1.2.0 + image-size: 0.5.5 + parse-png: 1.1.2 + resize-img: 1.1.2 + transitivePeerDependencies: + - debug + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + dev: true + + /ts-api-utils@2.5.0(typescript@6.0.3): + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + dependencies: + typescript: 6.0.3 + dev: true + + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + requiresBuild: true + + /tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + dev: true + + /tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + dev: true + + /typescript-eslint@8.60.1(eslint@10.4.1)(typescript@6.0.3): + resolution: {integrity: sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + dependencies: + '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1)(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.60.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.60.1(eslint@10.4.1)(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + dev: true + + /typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + dev: true + + /undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + /unplugin-fonts@2.0.0(vite@8.0.16): + resolution: {integrity: sha512-MjyPgq9UVXYSQlcqG5Y/tYFCJvK0unURtT8+PdhrYV7u/8uwycqqLj5ouwpZ+oNQXRvemYqgnSLxGUv00r984g==} + peerDependencies: + '@nuxt/kit': ^3.0.0 || ^4.0.0 + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + dependencies: + css-tree: 3.2.1 + fast-glob: 3.3.3 + fontaine: 0.8.0 + magic-string: 0.30.21 + pathe: 2.0.3 + unplugin: 3.0.0 + vite: 8.0.16(@types/node@24.13.0)(tsx@4.22.4) + dev: true + + /unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + dev: true + + /unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + dev: true + + /update-browserslist-db@1.2.3(browserslist@4.28.2): + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.1 + dev: true + + /url-regex@3.2.0: + resolution: {integrity: sha512-dQ9cJzMou5OKr6ZzfvwJkCq3rC72PNXhqz0v3EIhF4a3Np+ujr100AhUx2cKx5ei3iymoJpJrPB3sVSEMdqAeg==} + engines: {node: '>=0.10.0'} + dependencies: + ip-regex: 1.0.3 + dev: true + + /use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + react: 19.2.7 + tslib: 2.8.1 + dev: false + + /use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.16 + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + dev: false + + /uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + dev: true + + /verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + dev: true + + /vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.16): + resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==} + engines: {node: '>=18.17.0'} + peerDependencies: + sharp: '>=0.34.0' + svgo: '>=4' + vite: '>=5' + peerDependenciesMeta: + sharp: + optional: true + svgo: + optional: true + dependencies: + ansi-colors: 4.1.3 + pathe: 2.0.3 + sharp: 0.34.5 + svgo: 4.0.1 + vite: 8.0.16(@types/node@24.13.0)(tsx@4.22.4) + dev: true + + /vite@8.0.16(@types/node@24.13.0)(tsx@4.22.4): + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + dependencies: + '@types/node': 24.13.0 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + tsx: 4.22.4 + optionalDependencies: + fsevents: 2.3.3 + + /webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /xhr@2.6.0: + resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==} + dependencies: + global: 4.4.0 + is-function: 1.0.2 + parse-headers: 2.0.6 + xtend: 4.0.2 + dev: true + + /xml-parse-from-string@1.0.1: + resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} + dev: true + + /xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + dev: true + + /xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + dev: true + + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /zod-validation-error@4.0.2(zod@4.4.3): + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + dependencies: + zod: 4.4.3 + dev: true + + /zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + dev: true diff --git a/proxy/factsai-worker.js b/proxy/factsai-worker.js new file mode 100644 index 0000000..dd33249 --- /dev/null +++ b/proxy/factsai-worker.js @@ -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..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' }, + }) + } + }, +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..a5b5242 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..8d15491 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..28eb37a --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..76f1ce6 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000..6b738da Binary files /dev/null and b/public/og.png differ diff --git a/scripts/gen-icons.mjs b/scripts/gen-icons.mjs new file mode 100644 index 0000000..6c4ced6 --- /dev/null +++ b/scripts/gen-icons.mjs @@ -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) => + ` + + + + + + + + + + + ` +const og = ` + + + ${Array.from({ length: 12 }, (_, i) => ``).join('')} + ${Array.from({ length: 7 }, (_, i) => ``).join('')} + + ${globe(955, 315, 210, 5)} + + + + + + ${globe(0, 0, 19, 2.6)} + Quant Agent + Four agents argue. + One verdict. + A multi-agent quant terminal for Hyperliquid perps — live data, TA, LONG / SHORT / SKIP. + + + LIVE · HYPERLIQUID + PAPER · DRY-RUN + +` +await sharp(Buffer.from(og)).png().toFile(join(pub, 'og.png')) + +console.log('✓ icons + og generated') diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..6cc1802 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..9878102 --- /dev/null +++ b/src/App.tsx @@ -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 ( + + }> + + } /> + } /> + + + + ) +} + +export default App diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx new file mode 100644 index 0000000..1cf6eb5 --- /dev/null +++ b/src/components/Onboarding.tsx @@ -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 ( +
+
+
+
+
+ + + Quant Agent + + + {step + 1} / {STEPS.length} + +
+ +
+

{s.title}

+

+ {s.body} +

+
+ +
+
+ {STEPS.map((_, i) => ( +
+
+ {!last && ( + + )} + +
+
+
+
+ ) +} diff --git a/src/components/brand/Logo.tsx b/src/components/brand/Logo.tsx new file mode 100644 index 0000000..906b885 --- /dev/null +++ b/src/components/brand/Logo.tsx @@ -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 ( + + + + + + + + + + + + + + + ) +} + +export function Wordmark({ className }: { className?: string }) { + return ( + + + + Quant Agent + + + ) +} diff --git a/src/components/landing/AgentFlow.tsx b/src/components/landing/AgentFlow.tsx new file mode 100644 index 0000000..241625f --- /dev/null +++ b/src/components/landing/AgentFlow.tsx @@ -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 ( +
+ {/* numbered step rail */} +
+ {STEPS.map((s, i) => { + const on = i === step + const done = i < step + return ( +
+ + {i < STEPS.length - 1 && } +
+ ) + })} +
+ + {/* plain-language narration for the active step */} +
+ Step {STEPS[step].n}/5 +

+ {STEPS[step].say} +

+
+ + {/* visual: committee → verdict (syncs to the steps) */} +
+ {/* committee */} +
+
Agent committee · 4 strategies
+
+ {AGENTS.map((a) => { + const signal = a.dir !== 'SKIP' + return ( +
+
+
{a.label}
+
{a.reads}
+
+
+
+
+
+ {!signal && ( +
+ )} +
+ + {signal ? a.dir : 'skip'} + +
+
+ ) + })} +
+
+ + {/* verdict */} +
+
+
Verdict
+
+
+
+ {verdictOn ? 'LONG' : '—'} +
+
+ 2 of 4 agents align · the rest skip +
+
+ +
+
+ {[ + ['Size', '2.6%', 'var(--color-bone)'], + ['TP', '+1.7%', 'var(--color-long)'], + ['SL', '−0.9%', 'var(--color-short)'], + ].map(([l, v, c]) => ( +
+
{l}
+
{v}
+
+ ))} +
+
+ 🟢 + LONG BTC-PERP @ $63,540 · TP +1.7% · SL −0.9% · 81% + → telegram +
+
+
+
+ ) +} diff --git a/src/components/landing/GlobeCanvas.tsx b/src/components/landing/GlobeCanvas.tsx new file mode 100644 index 0000000..c629476 --- /dev/null +++ b/src/components/landing/GlobeCanvas.tsx @@ -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(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 +} diff --git a/src/components/landing/HeroCanvas.tsx b/src/components/landing/HeroCanvas.tsx new file mode 100644 index 0000000..54e31e1 --- /dev/null +++ b/src/components/landing/HeroCanvas.tsx @@ -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(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 +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..d819a5d --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = 'AccordionItem' + +export const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + +)) +AccordionTrigger.displayName = 'AccordionTrigger' + +export const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
+ {children} +
+
+)) +AccordionContent.displayName = 'AccordionContent' diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..04668a0 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps { + dot?: boolean +} + +export function Badge({ className, tone, dot, children, ...props }: BadgeProps) { + return ( + + {dot && ( + + + + + )} + {children} + + ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..648fc54 --- /dev/null +++ b/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, +) +Button.displayName = 'Button' + +export { buttonVariants } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9e11314 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + + + +)) +DialogContent.displayName = 'DialogContent' + +export const DialogTitle = DialogPrimitive.Title +export const DialogDescription = DialogPrimitive.Description diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..b995c47 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/lib/utils' + +/** Shimmer placeholder for live cards while data loads. */ +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..c394a93 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = 'TabsList' + +export const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = 'TabsTrigger' + +export const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = 'TabsContent' diff --git a/src/components/viz/AgentWorkflow.tsx b/src/components/viz/AgentWorkflow.tsx new file mode 100644 index 0000000..cf34482 --- /dev/null +++ b/src/components/viz/AgentWorkflow.tsx @@ -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 = { + 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 + +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 = { 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(null) + const [selKey, setSelKey] = useState(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 ( +
+
+
+ Agent flow + click a node · hover to trace the path +
+ + + {live ? 'flowing' : updatedAt > 0 ? 'paused' : 'connecting'} + +
+ +
+
+ + {/* edges */} + + {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 ( + + + {/* arrowhead at the target end (edges arrive ~horizontally) */} + + + ) + })} + + + {/* animated data packets (SMIL — scales with viewBox; off under reduced-motion) */} + {!reduced && !loading && ( + + {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) => ( + + + + + )) + })} + + )} + + {/* 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 ( + 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' }} + > + + {/* top row: status dot + layer code (left) · tag (right) */} + + + {n.code} + + {isCall ? ( + <> + + {data?.verdict ? `${(data.verdict.confidence * 100).toFixed(0)}%` : 'VERDICT'} + + + {vis.tag} + + + ) : ( + <> + + {vis.tag} + + + {n.label} + + + )} + + ) + })} + +
+
+ + {/* detail strip — updates on every node click */} +
+ {detail ? ( +
+
+ {detail.code} + {detail.title} + + {detail.tag} + + +
+

{detail.role}

+

↳ {detail.output}

+
+ ) : ( +

+ Click any node to inspect that agent — its role and live output. Hover to trace the data path. +

+ )} +
+
+ ) +} diff --git a/src/components/viz/ConfidenceRing.tsx b/src/components/viz/ConfidenceRing.tsx new file mode 100644 index 0000000..85dac5f --- /dev/null +++ b/src/components/viz/ConfidenceRing.tsx @@ -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 ( + + + + + {Math.round(v * 100)} + + + {label} + + + ) +} diff --git a/src/components/viz/LiveFeed.tsx b/src/components/viz/LiveFeed.tsx new file mode 100644 index 0000000..7883777 --- /dev/null +++ b/src/components/viz/LiveFeed.tsx @@ -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([]) + 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 ( +
+
+
+ Live feed + data → analysis → signal → decision → execution → comms +
+ + + {live ? 'streaming' : updatedAt > 0 ? 'paused' : 'connecting'} + +
+ +
+ {loading || rows.length === 0 ? ( +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : ( + rows.map((r) => { + const dot = toneDot(r) + return ( +
onInspect?.(`${r.code} · ${r.agent} — ${r.msg}`)} + onMouseLeave={() => onInspect?.(null)} + > + {hhmmss(r.ts)} + {r.code} + + {r.agent} + {r.msg} + {r.coin} + {r.tag} +
+ ) + }) + )} +
+
+ ) +} diff --git a/src/components/viz/Orchestra.tsx b/src/components/viz/Orchestra.tsx new file mode 100644 index 0000000..bcb6c67 --- /dev/null +++ b/src/components/viz/Orchestra.tsx @@ -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 = { + 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 = { 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 ( + + + + {name} + + + {tag} + + + ) +} + +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 ( +
+
+ Orchestra + + data → analysis → signal → decision → execution → comms + +
+ +
+ {STATIC.map((layer) => ( +
+
+ {layer.code} + {layer.name} +
+ + {layer.code === 'L3' ? ( + <> + {loading + ? [0, 1, 2, 3].map((i) =>
) + : 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 ( + onSelect?.(on ? null : v.agent)} + onEnter={() => { + onHover?.(v.agent) + onInspect?.(meta) + }} + onLeave={() => { + onHover?.(null) + onInspect?.(null) + }} + /> + ) + })} + onInspect?.(RISK.desc)} onLeave={() => onInspect?.(null)} /> + + ) : ( + layer.nodes.map((n) => { + const isVerdict = layer.code === 'L4' && n.label === 'Screener' + return ( + onInspect?.(n.desc)} + onLeave={() => onInspect?.(null)} + /> + ) + }) + )} +
+ ))} +
+ + {/* legend (status is never colour-only) */} +
+ live + computed + the call + paper / planned +
+
+ ) +} diff --git a/src/components/viz/TVChart.tsx b/src/components/viz/TVChart.tsx new file mode 100644 index 0000000..dec5daf --- /dev/null +++ b/src/components/viz/TVChart.tsx @@ -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(null) + const chartRef = useRef(null) + const seriesRef = useRef | 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
+} + +// 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 ( +
+
+ {SKELETON_BARS.map((h, i) => ( +
+ ))} +
+
+ + + Connecting · Hyperliquid + +
+
+ ) +} + diff --git a/src/data/seedMarket.ts b/src/data/seedMarket.ts new file mode 100644 index 0000000..698a88b --- /dev/null +++ b/src/data/seedMarket.ts @@ -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 = { + 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) diff --git a/src/hooks/useHyperliquid.ts b/src/hooks/useHyperliquid.ts new file mode 100644 index 0000000..55fc4cc --- /dev/null +++ b/src/hooks/useHyperliquid.ts @@ -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 = { + 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 { + const key = coins.join(',') + const [state, setState] = useState>(() => ({ + data: coins.map(seedStat), + loading: false, + live: false, + error: null, + updatedAt: 0, + })) + + useEffect(() => { + let alive = true + let timer: ReturnType + 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 & { reload: () => void } { + const seed = useMemo(() => { + 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>(() => ({ + data: seed, + loading: false, + live: false, + error: null, + updatedAt: 0, + })) + const lastGood = useRef(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 + 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() } +} diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 0000000..e44d49d --- /dev/null +++ b/src/hooks/useInView.ts @@ -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(opts: Opts = {}) { + const { once = true, threshold = 0.18, rootMargin = '0px 0px -8% 0px' } = opts + const ref = useRef(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 +} diff --git a/src/hooks/useMarketBrief.ts b/src/hooks/useMarketBrief.ts new file mode 100644 index 0000000..ae3d007 --- /dev/null +++ b/src/hooks/useMarketBrief.ts @@ -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({ + 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 +} diff --git a/src/hooks/usePrefersReducedMotion.ts b/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000..0129c6d --- /dev/null +++ b/src/hooks/usePrefersReducedMotion.ts @@ -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 +} diff --git a/src/hooks/useScrollProgress.ts b/src/hooks/useScrollProgress.ts new file mode 100644 index 0000000..cbaaf3d --- /dev/null +++ b/src/hooks/useScrollProgress.ts @@ -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 +} diff --git a/src/lib/factsai.ts b/src/lib/factsai.ts new file mode 100644 index 0000000..477a948 --- /dev/null +++ b/src/lib/factsai.ts @@ -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 +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 { + 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 +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..48122b7 --- /dev/null +++ b/src/lib/format.ts @@ -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, + }) +} diff --git a/src/lib/hyperliquid.ts b/src/lib/hyperliquid.ts new file mode 100644 index 0000000..6a2b2d8 --- /dev/null +++ b/src/lib/hyperliquid.ts @@ -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(body: unknown, signal?: AbortSignal): Promise { + 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 +} + +export async function getAllMids(signal?: AbortSignal): Promise> { + return info>({ 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 { + 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 = { + '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 { + const step = INTERVAL_MS[interval] ?? INTERVAL_MS['1h'] + const endTime = Date.now() + const startTime = endTime - step * (lookback + 2) + const raw = await info( + { 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] diff --git a/src/lib/seed.ts b/src/lib/seed.ts new file mode 100644 index 0000000..322d0e5 --- /dev/null +++ b/src/lib/seed.ts @@ -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 diff --git a/src/lib/signals.ts b/src/lib/signals.ts new file mode 100644 index 0000000..e6d57a1 --- /dev/null +++ b/src/lib/signals.ts @@ -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 } +} diff --git a/src/lib/ta.ts b/src/lib/ta.ts new file mode 100644 index 0000000..df9f480 --- /dev/null +++ b/src/lib/ta.ts @@ -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, + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..3b43e84 --- /dev/null +++ b/src/lib/utils.ts @@ -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)) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..97188bc --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +) diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx new file mode 100644 index 0000000..bd90a1e --- /dev/null +++ b/src/pages/Landing.tsx @@ -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 ( +
+ + Open terminal + + + + How it works ↓ + +
+ ) +} + +export function Landing() { + return ( +
+ {/* ── top nav ── */} +
+
+ + + Quant Agent + + + + Terminal ↗ + +
+
+ + {/* ── hero ── */} +
+ + {/* left scrim so the headline stays legible over the globe */} +
+
+ +
+
+
+ + Multi-agent · Hyperliquid perps · paper · dry-run +
+

+ Four agents argue. +
+ + One verdict. + +

+

+ A real-time quant terminal for Hyperliquid perps. Live market data → in-browser + technical analysis → a committee of strategy agents → LONG / SHORT / SKIP{' '} + with confidence and risk parameters. Paper-traded, fully transparent. +

+
+ +
+
+
+
+ + {/* ── how agents work ── */} +
+
+ How it works +

+ Data in. The call out. +

+

+ 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. +

+
+ +
+ +
+ +
+

+ Looping demo. Execution is paper (dry-run) — no orders are signed. +

+ + See it live on the terminal → + +
+
+ + {/* ── what's real ── */} +
+ What's real +
+ {REAL.map((r) => ( +
+
{r.k}
+
{r.t}
+

{r.d}

+
+ ))} +
+
+ + {/* ── footer (strict Carbon Desk terminal style) ── */} +
+
+
+
+ + + Quant Agent + Terminal + +

+ A transparent multi-agent quant terminal on Hyperliquid. Live data → verdict → Telegram. +

+
+ + + LIVE · HL + + + PAPER · DRY-RUN + +
+
+ + + + +
+ +
+

+ Market data is live from the Hyperliquid public API; indicators, agent votes and the + verdict are computed in-browser. Execution is paper (dry-run).{' '} + Not financial advice. +

+ © Quant Agent · pre-TGE +
+
+
+
+ ) +} + +function FooterCol({ + title, + links, +}: { + title: string + links: { label: string; to?: string; href?: string }[] +}) { + return ( +
+
{title}
+
    + {links.map((l) => ( +
  • + {l.to ? ( + + {l.label} + + ) : ( + + {l.label} + + )} +
  • + ))} +
+
+ ) +} diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx new file mode 100644 index 0000000..4cfb1e4 --- /dev/null +++ b/src/pages/Terminal.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+
+ {title} + {meta != null && {meta}} +
+ {children} +
+ ) +} + +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 ( +
onInspect(info) : undefined} + onMouseLeave={info && onInspect && !loading ? () => onInspect(null) : undefined} + > +
{label}
+ {loading ? ( +
+ ) : ( +
+ {value} +
+ )} + {sub && !loading &&
{sub}
} + {bar != null && !loading && ( +
+
+
+ )} +
+ ) +} + +function DivergeBar({ direction, score }: { direction: Direction; score: number }) { + const col = dirColor(direction) + return ( +
+
+ {direction === 'LONG' && ( +
+ )} + {direction === 'SHORT' && ( +
+ )} + {direction === 'SKIP' && ( +
+ )} +
+ ) +} + +/* ── 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 ( +
+
+
+ + + + Quant Agent + + + Terminal +
+ +
+ + + {statusText} + + + {clock} UTC + + PAPER · DRY-RUN + +
+
+
+ ) +} + +/* ── 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 ( +
+ Verdict +
+
+
+
+
+
+
+ ) + } + 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 = () => + const Field = ({ k, v, c }: { k: string; v: string; c?: string }) => ( + + {k} + {v} + + ) + return ( +
+ Verdict + {decision} + + 0 ? 'var(--color-long)' : net < 0 ? 'var(--color-short)' : INK} /> + {decision === 'SKIP' ? 'no consensus' : `${agree.length}/${active.length} agree`} + + + + + + {!live && ( + seed + )} + {reasoning} +
+ ) +} + +/* ── 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 ( + +
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ + ) + } + const active = votes.filter((v) => v.direction !== 'SKIP') + const lean = active.length ? clampN(net / active.length, -1, 1) : 0 + return ( + +
+ {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 ( + + ) + })} +
+
+ net lean +
+
+
+ {lean >= 0 ? ( +
+ ) : ( +
+ )} +
+
+ {net.toFixed(1)} + +
+
+ + ) +} + +/* ── 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 ( +
+
+ How to read this terminal +
+ + +
+
+
+ {GUIDE.map((g, i) => ( +
+
+ {i + 1} + {g.t} +
+

{g.d}

+
+ ))} +
+
+ ) +} + +/* ── page ─────────────────────────────────────────────────────────────────── */ + +export function Terminal() { + const [coin, setCoin] = useState('BTC') + const [tf, setTf] = useState('1h') + const [selectedAgent, setSelectedAgent] = useState(null) + const [hoverAgent, setHoverAgent] = useState(null) + const [inspect, setInspect] = useState(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 ( +
+ + +
+ {/* ── command bar — terminal grid: identity · instrument · timeframe ── */} +
+
+ {/* identity + help */} +
+

+ {coin}-PERP +

+ ${fmtPrice(ta?.price ?? 0)} + + {sign(changePct)}{changePct.toFixed(2)}% + + +
+ + {/* instrument switch — uniform grid cells */} +
+
Instrument
+
+ {COINS.map((c) => { + const on = c === coin + return ( + + ) + })} +
+
+ + {/* timeframe switch — uniform grid cells */} +
+
Timeframe
+
+ {TIMEFRAMES.map((t) => { + const on = t === tf + return ( + + ) + })} +
+
+
+
+ + {guideOpen && setGuideOpen(false)} />} + + {/* ── the call (compact) ── */} + + + {/* ── orchestra · the full agent pipeline ── */} + + + {/* ── agent flow · the pipeline as an interactive workflow graph ── */} + + + {/* ── workspace ── */} +
+ {/* LEFT — stats + pipeline */} +
+ +
+ + + + + + +
+
+ +
+ + + + + = 0 ? '▲' : '▼'} ${macdHist.toFixed(2)}`} tone={macdTone} info={METRIC_INFO['MACD hist']} onInspect={setInspect} loading={loading} /> + +
+
+
+ + {/* CENTER — chart */} + +
+ {updatedAt > 0 ? : } +
+
+ + {/* RIGHT — committee */} +
+ +
+
+ + {/* ── live feed · the agent chain streaming in real time ── */} + + + {/* ── inspector / status footer ── */} +
+ Inspect + + {inspect ?? 'Hover any field, agent or pipeline stage to inspect it.'} + + paper · dry-run · not advice +
+
+ + +
+ ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css new file mode 100644 index 0000000..0a00603 --- /dev/null +++ b/src/styles/globals.css @@ -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); +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..a86dbac --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Path alias (paths resolve relative to this file in TS 6+) */ + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fec8c8e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ab33ff3 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + // svgo v4 keeps viewBox by default; defaults are fine + ViteImageOptimizer(), + ], + resolve: { + dedupe: ['react', 'react-dom'], + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-runtime'], + }, + build: { + cssCodeSplit: true, + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) return + if (id.includes('@visx') || id.includes('/d3-') || id.includes('motion')) return 'charts' + if (id.includes('/react') || id.includes('react-dom') || id.includes('react-router')) + return 'vendor' + }, + }, + }, + }, +}) diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 0000000..87d7e6f --- /dev/null +++ b/worker/README.md @@ -0,0 +1,72 @@ +# Paper-trading worker + +The always-on runtime that makes the swarm **real** — without real-money risk. + +It reuses the exact browser brain (`src/lib/hyperliquid.ts` · `ta.ts` · `signals.ts`, +pure functions, no DOM) on a server loop, paper-trades the verdict against **live +Hyperliquid prices**, and broadcasts every fill to **Telegram**. This turns the site's +biggest fiction — the performance track record — into an honest, verifiable record. + +``` +real candles → real TA → 4 agents vote → verdict → paper open/close → Telegram + JSON + (L1) (L2) (L3) (L4) (L5 paper) (L6) +``` + +## Run + +```bash +pnpm worker # run forever, polling every QUANT_POLL_MS (default 60s) +pnpm worker:once # single tick then exit (smoke test) +``` + +No setup required — with no Telegram configured it runs head-less and prints every +signal/fill to stdout. Add a bot to broadcast (see below). + +## Telegram (optional) + +1. Create a bot with [@BotFather](https://t.me/BotFather) → copy the token. +2. Message the bot once, then get your chat id from [@userinfobot](https://t.me/userinfobot). +3. Put both in `.env.local`: + ``` + TELEGRAM_BOT_TOKEN=123456:ABC-... + TELEGRAM_CHAT_ID=123456789 + ``` +4. `pnpm worker`. The bot posts on open/close and answers commands: + `/status` · `/positions` · `/pnl` (a.k.a. `/learn`) · `/help`. + +## What's real vs. paper + +- **Real:** market data, candles, every indicator, the agent votes, the verdict, the + entry/exit *prices*, and therefore the PnL. +- **Paper:** no order is ever signed or sent. `mode` is fixed to `dry-run`. Positions are + marked to market against the live price; TP/SL are assumed filled at the level. This is + the deliberate 80/20 line — all the credibility, none of the key-management / loss risk. + +## Config (env, all optional) + +| var | default | meaning | +|---|---|---| +| `QUANT_COINS` | `BTC,ETH,SOL` | universe | +| `QUANT_INTERVAL` | `1h` | candle timeframe | +| `QUANT_POLL_MS` | `60000` | tick cadence | +| `QUANT_EQUITY_START` | `10000` | starting paper bankroll (USD) | +| `QUANT_LEVERAGE` | `5` | applied to `sizePct` → notional | +| `QUANT_CONF_FLOOR` | `0.55` | min verdict confidence to open | +| `QUANT_MAX_OPEN_PER_COIN` | `1` | concurrent positions per coin | +| `QUANT_DATA_FILE` | `worker/data/state.json` | persisted state | + +## State + +A single JSON file (`worker/data/`, gitignored) written atomically each tick: +`equity`, open `positions`, `closed` trades, `signals` log, and an `equityCurve`. It is +exactly the shape a future `/api/equity` · `/api/signals` endpoint would serve so the +site's Performance section and "recent fills" can read the real record instead of the +seeded one. + +## Next 20% (deliberately skipped) + +- **Live execution:** sign + send real Hyperliquid orders. Needs an account, key + management, and accepts real loss — keep behind a flag, `dry-run` stays default. +- **`/api/*` + frontend wiring:** serve this state and point `Performance` / fills at it. +- **Claude in the loop:** LLM-written reasoning per verdict and a real LLM `/learn` + retrospective (off the risk path). diff --git a/worker/config.ts b/worker/config.ts new file mode 100644 index 0000000..4f5c898 --- /dev/null +++ b/worker/config.ts @@ -0,0 +1,48 @@ +/** + * Worker config. Loaded from `.env.local` (gitignored) with safe defaults so the + * loop runs out-of-the-box in dry-run *paper* mode even with zero configuration. + * + * Everything here is intentionally paper-only: this process never signs or sends a + * real order. Live execution is the genuinely-risky 20% we deliberately skip — the + * `mode` is fixed to 'dry-run' so the credibility comes from an honest, verifiable + * paper track record, not from putting an LLM/agent in the money-losing path. + */ + +// Node 21+ — load .env.local into process.env without any dependency. +try { + process.loadEnvFile('.env.local') +} catch { + /* no .env.local — fall back to real env / defaults */ +} + +const num = (v: string | undefined, d: number) => + v != null && v !== '' && isFinite(+v) ? +v : d + +const list = (v: string | undefined, d: string[]) => + v ? v.split(',').map((s) => s.trim()).filter(Boolean) : d + +export const CONFIG = { + // ── universe / cadence + coins: list(process.env.QUANT_COINS, ['BTC', 'ETH', 'SOL']), + interval: process.env.QUANT_INTERVAL ?? '1h', + lookback: num(process.env.QUANT_LOOKBACK, 120), + pollMs: num(process.env.QUANT_POLL_MS, 60_000), + + // ── paper trading + equityStart: num(process.env.QUANT_EQUITY_START, 10_000), + leverage: num(process.env.QUANT_LEVERAGE, 5), + confFloor: num(process.env.QUANT_CONF_FLOOR, 0.55), + maxOpenPerCoin: num(process.env.QUANT_MAX_OPEN_PER_COIN, 1), + + // ── telegram (optional — worker logs to stdout if unset) + tgToken: process.env.TELEGRAM_BOT_TOKEN ?? '', + tgChat: process.env.TELEGRAM_CHAT_ID ?? '', + + // ── execution mode — paper only. 'live' is intentionally not implemented. + mode: 'dry-run' as const, + + // ── persistence + dataFile: process.env.QUANT_DATA_FILE ?? 'worker/data/state.json', +} + +export type Config = typeof CONFIG diff --git a/worker/loop.ts b/worker/loop.ts new file mode 100644 index 0000000..b142c80 --- /dev/null +++ b/worker/loop.ts @@ -0,0 +1,186 @@ +/** + * The runtime — the always-on cycle that makes the swarm real. + * + * Per tick, for each coin: real candles → real TA → 4 agents vote → verdict + * (all reused verbatim from src/lib/*), then the paper engine opens/closes + * positions and the Telegram layer broadcasts every fill. State is persisted + * each tick so it survives restarts. + * + * pnpm worker # run forever (default) + * pnpm worker:once # single tick, then exit (smoke test) + */ +import { getCandles, getMarketStats, type MarketStat } from '../src/lib/hyperliquid.ts' +import { summarizeTA, type TASnapshot } from '../src/lib/ta.ts' +import { screen } from '../src/lib/signals.ts' +import { CONFIG } from './config.ts' +import { load, save, getState } from './store.ts' +import { tgSend, tgPoll, TG_ENABLED } from './telegram.ts' +import { markToMarket, maybeOpen, recordSignal, stats, type Stats } from './paper.ts' +import type { Position, Trade } from './store.ts' + +// ── formatting ──────────────────────────────────────────────────────────────── +const usd = (n: number) => + '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 100 ? 0 : 2 }) +const px = (n: number) => + '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 100 ? 0 : n >= 1 ? 2 : 4 }) +const pct = (n: number) => (n >= 0 ? '+' : '') + n.toFixed(2) + '%' +const signed = (n: number) => (n >= 0 ? '+' : '−') + usd(Math.abs(n)).slice(1) + +const lastDecision = new Map() + +// ── telegram messages ─────────────────────────────────────────────────────── +function openMsg(p: Position): string { + const dot = p.dir === 'LONG' ? '🟢' : '🔴' + return [ + `${dot} ${p.dir} ${p.coin}-PERP @ ${px(p.entry)}`, + `size ${(p.notional / getState().equity / CONFIG.leverage * 100).toFixed(1)}% · ×${CONFIG.leverage} → ${usd(p.notional)} notional`, + `TP ${pct(p.tpPct)} (${px(p.tp)}) · SL ${pct(-p.slPct)} (${px(p.sl)})`, + `confidence ${Math.round(p.confidence * 100)}% · ${p.reason}`, + ].join('\n') +} + +function closeMsg(t: Trade): string { + const mark = t.outcome === 'tp' ? '✅ TP' : '❌ SL' + return [ + `${mark} ${t.coin}-PERP ${t.dir}`, + `${px(t.entry)} → ${px(t.exit)} (${pct(t.pnlPct)}, ${signed(t.pnlUsd)})`, + `held ${t.holdMin >= 60 ? (t.holdMin / 60).toFixed(1) + 'h' : t.holdMin + 'm'} · equity ${usd(getState().equity)}`, + ].join('\n') +} + +function statusMsg(): string { + const st = getState() + const s = stats() + const open = st.positions.length + ? st.positions.map((p) => `${p.coin} ${p.dir}`).join(' · ') + : '—' + return [ + `📊 $QUANT — paper · ${CONFIG.mode}`, + `equity ${usd(s.equity)} (${pct(s.roiPct)})`, + `open ${open}`, + `closed ${s.trades} · win ${Math.round(s.winRate * 100)}%`, + ].join('\n') +} + +function learnMsg(): string { + const s = stats() + if (!s.trades) return '🧠 Retrospective\nNo closed trades yet — be patient.' + const pf = s.profitFactor === Infinity ? '∞' : s.profitFactor.toFixed(2) + const lines = [ + `🧠 Retrospective — ${s.trades} trades`, + `win rate ${Math.round(s.winRate * 100)}% · profit factor ${pf}`, + `ROI ${pct(s.roiPct)} (${signed(s.pnlUsd)})`, + `avg win ${signed(s.avgWin)} · avg loss ${signed(-s.avgLoss)}`, + ] + if (s.best) lines.push(`best ${signed(s.best.pnlUsd)} (${s.best.coin} ${s.best.dir})`) + if (s.worst) lines.push(`worst ${signed(s.worst.pnlUsd)} (${s.worst.coin} ${s.worst.dir})`) + return lines.join('\n') +} + +function positionsMsg(): string { + const st = getState() + if (!st.positions.length) return '📭 No open positions.' + return ( + '📂 Open positions\n' + + st.positions + .map( + (p) => + `${p.dir === 'LONG' ? '🟢' : '🔴'} ${p.coin} ${p.dir} @ ${px(p.entry)} · TP ${px(p.tp)} / SL ${px(p.sl)}`, + ) + .join('\n') + ) +} + +const helpMsg = () => + [ + '🤖 $QUANT paper agent', + '/status — equity, open positions, win rate', + '/positions — open positions detail', + '/pnl or /learn — retrospective over closed trades', + '/help — this message', + ].join('\n') + +// ── core tick ───────────────────────────────────────────────────────────────── +async function tickCoin(coin: string, stat: MarketStat | null): Promise { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), 12_000) + try { + const candles = await getCandles(coin, CONFIG.interval, CONFIG.lookback, ctrl.signal) + const ta: TASnapshot = summarizeTA(candles, stat?.funding ?? 0) + const { verdict } = screen(ta, stat ?? undefined) + const price = stat?.mark ?? ta.price + + // 1) manage open positions against the real price + for (const tr of markToMarket(coin, price)) await tgSend(closeMsg(tr)) + + // 2) open a fresh position when the swarm has an edge + const opened = maybeOpen(coin, price, verdict) + if (opened) await tgSend(openMsg(opened)) + + // 3) log the call only when the decision flips (keeps the feed meaningful) + if (verdict.decision !== lastDecision.get(coin)) { + lastDecision.set(coin, verdict.decision) + recordSignal({ + t: Date.now(), + coin, + decision: verdict.decision, + confidence: verdict.confidence, + price, + net: verdict.net, + reason: verdict.reasoning, + }) + } + console.log( + `[${coin}] ${px(price)} ${verdict.decision.padEnd(5)} conf ${verdict.confidence.toFixed(2)} net ${verdict.net.toFixed(2)}`, + ) + } catch (e) { + console.error(`[${coin}] tick error:`, (e as Error).message) + } finally { + clearTimeout(t) + } +} + +async function tick(): Promise { + let byCoin = new Map() + try { + const all = await getMarketStats(CONFIG.coins) + byCoin = new Map(all.map((s) => [s.coin, s])) + } catch (e) { + console.error('[stats] fetch failed:', (e as Error).message) + } + for (const coin of CONFIG.coins) await tickCoin(coin, byCoin.get(coin) ?? null) + save() +} + +async function commandLoop(): Promise { + await tgPoll(async (cmd, chatId) => { + if (cmd === '/status' || cmd === '/start') await tgSend(statusMsg(), chatId) + else if (cmd === '/pnl' || cmd === '/learn') await tgSend(learnMsg(), chatId) + else if (cmd === '/positions') await tgSend(positionsMsg(), chatId) + else if (cmd === '/help') await tgSend(helpMsg(), chatId) + }) +} + +// ── boot ────────────────────────────────────────────────────────────────────── +function bootMsg(s: Stats): string { + return [ + `🟩 $QUANT paper agent online — ${CONFIG.mode}`, + `${CONFIG.coins.join(' · ')} · ${CONFIG.interval} · poll ${CONFIG.pollMs / 1000}s`, + `equity ${usd(s.equity)} · ${s.trades} trades so far`, + ].join('\n') +} + +async function main(): Promise { + const once = process.argv.includes('--once') + load() + console.log( + `QUANT paper worker · mode ${CONFIG.mode} · coins ${CONFIG.coins.join(',')} · interval ${CONFIG.interval} · TG ${TG_ENABLED ? 'on' : 'off'}${once ? ' · ONCE' : ''}`, + ) + await tgSend(bootMsg(stats())) + await tick() + if (once) return + setInterval(tick, CONFIG.pollMs) + if (CONFIG.tgToken) setInterval(commandLoop, 3_000) +} + +main() diff --git a/worker/paper.ts b/worker/paper.ts new file mode 100644 index 0000000..14a19e3 --- /dev/null +++ b/worker/paper.ts @@ -0,0 +1,133 @@ +/** + * Paper-trading engine. Turns the (real) verdict into (paper) positions and marks + * them to market against (real) Hyperliquid prices — so the PnL track record is + * honest and verifiable, not seeded. One position per coin; TP/SL assumed filled + * at the level. This is the layer that makes the LARP's "performance" real. + */ +import { getState, type Position, type Trade, type SignalLog } from './store.ts' +import { CONFIG } from './config.ts' +import type { Verdict } from '../src/lib/signals.ts' + +const round = (n: number, d = 2) => Math.round(n * 10 ** d) / 10 ** d +const sum = (a: number[]) => a.reduce((s, x) => s + x, 0) + +let seq = 0 +const newId = (coin: string) => `${coin}-${Date.now().toString(36)}-${(seq++).toString(36)}` + +/** Close any open position whose TP/SL the current price has crossed. */ +export function markToMarket(coin: string, price: number): Trade[] { + const st = getState() + const closed: Trade[] = [] + const keep: Position[] = [] + for (const p of st.positions) { + if (p.coin !== coin) { + keep.push(p) + continue + } + const hitTp = p.dir === 'LONG' ? price >= p.tp : price <= p.tp + const hitSl = p.dir === 'LONG' ? price <= p.sl : price >= p.sl + if (hitTp || hitSl) { + closed.push(close(p, hitTp ? p.tp : p.sl, hitTp ? 'tp' : 'sl')) + } else { + keep.push(p) + } + } + st.positions = keep + return closed +} + +function close(p: Position, exit: number, outcome: Trade['outcome']): Trade { + const st = getState() + const move = (exit - p.entry) / p.entry + const pnlPct = (p.dir === 'LONG' ? move : -move) * 100 + const pnlUsd = p.notional * (pnlPct / 100) + st.equity = round(st.equity + pnlUsd) + const trade: Trade = { + ...p, + exit, + closedAt: Date.now(), + pnlPct: round(pnlPct, 3), + pnlUsd: round(pnlUsd), + outcome, + holdMin: Math.round((Date.now() - p.openedAt) / 60_000), + } + st.closed.push(trade) + if (st.closed.length > 500) st.closed.splice(0, st.closed.length - 500) + st.equityCurve.push({ t: trade.closedAt, equity: st.equity }) + if (st.equityCurve.length > 1000) st.equityCurve.splice(0, st.equityCurve.length - 1000) + return trade +} + +/** Open a paper position if the verdict is actionable and above the conf floor. */ +export function maybeOpen(coin: string, price: number, v: Verdict): Position | null { + const st = getState() + if (v.decision === 'SKIP') return null + if (v.confidence < CONFIG.confFloor) return null + if (st.positions.filter((p) => p.coin === coin).length >= CONFIG.maxOpenPerCoin) return null + + const dir = v.decision as Position['dir'] + const tp = dir === 'LONG' ? price * (1 + v.tpPct / 100) : price * (1 - v.tpPct / 100) + const sl = dir === 'LONG' ? price * (1 - v.slPct / 100) : price * (1 + v.slPct / 100) + const notional = round(st.equity * (v.sizePct / 100) * CONFIG.leverage) + + const pos: Position = { + id: newId(coin), + coin, + dir, + entry: price, + notional, + tp: round(tp, 4), + sl: round(sl, 4), + tpPct: v.tpPct, + slPct: v.slPct, + confidence: v.confidence, + reason: v.reasoning, + openedAt: Date.now(), + } + st.positions.push(pos) + return pos +} + +/** Append to the rolling signal log (call only when a coin's decision changes). */ +export function recordSignal(s: SignalLog): void { + const st = getState() + st.signals.push(s) + if (st.signals.length > 200) st.signals.splice(0, st.signals.length - 200) +} + +export type Stats = { + trades: number + open: number + winRate: number + pnlUsd: number + roiPct: number + profitFactor: number + avgWin: number + avgLoss: number + best: Trade | null + worst: Trade | null + equity: number +} + +/** Retrospective over closed trades — the honest, no-LLM version of /learn. */ +export function stats(): Stats { + const st = getState() + const c = st.closed + const wins = c.filter((t) => t.pnlUsd > 0) + const losses = c.filter((t) => t.pnlUsd <= 0) + const grossWin = sum(wins.map((t) => t.pnlUsd)) + const grossLoss = -sum(losses.map((t) => t.pnlUsd)) + return { + trades: c.length, + open: st.positions.length, + winRate: c.length ? wins.length / c.length : 0, + pnlUsd: round(st.equity - CONFIG.equityStart), + roiPct: round((st.equity / CONFIG.equityStart - 1) * 100, 2), + profitFactor: grossLoss ? round(grossWin / grossLoss) : grossWin > 0 ? Infinity : 0, + avgWin: wins.length ? round(grossWin / wins.length) : 0, + avgLoss: losses.length ? round(grossLoss / losses.length) : 0, + best: c.reduce((m, t) => (t.pnlUsd > (m?.pnlUsd ?? -Infinity) ? t : m), null), + worst: c.reduce((m, t) => (t.pnlUsd < (m?.pnlUsd ?? Infinity) ? t : m), null), + equity: round(st.equity), + } +} diff --git a/worker/store.ts b/worker/store.ts new file mode 100644 index 0000000..1754e23 --- /dev/null +++ b/worker/store.ts @@ -0,0 +1,103 @@ +/** + * Persistence — a single JSON file written atomically (tmp + rename). No native + * deps, survives restarts, and the exact shape a future `/api/*` endpoint would + * serve to make the site's Performance + fills honest. Swap for SQLite later + * without touching callers (the engine only talks to getState()/save()). + */ +import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs' +import { dirname } from 'node:path' +import { CONFIG } from './config.ts' +import type { Direction } from '../src/lib/signals.ts' + +export type Side = Exclude + +export type Position = { + id: string + coin: string + dir: Side + entry: number + notional: number // USD exposure (= equity * size% * leverage) + tp: number + sl: number + tpPct: number + slPct: number + confidence: number + reason: string + openedAt: number +} + +export type Trade = Position & { + exit: number + closedAt: number + pnlPct: number // direction-adjusted price move, % + pnlUsd: number // notional * pnlPct + outcome: 'tp' | 'sl' + holdMin: number +} + +export type SignalLog = { + t: number + coin: string + decision: Direction + confidence: number + price: number + net: number + reason: string +} + +export type State = { + startedAt: number + mode: string + equity: number + positions: Position[] + closed: Trade[] + signals: SignalLog[] + equityCurve: { t: number; equity: number }[] +} + +let state: State | null = null + +function fresh(): State { + return { + startedAt: Date.now(), + mode: CONFIG.mode, + equity: CONFIG.equityStart, + positions: [], + closed: [], + signals: [], + equityCurve: [{ t: Date.now(), equity: CONFIG.equityStart }], + } +} + +/** Load persisted state, or start fresh. Idempotent. */ +export function load(): State { + if (state) return state + try { + if (existsSync(CONFIG.dataFile)) { + const raw = JSON.parse(readFileSync(CONFIG.dataFile, 'utf8')) as State + state = { ...fresh(), ...raw } + return state + } + } catch (e) { + console.error('[store] load failed, starting fresh:', (e as Error).message) + } + state = fresh() + return state +} + +export function getState(): State { + return state ?? load() +} + +/** Atomic write: serialise to `.tmp`, then rename over the real file. */ +export function save(): void { + const st = getState() + try { + mkdirSync(dirname(CONFIG.dataFile), { recursive: true }) + const tmp = `${CONFIG.dataFile}.tmp` + writeFileSync(tmp, JSON.stringify(st, null, 2)) + renameSync(tmp, CONFIG.dataFile) + } catch (e) { + console.error('[store] save failed:', (e as Error).message) + } +} diff --git a/worker/telegram.ts b/worker/telegram.ts new file mode 100644 index 0000000..1d3e689 --- /dev/null +++ b/worker/telegram.ts @@ -0,0 +1,66 @@ +/** + * Telegram — the real L6 "Comms" layer, in ~60 lines via the raw Bot API (no + * dependency). Outbound: tgSend() broadcasts signals/closes. Inbound: tgPoll() + * long-polls getUpdates for slash commands (/status, /pnl, /positions, /help). + * + * If no token/chat is configured the worker still runs — messages print to stdout + * so you can demo the whole loop with zero Telegram setup. + */ +import { CONFIG } from './config.ts' + +const api = (method: string) => `https://api.telegram.org/bot${CONFIG.tgToken}/${method}` + +export const TG_ENABLED = Boolean(CONFIG.tgToken && CONFIG.tgChat) + +const stripHtml = (s: string) => s.replace(/<[^>]+>/g, '') + +/** Send a message (HTML). Defaults to the configured chat; pass chatId to reply. */ +export async function tgSend(text: string, chatId: string | number = CONFIG.tgChat): Promise { + if (!CONFIG.tgToken || !chatId) { + console.log('\n' + stripHtml(text) + '\n') + return + } + try { + const res = await fetch(api('sendMessage'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: true, + }), + }) + if (!res.ok) console.error('[tg] send failed', res.status, await res.text()) + } catch (e) { + console.error('[tg] send error', (e as Error).message) + } +} + +let offset = 0 + +/** Poll once for new commands and dispatch them. Cheap to call on an interval. */ +export async function tgPoll( + handler: (cmd: string, chatId: number) => Promise, +): Promise { + if (!CONFIG.tgToken) return + try { + const res = await fetch(api('getUpdates') + `?timeout=0&offset=${offset}`) + if (!res.ok) return + const json = (await res.json()) as { + result?: { update_id: number; message?: { text?: string; chat?: { id: number } } }[] + } + for (const u of json.result ?? []) { + offset = u.update_id + 1 + const text = u.message?.text + const chatId = u.message?.chat?.id + if (text && chatId != null && text.startsWith('/')) { + // strip @BotName suffix and arguments → bare command + const cmd = text.trim().split(/\s+/)[0].split('@')[0].toLowerCase() + await handler(cmd, chatId) + } + } + } catch { + /* transient — ignore, next poll retries */ + } +}