From 5039c16ba676c70c4cd5cc09c67f4176ff731468 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Tue, 14 Jul 2026 18:17:46 -0700 Subject: [PATCH 1/5] ci: add React Doctor workflow, doctor script, and agent skill Advisory PR scans + main-branch trend tracking via millionco/react-doctor@v2, a root `bun run doctor` script, and the local triage skill for agents. Co-Authored-By: Claude Fable 5 --- .claude/skills/react-doctor/SKILL.md | 51 +++++++++++++ .../skills/react-doctor/references/explain.md | 72 +++++++++++++++++++ .github/workflows/react-doctor.yml | 53 ++++++++++++++ package.json | 3 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/react-doctor/SKILL.md create mode 100644 .claude/skills/react-doctor/references/explain.md create mode 100644 .github/workflows/react-doctor.yml diff --git a/.claude/skills/react-doctor/SKILL.md b/.claude/skills/react-doctor/SKILL.md new file mode 100644 index 0000000..332a47f --- /dev/null +++ b/.claude/skills/react-doctor/SKILL.md @@ -0,0 +1,51 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user types `/doctor`, asks to scan, triage, or clean up React diagnostics. Covers lint, accessibility, bundle size, architecture. Includes a regression check and a full local-triage workflow that fetches the canonical playbook. +version: "1.2.0" +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx react-doctor@latest --verbose --scope changed` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx react-doctor@latest --verbose` (the default `--scope full`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## /doctor — full local triage workflow + +When the user types `/doctor`, says "run react doctor", or asks for a full triage / cleanup pass (not just a regression check), fetch the canonical local-triage playbook and follow every step in it: + +```bash +curl --fail --silent --show-error \ + --header 'Cache-Control: no-cache' \ + https://www.react.doctor/prompts/react-doctor-agent.md +``` + +The playbook is the single source of truth — a scan → filter → triage → fix → validate loop that edits the working tree directly (never commits, never opens PRs). Updating the prompt at its source updates every agent on its next fetch — no skill reinstall needed. + +Pair it with the matching per-rule prompts at `https://www.react.doctor/prompts/rules//.md` (fetched on demand inside the playbook) so each fix uses the canonical, reviewer-tested recipe. + +## Configuring or explaining rules + +When the user wants to understand a rule, disagrees with one, or wants to disable / tune which rules run (not fix code), read [references/explain.md](references/explain.md) and follow it. Start with `npx react-doctor@latest rules explain `, then apply the narrowest control via `npx react-doctor@latest rules disable|set|category|ignore-tag …`, which edits your `doctor.config.*` (or `package.json#reactDoctor`). + +## Command + +```bash +npx react-doctor@latest --verbose --scope changed +``` + +| Flag | Purpose | +| ----------------- | ---------------------------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--scope changed` | Only report issues introduced vs the base branch (default: full) | +| `--scope lines` | Only report issues on the changed lines | +| `--score` | Output only the numeric score | diff --git a/.claude/skills/react-doctor/references/explain.md b/.claude/skills/react-doctor/references/explain.md new file mode 100644 index 0000000..8e4defe --- /dev/null +++ b/.claude/skills/react-doctor/references/explain.md @@ -0,0 +1,72 @@ +# Explaining and configuring rules + +Explain React Doctor rules and edit `doctor.config.*` safely. Use this when a user +wants to understand a rule or change which rules run — not for fixing diagnostics +(that is the main `react-doctor` skill / `/doctor`). + +Triggers: "why did this rule fire", "I disagree with this rule", "turn this rule off", +"stop flagging X", "too noisy", "disable design rules". + +## Workflow + +1. Identify the rule key from the diagnostic (e.g. `react-doctor/no-array-index-as-key`). +2. Explain it before changing anything: + +```bash +npx react-doctor@latest rules explain react-doctor/no-array-index-as-key +``` + +3. Pick the narrowest control that matches the user's intent (see decision guide). +4. Apply it with a `rules` subcommand (edits your `doctor.config.*` or `package.json#reactDoctor` in place, preserving other fields and formatting). +5. Validate the change did what they wanted: + +```bash +npx react-doctor@latest --verbose --diff +``` + +## Commands + +```bash +npx react-doctor@latest rules list # every rule + its effective severity +npx react-doctor@latest rules list --configured # only what your config changed +npx react-doctor@latest rules list --category Performance # filter by category +npx react-doctor@latest rules explain # why it matters + how to configure +npx react-doctor@latest rules disable # rule never runs +npx react-doctor@latest rules enable # turn back on at its recommended severity +npx react-doctor@latest rules set warn # off | warn | error +npx react-doctor@latest rules category "React Native" off # whole category +npx react-doctor@latest rules ignore-tag design # skip a rule family (design, test-noise, …) +npx react-doctor@latest rules unignore-tag design +``` + +Rule references accept the full key (`react-doctor/no-danger`), the bare id (`no-danger`), or a legacy key (`react/no-danger`). + +## Decision guide + +Match the control to the intent — prefer the narrowest one: + +- **User disagrees with one rule / it's a false positive for them** → `rules disable ` (sets `rules. = "off"`; the rule stops running everywhere). This is the default for "I don't want this rule". +- **Rule is fine but wrong severity** → `rules set warn` or `rules set error`. +- **A disabled-by-default rule they want on** → `rules enable `. +- **A whole area is unwanted** (e.g. all React Native rules) → `rules category "" off`. +- **A behavioral family is noisy** (`design`, `test-noise`, `migration-hint`) → `rules ignore-tag `. +- **Keep it locally but hide from PR comment / score / CI gate only** → do NOT disable. Edit `surfaces` in your config (`surfaces.prComment.excludeRules`, `surfaces.score.excludeTags`, `surfaces.ciFailure.excludeCategories`). The rule still shows in local `cli` output. + +How the layers combine: `ignore.tags` disables every rule carrying that tag **before** linting, so a tagged rule stays off even if `rules`/`categories` set it to `warn`/`error` (a rule-level override cannot re-enable a tag-ignored rule). For rules that aren't tag-disabled, `rules` overrides `categories` overrides the rule's default. `surfaces` is visibility-only and never changes whether a rule runs. + +## Config shape + +Config lives in `doctor.config.ts` (or `.js`/`.mjs`/`.cjs`/`.json`/`.jsonc`), or the `reactDoctor` key in `package.json`. The `rules` commands edit whichever exists — TS/JS edits preserve formatting (via magicast) — and create `doctor.config.json` when none does, stamping `$schema`: + +```ts +// doctor.config.ts +export default { + rules: { "react-doctor/no-array-index-as-key": "off" }, + categories: { "React Native": "warn" }, + ignore: { tags: ["design"] }, +}; +``` + +## Educating the user + +When explaining a rule, lead with the "Why it matters" guidance from `rules explain` and, when they want depth, the per-rule recipe at `https://www.react.doctor/prompts/rules//.md`. Only after they understand it should you offer to disable it — many "bad" rules are catching real issues. diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 0000000..a8727fa --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,53 @@ +# React Doctor — finds security, performance, correctness, accessibility, +# bundle-size, and architecture issues in React codebases. +# +# Docs: https://www.react.doctor/ci +# Source: https://github.com/millionco/react-doctor + +name: React Doctor + +on: + # Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review. + push: + branches: ["main"] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +# Cancels any in-flight scan for the same PR (or branch, on push) the moment a new commit arrives, so reviewers only ever see the latest run. +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + # fetch-depth: 0 gives React Doctor the full git history it needs to find the merge base with the target branch. Without it a shallow checkout has no merge base, so PR runs can't compare against the base and fall back to reporting every issue in the changed files (pre-existing ones included) instead of only the ones the PR introduced. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: millionco/react-doctor@v2 + # Advisory by default: React Doctor reports findings on every PR — a + # sticky summary comment, inline review comments, and a commit status + # with the health score — but never fails the check, so it won't red-X + # a teammate's PR on day one. When your team trusts the signal, graduate + # the gate: uncomment the block below and set blocking to "error" (fail + # on new error-severity findings) or "warning" (fail on any finding). + # Full reference: https://www.react.doctor/ci + # with: + # blocking: error # Gate level: "none" (advisory, the default) | "warning" | "error" + # scope: full # On PRs, scan the whole project instead of just changed files + # comment: false # Disable the sticky PR summary comment + # review-comments: false # Disable inline review comments on changed lines + # commit-status: false # Disable the commit status (score + counts, links to the run) + # version: "0.4.0" # Pin to a specific react-doctor version instead of "latest" + # directory: apps/web # Scan a sub-directory (default: ".") + # project: "web,admin" # In a monorepo, scan specific workspace project(s) diff --git a/package.json b/package.json index ff046e2..5c3114b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build-storybook": "turbo run build --filter=@elide/tokens --filter=@elide/ui && bun run --filter=@elide/storybook build-storybook", "chromatic": "bun run --filter @elide/storybook chromatic", "changeset": "changeset", - "release": "changeset publish" + "release": "changeset publish", + "doctor": "npx react-doctor@latest" }, "devDependencies": { "@changesets/cli": "^2.31.0", From 807363f0a733e7138d0603aeef25d0aeecba814b Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Tue, 14 Jul 2026 18:18:08 -0700 Subject: [PATCH 2/5] fix(a11y): label the render-prop anchors in AppNav and MobileNav The changelog/install anchors passed to `Button render={...}` only receive their visible text at runtime, so statically they are empty links that screen-reader tooling flags as announcing nothing. Give each an aria-label bound to the same i18n message as the visible text so the two cannot drift. Co-Authored-By: Claude Fable 5 --- packages/ui/src/components/app-nav.tsx | 15 +++++++++++++-- packages/ui/src/components/mobile-nav.tsx | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/app-nav.tsx b/packages/ui/src/components/app-nav.tsx index 3518302..3cad56d 100644 --- a/packages/ui/src/components/app-nav.tsx +++ b/packages/ui/src/components/app-nav.tsx @@ -120,12 +120,23 @@ export function AppNav({ - - diff --git a/packages/ui/src/components/mobile-nav.tsx b/packages/ui/src/components/mobile-nav.tsx index 3a0518c..69dab4e 100644 --- a/packages/ui/src/components/mobile-nav.tsx +++ b/packages/ui/src/components/mobile-nav.tsx @@ -172,7 +172,9 @@ export function MobileNav({ `. */ -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", - { - variants: { - variant: { - primary: "bg-primary text-primary-foreground hover:bg-primary/90", - gradient: "text-white [background:var(--eld-gradient-brand)] hover:opacity-95", - outline: "border border-border bg-transparent text-foreground hover:bg-[var(--hover)]", - ghost: "bg-transparent text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground", - changelog: - "border text-foreground [border-color:var(--eld-accent-violet)] [background:color-mix(in_oklab,var(--eld-accent-violet)_10%,transparent)] [box-shadow:0_0_0_1px_color-mix(in_oklab,var(--eld-accent-violet)_22%,transparent),0_0_16px_-4px_color-mix(in_oklab,var(--eld-accent-violet)_55%,transparent)] hover:[background:color-mix(in_oklab,var(--eld-accent-violet)_16%,transparent)]", - }, - size: { - sm: "h-8 px-3 text-xs", - md: "h-9 px-4 text-sm", - icon: "h-9 w-9 p-0", - "icon-sm": "h-8 w-8 p-0", - }, - }, - defaultVariants: { variant: "primary", size: "md" }, - }, -); - export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { @@ -65,5 +41,3 @@ export const Button = React.forwardRef( }, ); Button.displayName = "Button"; - -export { buttonVariants }; diff --git a/packages/ui/src/components/code-block.tsx b/packages/ui/src/components/code-block.tsx index 8e83d0c..9894603 100644 --- a/packages/ui/src/components/code-block.tsx +++ b/packages/ui/src/components/code-block.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Check, Copy } from "lucide-react"; import { Highlight, type PrismTheme } from "prism-react-renderer"; import { cn, keyed } from "../lib/utils"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; /** * CopyButton — copies `value` to the clipboard, flips to a check for 1.5s. diff --git a/packages/ui/src/components/mobile-nav.tsx b/packages/ui/src/components/mobile-nav.tsx index 69dab4e..5b28fc2 100644 --- a/packages/ui/src/components/mobile-nav.tsx +++ b/packages/ui/src/components/mobile-nav.tsx @@ -3,7 +3,7 @@ import { History, Menu, Search, Sparkle } from "lucide-react"; import { cn } from "../lib/utils"; import { Button } from "./button"; import { Sheet, SheetTrigger, SheetContent } from "./sheet"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; /** * MobileNav — the docs shell at phone width (mockup 3b): a condensed top app diff --git a/packages/ui/src/components/navigation-menu-variants.ts b/packages/ui/src/components/navigation-menu-variants.ts new file mode 100644 index 0000000..800c64c --- /dev/null +++ b/packages/ui/src/components/navigation-menu-variants.ts @@ -0,0 +1,10 @@ +import { cva } from "class-variance-authority" + +/** + * Trigger style shared by NavigationMenuTrigger and standalone links styled to + * match. Kept out of navigation-menu.tsx so that file exports only components + * and Fast Refresh can preserve state on edit. + */ +export const navigationMenuTriggerStyle = cva( + "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted" +) diff --git a/packages/ui/src/components/navigation-menu.tsx b/packages/ui/src/components/navigation-menu.tsx index 7e09d94..64f6bc2 100644 --- a/packages/ui/src/components/navigation-menu.tsx +++ b/packages/ui/src/components/navigation-menu.tsx @@ -1,7 +1,7 @@ import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu" -import { cva } from "class-variance-authority" import { cn } from "../lib/utils" +import { navigationMenuTriggerStyle } from "./navigation-menu-variants" import { ChevronDownIcon } from "lucide-react" function NavigationMenu({ @@ -55,10 +55,6 @@ function NavigationMenuItem({ ) } -const navigationMenuTriggerStyle = cva( - "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted" -) - function NavigationMenuTrigger({ className, children, @@ -163,6 +159,5 @@ export { NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, - navigationMenuTriggerStyle, NavigationMenuPositioner, } diff --git a/packages/ui/src/components/section-tabs.tsx b/packages/ui/src/components/section-tabs.tsx index 9c10dba..8233257 100644 --- a/packages/ui/src/components/section-tabs.tsx +++ b/packages/ui/src/components/section-tabs.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { cn } from "../lib/utils"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; /** * SectionTabs — the 46px top-level section bar under the main nav (Start · diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx index 03953d7..2a608d0 100644 --- a/packages/ui/src/components/sidebar.tsx +++ b/packages/ui/src/components/sidebar.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Check, ChevronDown, ExternalLink } from "lucide-react"; import { cn } from "../lib/utils"; import { Badge } from "./badge"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; import { DropdownMenu, DropdownMenuContent, diff --git a/packages/ui/src/components/status-badge.tsx b/packages/ui/src/components/status-badge.tsx index be0f357..4cf0d6a 100644 --- a/packages/ui/src/components/status-badge.tsx +++ b/packages/ui/src/components/status-badge.tsx @@ -1,5 +1,5 @@ import { Badge, type BadgeProps } from "./badge"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; /** * StatusBadge — thin wrapper over `Badge`'s status tones. Maps an API-support diff --git a/packages/ui/src/components/support-matrix.tsx b/packages/ui/src/components/support-matrix.tsx index 9e91fb0..f6f47b7 100644 --- a/packages/ui/src/components/support-matrix.tsx +++ b/packages/ui/src/components/support-matrix.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Check, CircleDot, X, type LucideIcon } from "lucide-react"; import { cn, keyed } from "../lib/utils"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; import type { Messages } from "../i18n/messages"; /** diff --git a/packages/ui/src/components/table-of-contents.tsx b/packages/ui/src/components/table-of-contents.tsx index c1548d7..698705f 100644 --- a/packages/ui/src/components/table-of-contents.tsx +++ b/packages/ui/src/components/table-of-contents.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { cn } from "../lib/utils"; -import { useMessages } from "../i18n/provider"; +import { useMessages } from "../i18n/context"; /** * TableOfContents — the "On this page" right-rail nav that lists in-page diff --git a/packages/ui/src/components/tabs-variants.ts b/packages/ui/src/components/tabs-variants.ts new file mode 100644 index 0000000..b32afda --- /dev/null +++ b/packages/ui/src/components/tabs-variants.ts @@ -0,0 +1,20 @@ +import { cva } from "class-variance-authority" + +/** + * TabsList class variants. Kept out of tabs.tsx so that file exports only + * components and Fast Refresh can preserve state on edit. + */ +export const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 3668239..4908016 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -1,9 +1,10 @@ "use client" import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" -import { cva, type VariantProps } from "class-variance-authority" +import { type VariantProps } from "class-variance-authority" import { cn } from "../lib/utils" +import { tabsListVariants } from "./tabs-variants" function Tabs({ className, @@ -23,21 +24,6 @@ function Tabs({ ) } -const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", - { - variants: { - variant: { - default: "bg-muted", - line: "gap-1 bg-transparent", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - function TabsList({ className, variant = "default", @@ -79,4 +65,4 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { ) } -export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/packages/ui/src/components/theme-context.ts b/packages/ui/src/components/theme-context.ts new file mode 100644 index 0000000..aa4952a --- /dev/null +++ b/packages/ui/src/components/theme-context.ts @@ -0,0 +1,21 @@ +import * as React from "react"; + +/** + * Theme context + `useTheme`. Kept out of theme-provider.tsx so that file + * exports only components and Fast Refresh can preserve state on edit. + */ +export type Theme = "light" | "dark"; + +export interface ThemeContextValue { + theme: Theme; + setTheme: (theme: Theme) => void; + toggle: () => void; +} + +export const ThemeContext = React.createContext(null); + +export function useTheme(): ThemeContextValue { + const ctx = React.useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/packages/ui/src/components/theme-provider.tsx b/packages/ui/src/components/theme-provider.tsx index 39f988f..d445a3c 100644 --- a/packages/ui/src/components/theme-provider.tsx +++ b/packages/ui/src/components/theme-provider.tsx @@ -1,20 +1,11 @@ import * as React from "react"; +import { ThemeContext, type Theme, type ThemeContextValue } from "./theme-context"; /** * ThemeProvider — sets the active color theme on so that both the page * and Base UI overlays (which portal to ) pick it up. Persists the choice * to localStorage and initializes from the stored value or the OS preference. */ -export type Theme = "light" | "dark"; - -interface ThemeContextValue { - theme: Theme; - setTheme: (theme: Theme) => void; - toggle: () => void; -} - -const ThemeContext = React.createContext(null); - export interface ThemeProviderProps { children: React.ReactNode; /** Theme used until the stored/system value resolves on mount. */ @@ -63,9 +54,3 @@ export function ThemeProvider({ return {children}; } - -export function useTheme(): ThemeContextValue { - const ctx = React.useContext(ThemeContext); - if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); - return ctx; -} diff --git a/packages/ui/src/i18n/context.ts b/packages/ui/src/i18n/context.ts new file mode 100644 index 0000000..4ad9905 --- /dev/null +++ b/packages/ui/src/i18n/context.ts @@ -0,0 +1,14 @@ +import * as React from "react"; +import { en, type Messages } from "./messages"; + +/** + * Messages context + `useMessages`. Kept out of provider.tsx so that file + * exports only components and Fast Refresh can preserve state on edit. + * Without a provider, English defaults (`en`) are used. + */ +export const MessagesContext = React.createContext(en); + +/** Access the merged message dictionary (defaults ⊕ provider override). */ +export function useMessages(): Messages { + return React.useContext(MessagesContext); +} diff --git a/packages/ui/src/i18n/provider.tsx b/packages/ui/src/i18n/provider.tsx index 31dc735..e047162 100644 --- a/packages/ui/src/i18n/provider.tsx +++ b/packages/ui/src/i18n/provider.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import { en, mergeMessages, type Messages, type PartialMessages } from "./messages"; +import { en, mergeMessages, type PartialMessages } from "./messages"; +import { MessagesContext } from "./context"; /** * i18n for @elide/ui. Components read their own chrome strings from @@ -10,8 +11,6 @@ import { en, mergeMessages, type Messages, type PartialMessages } from "./messag * import fr from "./locales/fr.json"; // a PartialMessages dictionary * {app} */ -const MessagesContext = React.createContext(en); - export interface MessagesProviderProps { /** Locale dictionary; deep-merged (two levels) over the English defaults. */ messages?: PartialMessages; @@ -22,8 +21,3 @@ export function MessagesProvider({ messages, children }: MessagesProviderProps) const value = React.useMemo(() => mergeMessages(en, messages), [messages]); return {children}; } - -/** Access the merged message dictionary (defaults ⊕ provider override). */ -export function useMessages(): Messages { - return React.useContext(MessagesContext); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e63def5..00c665a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,13 +1,15 @@ -export { Button, buttonVariants, type ButtonProps } from "./components/button"; -export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; +export { Button, type ButtonProps } from "./components/button"; +export { buttonVariants } from "./components/button-variants"; +export { Badge, type BadgeProps } from "./components/badge"; +export { badgeVariants } from "./components/badge-variants"; export { Callout, type CalloutProps } from "./components/callout"; export { CodeBlock, CopyButton, type CodeBlockProps } from "./components/code-block"; export { cn } from "./lib/utils"; -export { - MessagesProvider, useMessages, type MessagesProviderProps, -} from "./i18n/provider"; +export { MessagesProvider, type MessagesProviderProps } from "./i18n/provider"; +export { useMessages } from "./i18n/context"; export { en as defaultMessages, type Messages, type PartialMessages } from "./i18n/messages"; -export { ThemeProvider, useTheme, type Theme, type ThemeProviderProps } from "./components/theme-provider"; +export { ThemeProvider, type ThemeProviderProps } from "./components/theme-provider"; +export { useTheme, type Theme } from "./components/theme-context"; export { Breadcrumbs, type BreadcrumbSegment, type BreadcrumbsProps } from "./components/breadcrumbs"; export { PageFooterNav, type PageFooterNavLink, type PageFooterNavProps } from "./components/page-footer-nav"; @@ -32,12 +34,14 @@ export { export { Separator } from "./components/separator"; export { ScrollArea, ScrollBar } from "./components/scroll-area"; export { Switch } from "./components/switch"; -export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } from "./components/tabs"; +export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/tabs"; +export { tabsListVariants } from "./components/tabs-variants"; export { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, - NavigationMenuPositioner, navigationMenuTriggerStyle, + NavigationMenuPositioner, } from "./components/navigation-menu"; +export { navigationMenuTriggerStyle } from "./components/navigation-menu-variants"; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./components/accordion"; export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, diff --git a/packages/ui/src/stories/Theme.stories.tsx b/packages/ui/src/stories/Theme.stories.tsx index 2a9a1db..82f5741 100644 --- a/packages/ui/src/stories/Theme.stories.tsx +++ b/packages/ui/src/stories/Theme.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { ThemeProvider, useTheme } from "../components/theme-provider"; +import { ThemeProvider } from "../components/theme-provider"; +import { useTheme } from "../components/theme-context"; import { Button } from "../components/button"; function ThemeToggleDemo() {