2 Commits

Author SHA1 Message Date
Jason Stedwell 840504843a chore: release v0.5.0
ci / build-and-design (push) Failing after 1s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:06:49 -05:00
Jason Stedwell 822d8e41e6 visual upgrade
ci / build-and-design (push) Failing after 13s
2026-07-23 04:23:19 -05:00
53 changed files with 7758 additions and 286 deletions
+5 -7
View File
@@ -12,10 +12,8 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
- run: npm install --no-audit --no-fund - run: npm ci --no-audit --no-fund
- name: Typecheck - name: Full library verification
run: npm run typecheck run: npm run check
- name: Build - name: Published package smoke test
run: npm run build run: npm pack --dry-run
- name: Impeccable design detector (anti-slop)
run: npx impeccable detect src/ --json || true
+2
View File
@@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
dist-showcase/
dist-demo/
*.log *.log
.DS_Store .DS_Store
# impeccable-ignore-start # impeccable-ignore-start
+5 -3
View File
@@ -2,9 +2,11 @@
"$schema": "https://impeccable.style/schema/config.json", "$schema": "https://impeccable.style/schema/config.json",
"detector": { "detector": {
"designSystem": { "enabled": true }, "designSystem": { "enabled": true },
"ignoreValues": { "ignoreValues": [
"overused-font": ["Montserrat", "Open Sans", "JetBrains Mono"] { "rule": "overused-font", "value": "Montserrat", "reason": "Established display face in the shared brand system" },
}, { "rule": "overused-font", "value": "Open Sans", "reason": "Established UI face in the shared brand system" },
{ "rule": "overused-font", "value": "JetBrains Mono", "reason": "Established data face in the shared brand system" }
],
"ignoreFiles": ["dist/**"] "ignoreFiles": ["dist/**"]
}, },
"hook": { "enabled": true } "hook": { "enabled": true }
+2 -1
View File
@@ -65,7 +65,8 @@ This app uses the shared design foundation `@jason/ui-kit`.
- Design system + rules: `node_modules/@jason/ui-kit/DESIGN.md` - Design system + rules: `node_modules/@jason/ui-kit/DESIGN.md`
- API/exports: `node_modules/@jason/ui-kit/README.md` - API/exports: `node_modules/@jason/ui-kit/README.md`
- Tailwind: use `presets: [require("@jason/ui-kit/preset")]`; import `@jason/ui-kit/styles.css` once at entry. - Tailwind: use `presets: [require("@jason/ui-kit/preset")]`; import `@jason/ui-kit/styles.css` once at entry.
- Prefer kit components (Button, Card, Input, Badge, Dialog, DropdownMenu, Table, Toaster, - Prefer kit components (Button, Card, FormField, Input, Select, Checkbox, Switch, Tabs,
Tooltip, Badge, Dialog, DropdownMenu, Table, Pagination, Toaster,
AppShell, CommandPalette, Hero, FeatureGrid, Pricing) over hand-rolled ones. Never fork a AppShell, CommandPalette, Hero, FeatureGrid, Pricing) over hand-rolled ones. Never fork a
kit component in-app — request the change upstream instead. kit component in-app — request the change upstream instead.
- Use `cn()` for conditional classes and `toast()` for notifications. - Use `cn()` for conditional classes and `toast()` for notifications.
+5 -2
View File
@@ -5,13 +5,16 @@
2. Use `cn()` + `class-variance-authority` for variants. Wrap Radix for interactive primitives. 2. Use `cn()` + `class-variance-authority` for variants. Wrap Radix for interactive primitives.
3. Follow **DESIGN.md**: tinted neutrals, layered shadows, ease-out motion, focus-visible rings, no nested cards. 3. Follow **DESIGN.md**: tinted neutrals, layered shadows, ease-out motion, focus-visible rings, no nested cards.
4. Export it from `src/index.ts`. 4. Export it from `src/index.ts`.
5. `npm run typecheck && npm run build && npm run design:detect` must pass. 5. Add the component and its important states to `showcase/showcase.tsx`.
6. Add an interaction/accessibility test when behavior changes.
7. `npm run check` must pass.
## Releasing ## Releasing
`npm version <patch|minor|major>` then `npm publish` (builds automatically). `npm version <patch|minor|major>` then `npm publish` (builds automatically).
Tag notes in the commit. Consuming apps bump at their own pace. Tag notes in the commit. Consuming apps bump at their own pace.
## Design guardrail ## Design guardrail
Every visual change should survive `impeccable audit`/`polish`. If the detector Every visual change should survive `impeccable audit`/`polish` and the living
showcase in both themes. If the detector
flags something intentional (e.g. a brand font), add a scoped ignore in flags something intentional (e.g. a brand font), add a scoped ignore in
`.impeccable/config.json` with a reason — don't disable the rule globally. `.impeccable/config.json` with a reason — don't disable the rule globally.
+19 -15
View File
@@ -1,30 +1,32 @@
# Design System — @jason/ui-kit # Design System — @jason/ui-kit
The single source of truth for how every app looks and feels. Tokens live in The single source of truth for how every app looks and feels. Tokens live in
`src/lib/tokens.ts` and `src/styles/globals.css`; the Tailwind preset `src/lib/tokens.json`; TypeScript consumes it directly, the CSS theme is generated,
(`tailwind-preset.cjs`) exposes them as utilities. and the Tailwind preset exposes it as utilities. `npm run tokens:check` prevents drift.
## Color ## Color
Warm-tinted neutrals (never pure black/gray) on a warm dark base, with a single Warm-tinted neutrals (never pure black/gray) on a warm dark base, with a single
gold brand accent. Semantic colors: success / warning / danger / info. gold brand accent. Semantic colors: success / warning / danger / info.
| Token | Dark (default) | Use | | Token | Dark (default) | Light | Use |
|---|---|---| |---|---|---|---|
| `bg` | #232022 | Page background | | `bg` | #232022 | #FAF8F4 | Page background |
| `surface` / `surface-2` | #2b282a / #333031 | Cards, raised panels | | `surface` / `surface-2` | #2B282A / #333031 | #FFFFFF / #F4F0EA | Cards, raised panels |
| `border` | #3a3638 | Hairlines, inputs | | `border` | #3A3638 | #DED8D1 | Hairlines, inputs |
| `text` / `muted` | #F5F1EC / #A99F97 | Body / secondary | | `text` / `muted` | #F5F1EC / #A99F97 | #272326 / #665F62 | Body / secondary |
| `brand` / `brand-bright` | #DCBB4F / #F5CD15 | Accent, primary actions | | `brand` | #DCBB4F | #79640E | Accent and action |
A `.light` class on `<html>` flips neutrals for light surfaces; brand stays constant. Every filled semantic color has a matching `*-foreground` token. Do not reuse a
Rebrand any app by overriding the CSS variables in `globals.css` — no recompile. background color as text without a contrast check. `ThemeProvider` supports dark,
light, and system modes; `ThemeScript` prevents a flash before hydration.
## Type ## Type
- Display / headings: **Montserrat** (600/700) - Display / headings: **Montserrat** (600/700)
- Body / UI: **Open Sans** - Body / UI: **Open Sans**
- Code / data: **JetBrains Mono** - Code / data: **JetBrains Mono**
Never Inter, Arial, or system defaults for headings (Impeccable anti-slop rule). Import `@jason/ui-kit/fonts.css` to self-host the Latin variable fonts. Without it,
the declared system fallbacks remain safe. Never use Inter or Arial for headings.
## Radius, elevation, motion ## Radius, elevation, motion
- Radius scale: 6 / 10 / 14 / 20px. - Radius scale: 6 / 10 / 14 / 20px.
@@ -37,11 +39,13 @@ Never Inter, Arial, or system defaults for headings (Impeccable anti-slop rule).
2. Variants via `class-variance-authority`, not ad-hoc conditionals. 2. Variants via `class-variance-authority`, not ad-hoc conditionals.
3. Interactive primitives wrap Radix (focus traps, keyboard nav, ARIA for free). 3. Interactive primitives wrap Radix (focus traps, keyboard nav, ARIA for free).
4. Focus-visible ring on every interactive element. 4. Focus-visible ring on every interactive element.
5. Don't nest cards inside cards. Don't put gray text on colored fills. 5. Don't nest cards inside cards. Use semantic foreground tokens on colored fills.
6. Review every new state in the showcase in dark, light, narrow, and reduced-motion modes.
## Included ## Included
Primitives: Button, Card, Input, Select (themed Radix popover), Textarea, Label, Primitives: Button, Card, FormField, Input, Select, Textarea, Label, Checkbox,
Badge, Dialog, DropdownMenu, Table, Toaster, Spinner, EmptyState, StatCard. Switch, Tabs, Tooltip, Badge, Dialog, DropdownMenu, Table, Pagination, Skeleton,
Toaster, Spinner, EmptyState, StatCard.
Motion: FadeIn, Stagger/StaggerItem. Motion: FadeIn, Stagger/StaggerItem.
Animated: AnimatedNumber, SpotlightCard. Animated: AnimatedNumber, SpotlightCard.
Blocks: AppShell, CommandPalette, Hero, FeatureGrid, Pricing. Blocks: AppShell, CommandPalette, Hero, FeatureGrid, Pricing.
+4 -1
View File
@@ -26,6 +26,7 @@ so they're the natural reference implementations:
2. **Adopt the preset.** Replace the app's hand-rolled color block in 2. **Adopt the preset.** Replace the app's hand-rolled color block in
`tailwind.config.js` with `presets: [require("@jason/ui-kit/preset")]`. Add the `tailwind.config.js` with `presets: [require("@jason/ui-kit/preset")]`. Add the
kit's `dist` to `content`. Import `@jason/ui-kit/styles.css` in your entry. kit's `dist` to `content`. Import `@jason/ui-kit/styles.css` in your entry.
Import `@jason/ui-kit/fonts.css` first when the app should use the shared fonts.
3. **Reconcile tokens.** If the app had different colors, either map them to the 3. **Reconcile tokens.** If the app had different colors, either map them to the
shared tokens or override the CSS variables in your local `globals.css` for a shared tokens or override the CSS variables in your local `globals.css` for a
per-app skin. Goal: one system, small per-app deltas. per-app skin. Goal: one system, small per-app deltas.
@@ -33,7 +34,9 @@ so they're the natural reference implementations:
components. Delete the local copies. Standardize on `cn()` and `toast()`. components. Delete the local copies. Standardize on `cn()` and `toast()`.
5. **Add depth + motion.** Wrap page sections in `FadeIn`/`Stagger`; use 5. **Add depth + motion.** Wrap page sections in `FadeIn`/`Stagger`; use
`SpotlightCard` for feature/stat cards; `AnimatedNumber` on dashboard metrics. `SpotlightCard` for feature/stat cards; `AnimatedNumber` on dashboard metrics.
6. **Add the shell.** Move dashboards onto `AppShell` + `CommandPalette` (⌘K). 6. **Add the shell and theme.** Wrap the app in `ThemeProvider`, place `ThemeScript`
before app CSS when SSR is involved, and move dashboards onto responsive
`AppShell` + `CommandPalette` (⌘K).
7. **Guard it.** `npx impeccable install` in the app, run `/impeccable init` once, 7. **Guard it.** `npx impeccable install` in the app, run `/impeccable init` once,
then `/impeccable audit` and `/impeccable polish` per screen. Add the detector then `/impeccable audit` and `/impeccable polish` per screen. Add the detector
to the app's CI (copy `.gitea/workflows/ci.yml`). to the app's CI (copy `.gitea/workflows/ci.yml`).
+31 -15
View File
@@ -9,12 +9,14 @@ generic AI-generated template.
## What's inside ## What's inside
- **Design tokens + Tailwind preset** — warm gold/dark brand, radius/shadow/motion scales, CSS-variable theming (dark default, `.light` opt-in), Montserrat / Open Sans / JetBrains Mono. - **Generated design tokens + Tailwind preset** — accessible dark/light palettes, semantic foregrounds, radius/shadow/motion scales, and a parity check that prevents CSS/TypeScript drift.
- **Theme runtime + optional self-hosted fonts** — persisted dark/light/system mode, a no-flash script, and Latin variable-font delivery.
- **`cn()`** helper (clsx + tailwind-merge) — the standard everywhere. - **`cn()`** helper (clsx + tailwind-merge) — the standard everywhere.
- **Primitives** — Button, Card, Input, Badge, Dialog, DropdownMenu, Table, Toaster (sonner). Radix-backed, accessible, pre-themed. - **Primitives** — Button, Card, FormField, Input, Select, Checkbox, Switch, Tabs, Tooltip, Badge, Dialog, DropdownMenu, Table, Pagination, Skeleton, and Toaster. Radix-backed, accessible, pre-themed.
- **Motion** — `FadeIn`, `Stagger` (framer/`motion`, reduced-motion aware). - **Motion** — `FadeIn`, `Stagger` (framer/`motion`, reduced-motion aware).
- **Animated** — `AnimatedNumber` (count-up stats), `SpotlightCard` (cursor glow). - **Animated** — `AnimatedNumber` (count-up stats), `SpotlightCard` (cursor glow).
- **Blocks** — `AppShell` (sidebar + topbar), `CommandPalette` (⌘K), `Hero`, `FeatureGrid`, `Pricing`. - **Blocks** — responsive `AppShell` (desktop sidebar + mobile drawer), `CommandPalette` (⌘K), `Hero`, `FeatureGrid`, `Pricing`.
- **Living showcase** — executable coverage of themes, states, product UI, and marketing blocks.
- **Impeccable** wired in — `DESIGN.md` / `PRODUCT.md` design context, config, and a CI anti-slop detector. - **Impeccable** wired in — `DESIGN.md` / `PRODUCT.md` design context, config, and a CI anti-slop detector.
## Install (in a consuming app) ## Install (in a consuming app)
@@ -51,34 +53,48 @@ module.exports = {
import "@jason/ui-kit/styles.css"; import "@jason/ui-kit/styles.css";
``` ```
Load the branded variable fonts as an opt-in asset:
```ts
import "@jason/ui-kit/fonts.css";
import "@jason/ui-kit/styles.css";
```
### Use it ### Use it
```tsx ```tsx
import { Button, Card, CardHeader, CardTitle, Hero, AnimatedNumber, Toaster, toast } from "@jason/ui-kit"; import {
Button, FormField, Input, ThemeProvider, ThemeScript, Toaster
} from "@jason/ui-kit";
<Hero <ThemeScript />
eyebrow="New" <ThemeProvider>
title="Ship tools that look designed" <FormField label="Workspace name" description="Shown in navigation.">
subtitle="One brand system across every app." <Input />
primaryCta={{ label: "Get started", href: "/signup" }} </FormField>
secondaryCta={{ label: "Docs", href: "/docs" }} <Button>Save</Button>
/> <Toaster />
</ThemeProvider>
``` ```
## Develop ## Develop
```bash ```bash
npm install npm ci
npm run dev # tsup watch build npm run dev # tsup watch build
npm run typecheck npm run demo # root demo.html using the real kit components
npm run design:detect # run the Impeccable anti-slop detector on src/ npm run showcase # living component review surface
npm run check # tokens, types, tests, builds, and design detector
``` ```
The portable project entry is [`demo.html`](./demo.html). Open
`http://localhost:5173/demo.html` after running `npm run demo`.
## Publish a new version ## Publish a new version
```bash ```bash
npm version patch # or minor / major npm version patch # or minor / major
npm publish # builds via prepublishOnly, pushes to Gitea registry npm publish # builds via prepare, pushes to Gitea registry
``` ```
## Extending with 21st.dev ## Extending with 21st.dev
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#232022" />
<meta
name="description"
content="Interactive demonstration of the @jason/ui-kit design system, components, themes, product shell, and marketing blocks."
/>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23232022'/%3E%3Ccircle cx='32' cy='32' r='12' fill='%23DCBB4F'/%3E%3C/svg%3E"
/>
<title>@jason/ui-kit — Interactive Demo</title>
<script>
(() => {
try {
const stored = localStorage.getItem("jason-ui-theme") || "system";
const mode =
stored === "system"
? matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark"
: stored;
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(mode);
root.style.colorScheme = mode;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", mode === "light" ? "#FAF8F4" : "#232022");
} catch {}
})();
</script>
</head>
<body>
<div id="root">
<noscript>
<main style="max-width: 40rem; margin: 4rem auto; padding: 1.5rem; font-family: system-ui, sans-serif;">
<h1>JavaScript is required</h1>
<p>This interactive UI kit demonstration uses React and needs JavaScript enabled.</p>
</main>
</noscript>
</div>
<script type="module" src="/showcase/main.tsx"></script>
</body>
</html>
+6201
View File
File diff suppressed because it is too large Load Diff
+48 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "@jason/ui-kit", "name": "@jason/ui-kit",
"version": "0.4.1", "version": "0.5.0",
"description": "Shared design foundation \u2014 branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.", "description": "Shared design foundation branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Jason <jason@messagepoint.tv>", "author": "Jason <jason@messagepoint.tv>",
@@ -18,7 +18,10 @@
"files": [ "files": [
"dist", "dist",
"tailwind-preset.cjs", "tailwind-preset.cjs",
"src/styles/globals.css", "src/lib/tokens.json",
"src/styles",
".npmrc.example",
"ADOPT.md",
"DESIGN.md", "DESIGN.md",
"PRODUCT.md", "PRODUCT.md",
"MIGRATION.md", "MIGRATION.md",
@@ -26,27 +29,47 @@
], ],
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/client/index.d.ts",
"import": "./dist/index.js" "import": "./dist/client/index.js"
},
"./tokens": {
"types": "./dist/tokens/tokens-entry.d.ts",
"import": "./dist/tokens/tokens-entry.js"
}, },
"./preset": "./tailwind-preset.cjs", "./preset": "./tailwind-preset.cjs",
"./styles.css": "./src/styles/globals.css" "./styles.css": "./src/styles/globals.css",
"./fonts.css": "./src/styles/fonts.css"
}, },
"main": "./dist/index.js", "main": "./dist/client/index.js",
"module": "./dist/index.js", "module": "./dist/client/index.js",
"types": "./dist/index.d.ts", "types": "./dist/client/index.d.ts",
"scripts": { "scripts": {
"build": "tsup", "tokens:generate": "node scripts/generate-theme.mjs",
"tokens:check": "node scripts/generate-theme.mjs --check",
"build": "npm run tokens:generate && node scripts/clean-dist.mjs && tsup && node scripts/mark-client-entry.mjs",
"dev": "tsup --watch", "dev": "tsup --watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run",
"demo": "vite --config vite.demo.config.ts",
"demo:build": "vite build --config vite.demo.config.ts",
"showcase": "vite --config showcase/vite.config.ts",
"showcase:build": "vite build --config showcase/vite.config.ts",
"design:detect": "impeccable detect src/", "design:detect": "impeccable detect src/",
"check": "npm run tokens:check && npm run typecheck && npm run test && npm run build && npm run demo:build && npm run showcase:build && npm run design:detect",
"prepare": "npm run build" "prepare": "npm run build"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/montserrat": "^5.3.0",
"@fontsource-variable/open-sans": "^5.3.0",
"@radix-ui/react-checkbox": "^1.3.9",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.3.3", "@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.3.5",
"@radix-ui/react-tabs": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.14",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
@@ -56,18 +79,29 @@
"tailwind-merge": "^2.6.0" "tailwind-merge": "^2.6.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": ">=18 <20",
"react-dom": ">=18", "react-dom": ">=18 <20",
"tailwindcss": ">=3.4" "tailwindcss": ">=3.4 <4"
},
"overrides": {
"esbuild": "^0.28.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.4",
"autoprefixer": "^10.5.4",
"axe-core": "^4.12.1",
"impeccable": "^3.2.1", "impeccable": "^3.2.1",
"jsdom": "^29.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"vite": "^8.1.5",
"vitest": "^4.1.10"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
import { rm } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
await rm(path.join(root, "dist"), { recursive: true, force: true });
+55
View File
@@ -0,0 +1,55 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const tokenPath = path.join(root, "src/lib/tokens.json");
const outputPath = path.join(root, "src/styles/theme.generated.css");
const source = JSON.parse(await readFile(tokenPath, "utf8"));
const variableName = (key) =>
`--${key
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([a-zA-Z])([0-9])/g, "$1-$2")
.toLowerCase()}`;
function hexToHsl(hex) {
const value = hex.replace("#", "");
const [r, g, b] = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16) / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
let hue = 0;
let saturation = 0;
if (max !== min) {
const delta = max - min;
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0);
if (max === g) hue = (b - r) / delta + 2;
if (max === b) hue = (r - g) / delta + 4;
hue /= 6;
}
return `${Math.round(hue * 360)} ${Math.round(saturation * 100)}% ${Math.round(lightness * 100)}%`;
}
function block(selector, colors) {
const variables = Object.entries(colors)
.map(([name, value]) => ` ${variableName(name)}: ${hexToHsl(value)};`)
.join("\n");
return `${selector} {\n${variables}\n}`;
}
const generated = `/* Generated by scripts/generate-theme.mjs from src/lib/tokens.json. */\n${block(":root, .dark", source.color.dark)}\n\n${block(".light", source.color.light)}\n`;
const check = process.argv.includes("--check");
if (check) {
const current = await readFile(outputPath, "utf8").catch(() => "");
if (current !== generated) {
console.error("Generated theme is stale. Run npm run tokens:generate.");
process.exit(1);
}
} else {
await writeFile(outputPath, generated);
}
+11
View File
@@ -0,0 +1,11 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entry = path.join(root, "dist/client/index.js");
const source = await readFile(entry, "utf8");
if (!source.startsWith('"use client";')) {
await writeFile(entry, `"use client";${source}`);
}
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="@jason/ui-kit living component showcase" />
<title>@jason/ui-kit — Component Showcase</title>
<script>
(() => {
try {
const stored = localStorage.getItem("jason-ui-theme") || "system";
const mode = stored === "system"
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
: stored;
document.documentElement.className = mode;
document.documentElement.style.colorScheme = mode;
} catch {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
import * as React from "react";
import { createRoot } from "react-dom/client";
import "../src/styles/fonts.css";
import "../src/styles/globals.css";
import { ThemeProvider } from "../src";
import { Showcase } from "./showcase";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<Showcase />
</ThemeProvider>
</React.StrictMode>
);
+254
View File
@@ -0,0 +1,254 @@
import * as React from "react";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
CommandPalette,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FeatureGrid,
FormField,
Hero,
Input,
Label,
Pagination,
Pricing,
Select,
Skeleton,
Spinner,
StatCard,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
Toaster,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
AppShell,
useTheme,
} from "../src";
import {
BarChart3,
Blocks,
CheckCircle2,
Command,
CreditCard,
LayoutDashboard,
Moon,
MoreHorizontal,
Palette,
Plus,
Search,
Settings,
Sparkles,
Sun,
Users,
} from "lucide-react";
type Page = "overview" | "components" | "marketing";
function Section({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-4">
<div>
<h2 className="font-display text-xl font-semibold">{title}</h2>
<p className="mt-1 max-w-2xl text-sm text-muted">{description}</p>
</div>
{children}
</section>
);
}
function ThemeButton() {
const { resolvedTheme, toggleTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" onClick={toggleTheme} aria-label="Toggle color theme">
{resolvedTheme === "dark" ? <Sun /> : <Moon />}
</Button>
</TooltipTrigger>
<TooltipContent>Use {resolvedTheme === "dark" ? "light" : "dark"} mode</TooltipContent>
</Tooltip>
);
}
function Overview() {
return (
<div className="space-y-10">
<div>
<Badge variant="brand">Living design system</Badge>
<h1 className="mt-3 font-display text-3xl font-bold tracking-tight sm:text-4xl">Warm, clear, dependable interfaces.</h1>
<p className="mt-3 max-w-2xl text-muted">A single place to review brand tokens, responsive behavior, interaction states, and accessibility before changes reach consuming apps.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="Components" animate={28} icon={<Blocks />} />
<StatCard label="Accessibility" value="AA" intent="success" sub="Contrast-gated themes" icon={<CheckCircle2 />} />
<StatCard label="Themes" value="3 modes" intent="brand" sub="Dark, light, and system" icon={<Palette />} />
<StatCard label="Build" value="Typed" intent="brand" sub="ESM and declarations" icon={<Sparkles />} />
</div>
<Section title="Operational surface" description="Representative dashboard density, controls, and data presentation.">
<Card>
<CardHeader className="flex-row items-start justify-between">
<div>
<CardTitle>Recent workspaces</CardTitle>
<CardDescription>Tables remain legible and horizontally scroll when space is constrained.</CardDescription>
</div>
<Button size="sm"><Plus />New workspace</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow><TableHead>Name</TableHead><TableHead>Owner</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Members</TableHead></TableRow>
</TableHeader>
<TableBody>
<TableRow><TableCell className="font-medium">Forge inventory</TableCell><TableCell>Jordan Lee</TableCell><TableCell><Badge variant="success">Healthy</Badge></TableCell><TableCell className="text-right">18</TableCell></TableRow>
<TableRow><TableCell className="font-medium">Field operations</TableCell><TableCell>Sam Rivera</TableCell><TableCell><Badge variant="warning">Review</Badge></TableCell><TableCell className="text-right">12</TableCell></TableRow>
<TableRow><TableCell className="font-medium">Quality portal</TableCell><TableCell>Alex Morgan</TableCell><TableCell><Badge>Draft</Badge></TableCell><TableCell className="text-right">7</TableCell></TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</Section>
</div>
);
}
function Components() {
const [notifications, setNotifications] = React.useState(true);
const [page, setPage] = React.useState(2);
return (
<div className="space-y-12">
<div>
<h1 className="font-display text-3xl font-bold tracking-tight">Components</h1>
<p className="mt-2 text-muted">Every core state in one executable review surface.</p>
</div>
<Section title="Actions and status" description="Variants share predictable sizing, focus, disabled, and semantic foreground behavior.">
<div className="flex flex-wrap gap-3">
<Button>Primary</Button><Button variant="secondary">Secondary</Button><Button variant="outline">Outline</Button><Button variant="ghost">Ghost</Button><Button variant="danger">Delete</Button><Button disabled>Disabled</Button><Spinner label="Saving…" />
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="brand">Brand</Badge><Badge>Neutral</Badge><Badge variant="success">Success</Badge><Badge variant="warning">Warning</Badge><Badge variant="danger">Danger</Badge><Badge variant="outline">Outline</Badge>
</div>
</Section>
<Section title="Forms" description="Field-level descriptions and errors are automatically associated with controls.">
<Card className="max-w-2xl">
<CardContent className="grid gap-5 pt-5 sm:grid-cols-2">
<FormField label="Workspace name" description="Shown in the primary navigation."><Input placeholder="Forge inventory" /></FormField>
<FormField label="Region" required>
<Select defaultValue="" placeholder="Choose a region">
<option value="">Not assigned</option>
<optgroup label="Americas"><option value="us">United States</option><option value="ca">Canada</option></optgroup>
<optgroup label="Europe"><option value="uk">United Kingdom</option></optgroup>
</Select>
</FormField>
<FormField label="Summary" error="Add at least 20 characters" className="sm:col-span-2"><Textarea placeholder="What is this workspace for?" /></FormField>
<Label className="mb-0 flex items-center gap-2 text-sm text-text"><Checkbox defaultChecked />Email weekly digest</Label>
<Label className="mb-0 flex items-center justify-between gap-3 text-sm text-text">Notifications<Switch checked={notifications} onCheckedChange={setNotifications} /></Label>
</CardContent>
</Card>
</Section>
<Section title="Disclosure and navigation" description="Keyboard-friendly Radix foundations with branded surfaces.">
<div className="flex flex-wrap gap-3">
<Dialog>
<DialogTrigger asChild><Button variant="outline">Open dialog</Button></DialogTrigger>
<DialogContent><DialogHeader><DialogTitle>Invite collaborators</DialogTitle><DialogDescription>Send a secure invitation to this workspace.</DialogDescription></DialogHeader><FormField label="Email"><Input type="email" placeholder="name@example.com" /></FormField><Button className="mt-5 w-full">Send invitation</Button></DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="outline">More actions<MoreHorizontal /></Button></DropdownMenuTrigger>
<DropdownMenuContent><DropdownMenuItem>Duplicate</DropdownMenuItem><DropdownMenuItem>Archive</DropdownMenuItem><DropdownMenuItem className="text-danger">Delete</DropdownMenuItem></DropdownMenuContent>
</DropdownMenu>
</div>
<Tabs defaultValue="details" className="max-w-2xl">
<TabsList><TabsTrigger value="details">Details</TabsTrigger><TabsTrigger value="activity">Activity</TabsTrigger><TabsTrigger value="settings">Settings</TabsTrigger></TabsList>
<TabsContent value="details"><Card><CardContent className="pt-5 text-sm text-muted">Overview content with a calm, raised surface.</CardContent></Card></TabsContent>
<TabsContent value="activity"><EmptyState title="No activity yet" hint="Changes will appear here." /></TabsContent>
<TabsContent value="settings"><div className="space-y-2"><Skeleton className="h-10 w-full" /><Skeleton className="h-24 w-full" /></div></TabsContent>
</Tabs>
<Pagination page={page} pageCount={6} onPageChange={setPage} className="max-w-xl" />
</Section>
</div>
);
}
function Marketing() {
return (
<div className="-m-4 sm:-m-6">
<Hero eyebrow="Shared foundation" title="Ship tools that look designed." subtitle="Product UI first, with a confident editorial brand layer when the moment calls for it." primaryCta={{ label: "Get started", href: "#pricing" }} secondaryCta={{ label: "View components", href: "#components" }} />
<FeatureGrid heading="Built for real work" features={[
{ icon: <Command />, title: "Fast by default", description: "Command-first patterns and predictable controls keep operators moving." },
{ icon: <Palette />, title: "One visual language", description: "Generated tokens keep product and marketing surfaces in sync." },
{ icon: <CheckCircle2 />, title: "Guarded quality", description: "Contrast, interaction, and packaging checks run before release." },
]} />
<div id="pricing">
<Pricing heading="Simple plans" tiers={[
{ name: "Starter", price: "$0", period: "month", features: ["One workspace", "Core components"], cta: { label: "Start free" } },
{ name: "Team", price: "$24", period: "month", featured: true, description: "For active operators", features: ["Unlimited workspaces", "Priority support", "Advanced blocks"], cta: { label: "Choose Team" } },
{ name: "Custom", price: "Lets talk", features: ["Brand adaptation", "Migration support"], cta: { label: "Contact us" } },
]} />
</div>
</div>
);
}
export function Showcase() {
const [page, setPage] = React.useState<Page>("overview");
const nav = [
{ label: "Overview", icon: <LayoutDashboard />, active: page === "overview", onClick: () => setPage("overview") },
{ label: "Components", icon: <Blocks />, active: page === "components", onClick: () => setPage("components") },
{ label: "Marketing", icon: <CreditCard />, active: page === "marketing", onClick: () => setPage("marketing") },
];
return (
<TooltipProvider delayDuration={250}>
<AppShell
brand={<span className="flex items-center gap-2"><span className="size-2 rounded-full bg-brand shadow-glow" />UI Kit</span>}
nav={nav}
topbar={<><div className="flex min-w-0 items-center gap-2 text-sm text-muted"><Search className="size-4" /><span className="truncate">Review components, themes, and responsive states</span></div><div className="ml-auto flex items-center gap-1"><Badge variant="outline">v0.5 preview</Badge><ThemeButton /></div></>}
footer={<div className="flex items-center gap-2"><Settings className="size-4" />Design settings</div>}
>
{page === "overview" && <Overview />}
{page === "components" && <Components />}
{page === "marketing" && <Marketing />}
</AppShell>
<CommandPalette actions={[
{ id: "overview", label: "Open overview", group: "Navigate", icon: <BarChart3 />, onSelect: () => setPage("overview") },
{ id: "components", label: "Open components", group: "Navigate", icon: <Blocks />, onSelect: () => setPage("components") },
{ id: "marketing", label: "Open marketing blocks", group: "Navigate", icon: <Sparkles />, onSelect: () => setPage("marketing") },
{ id: "users", label: "Invite collaborator", group: "Actions", icon: <Users />, onSelect: () => undefined },
]} />
<Toaster />
</TooltipProvider>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
plugins: [react()],
css: {
postcss: fileURLToPath(new URL("../postcss.config.cjs", import.meta.url)),
},
build: {
outDir: fileURLToPath(new URL("../dist-showcase", import.meta.url)),
emptyOutDir: true,
},
});
+9 -2
View File
@@ -1,10 +1,12 @@
import * as React from "react"; import * as React from "react";
import { useInView, useMotionValue, useSpring, useReducedMotion } from "motion/react"; import { useInView, useMotionValue, useSpring, useReducedMotion } from "motion/react";
const defaultFormat = (value: number) => Math.round(value).toLocaleString();
/** Count-up number for dashboards/stat cards. Springs to `value` when scrolled into view. */ /** Count-up number for dashboards/stat cards. Springs to `value` when scrolled into view. */
export function AnimatedNumber({ export function AnimatedNumber({
value, value,
format = (n) => Math.round(n).toLocaleString(), format = defaultFormat,
className, className,
}: { }: {
value: number; value: number;
@@ -25,5 +27,10 @@ export function AnimatedNumber({
React.useEffect(() => spring.on("change", (v) => setDisplay(format(v))), [spring, format]); React.useEffect(() => spring.on("change", (v) => setDisplay(format(v))), [spring, format]);
return <span ref={ref} className={className}>{display}</span>; return (
<span ref={ref} className={className}>
<span aria-hidden>{display}</span>
<span className="sr-only">{format(value)}</span>
</span>
);
} }
+17 -6
View File
@@ -10,26 +10,37 @@ export function SpotlightCard({
className?: string; className?: string;
}) { }) {
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState({ x: 50, y: 50, active: false }); const frame = React.useRef<number>();
const onMove = (e: React.MouseEvent<HTMLDivElement>) => { const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType !== "mouse") return;
const r = ref.current?.getBoundingClientRect(); const r = ref.current?.getBoundingClientRect();
if (!r) return; if (!r) return;
setPos({ x: ((e.clientX - r.left) / r.width) * 100, y: ((e.clientY - r.top) / r.height) * 100, active: true }); const x = ((e.clientX - r.left) / r.width) * 100;
const y = ((e.clientY - r.top) / r.height) * 100;
if (frame.current) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
ref.current?.style.setProperty("--spotlight-x", `${x}%`);
ref.current?.style.setProperty("--spotlight-y", `${y}%`);
});
}; };
React.useEffect(() => () => {
if (frame.current) cancelAnimationFrame(frame.current);
}, []);
return ( return (
<div <div
ref={ref} ref={ref}
onMouseMove={onMove} onPointerMove={onMove}
onMouseLeave={() => setPos((p) => ({ ...p, active: false }))}
className={cn("group relative overflow-hidden rounded-lg border border-border bg-surface p-6 shadow-sm transition-shadow duration-slow hover:shadow-md", className)} className={cn("group relative overflow-hidden rounded-lg border border-border bg-surface p-6 shadow-sm transition-shadow duration-slow hover:shadow-md", className)}
style={{ "--spotlight-x": "50%", "--spotlight-y": "50%" } as React.CSSProperties}
> >
<div <div
aria-hidden aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-slow group-hover:opacity-100" className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-slow group-hover:opacity-100"
style={{ style={{
background: `radial-gradient(400px circle at ${pos.x}% ${pos.y}%, hsl(var(--brand) / 0.10), transparent 60%)`, background: "radial-gradient(400px circle at var(--spotlight-x) var(--spotlight-y), hsl(var(--brand) / 0.10), transparent 60%)",
}} }}
/> />
<div className="relative">{children}</div> <div className="relative">{children}</div>
Executable → Regular
+109 -26
View File
@@ -1,4 +1,9 @@
"use client";
import * as React from "react"; import * as React from "react";
import { Menu } from "lucide-react";
import { Button } from "../components/button";
import { Dialog, DialogContent, DialogTitle } from "../components/dialog";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
export interface NavItem { export interface NavItem {
@@ -9,50 +14,128 @@ export interface NavItem {
onClick?: () => void; onClick?: () => void;
} }
/** Dashboard chrome: fixed sidebar + sticky topbar + scrolling content. */ function ShellNav({
items,
label,
onNavigate,
}: {
items: NavItem[];
label: string;
onNavigate?: () => void;
}) {
return (
<nav aria-label={label} className="flex-1 space-y-0.5 px-3 py-3">
{items.map((item) => {
const classes = cn(
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 [&_svg]:size-4 [&_svg]:shrink-0",
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
);
const content = <>{item.icon}<span className="truncate">{item.label}</span></>;
if (item.href) {
return (
<a
key={item.label}
href={item.href}
aria-current={item.active ? "page" : undefined}
onClick={() => {
item.onClick?.();
onNavigate?.();
}}
className={classes}
>
{content}
</a>
);
}
return (
<button
key={item.label}
type="button"
aria-current={item.active ? "page" : undefined}
onClick={() => {
item.onClick?.();
onNavigate?.();
}}
className={classes}
>
{content}
</button>
);
})}
</nav>
);
}
/** Responsive dashboard chrome with desktop navigation and a mobile drawer. */
export function AppShell({ export function AppShell({
brand, brand,
nav, nav,
topbar, topbar,
children, children,
footer, footer,
navLabel = "Primary navigation",
className,
mainClassName,
}: { }: {
brand: React.ReactNode; brand: React.ReactNode;
nav: NavItem[]; nav: NavItem[];
topbar?: React.ReactNode; topbar?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
footer?: React.ReactNode; footer?: React.ReactNode;
navLabel?: string;
className?: string;
mainClassName?: string;
}) { }) {
const [mobileOpen, setMobileOpen] = React.useState(false);
const sidebar = (mobile = false) => (
<>
<div className={cn("flex h-14 items-center px-5 font-display text-lg font-bold", mobile && "pr-12")}>
{brand}
</div>
<ShellNav items={nav} label={navLabel} onNavigate={mobile ? () => setMobileOpen(false) : undefined} />
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
</>
);
return ( return (
<div className="flex min-h-screen bg-bg text-text"> <div className={cn("flex min-h-dvh bg-bg text-text", className)}>
<a
href="#main-content"
className="fixed left-3 top-3 z-[70] -translate-y-20 rounded bg-brand px-3 py-2 text-sm font-semibold text-brand-foreground shadow-lg focus:translate-y-0"
>
Skip to content
</a>
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex"> <aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex">
<div className="flex h-14 items-center px-5 font-display text-lg font-bold">{brand}</div> {sidebar()}
<nav className="flex-1 space-y-0.5 px-3 py-3">
{nav.map((item) => {
const Comp = item.href ? "a" : "button";
return (
<Comp
key={item.label}
href={item.href}
onClick={item.onClick}
className={cn(
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast [&_svg]:size-4",
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
)}
>
{item.icon}
{item.label}
</Comp>
);
})}
</nav>
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
</aside> </aside>
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}>
<DialogContent className="inset-y-0 left-0 top-0 flex h-dvh w-[min(20rem,88vw)] max-w-none translate-x-0 translate-y-0 flex-col rounded-none border-y-0 border-l-0 p-0 md:hidden">
<DialogTitle className="sr-only">{navLabel}</DialogTitle>
{sidebar(true)}
</DialogContent>
</Dialog>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center justify-between gap-4 border-b border-border bg-bg/80 px-5 backdrop-blur"> <header className="sticky top-0 z-30 flex min-h-14 items-center gap-3 border-b border-border bg-bg/85 px-4 py-2 backdrop-blur md:px-5">
{topbar} <Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 md:hidden"
aria-label="Open navigation"
onClick={() => setMobileOpen(true)}
>
<Menu />
</Button>
<div className="min-w-0 flex-1">{topbar ?? <div className="font-display font-semibold md:hidden">{brand}</div>}</div>
</header> </header>
<main className="flex-1 p-6">{children}</main> <main id="main-content" tabIndex={-1} className={cn("flex-1 p-4 outline-none sm:p-6", mainClassName)}>
{children}
</main>
</div> </div>
</div> </div>
); );
+4 -2
View File
@@ -45,9 +45,11 @@ export function CommandPalette({
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
label="Command palette" label="Command palette"
overlayClassName="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
contentClassName="fixed left-1/2 top-[18vh] z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2"
className={cn( className={cn(
"fixed left-1/2 top-1/4 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden", "overflow-hidden rounded-lg border border-border bg-surface shadow-lg",
"rounded-lg border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in" "data-[state=open]:animate-scale-in"
)} )}
> >
<div className="flex items-center gap-2 border-b border-border px-4"> <div className="flex items-center gap-2 border-b border-border px-4">
+1 -1
View File
@@ -44,7 +44,7 @@ export function Hero({
)} )}
{(primaryCta || secondaryCta) && ( {(primaryCta || secondaryCta) && (
<FadeIn delay={0.15}> <FadeIn delay={0.15}>
<div className="mt-10 flex items-center justify-center gap-3"> <div className="mt-10 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
{primaryCta && ( {primaryCta && (
<Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}> <Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}>
{primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label} {primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label}
+2 -2
View File
@@ -11,11 +11,11 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
primary: "bg-brand text-bg hover:bg-brand-bright shadow-sm hover:shadow-glow", primary: "bg-brand text-brand-foreground hover:bg-brand-bright shadow-sm hover:shadow-glow",
secondary: "bg-surface-2 text-text hover:bg-surface-2/70 shadow-sm", secondary: "bg-surface-2 text-text hover:bg-surface-2/70 shadow-sm",
outline: "border border-border bg-transparent text-text hover:bg-surface-2/60", outline: "border border-border bg-transparent text-text hover:bg-surface-2/60",
ghost: "bg-transparent text-text hover:bg-surface-2/60", ghost: "bg-transparent text-text hover:bg-surface-2/60",
danger: "bg-danger text-bg hover:bg-danger/90 shadow-sm", danger: "bg-danger text-danger-foreground hover:bg-danger/90 shadow-sm",
link: "text-brand underline-offset-4 hover:underline", link: "text-brand underline-offset-4 hover:underline",
}, },
size: { size: {
+27
View File
@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../lib/cn";
export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer size-4 shrink-0 rounded-sm border border-border bg-bg/40 shadow-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
"disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = "Checkbox";
+1 -1
View File
@@ -32,7 +32,7 @@ export const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2", "fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2",
"rounded-lg border border-border bg-surface p-6 shadow-lg", "rounded-lg border border-border bg-surface p-6 shadow-lg",
"data-[state=open]:animate-scale-in", "data-[state=open]:animate-scale-in",
className className
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "../lib/cn";
import { Label } from "./label";
export interface FormFieldProps {
label: React.ReactNode;
children: React.ReactElement<{
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean;
}>;
id?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
className?: string;
}
/** Associates a control with its label, supporting text, and validation error. */
export function FormField({
label,
children,
id,
description,
error,
required,
className,
}: FormFieldProps) {
const generatedId = React.useId();
const controlId = id ?? children.props.id ?? `field-${generatedId}`;
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const describedBy = [children.props["aria-describedby"], descriptionId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("space-y-1.5", className)}>
<Label htmlFor={controlId} className="mb-0 text-sm text-text">
{label}
{required && <span className="ml-1 text-danger" aria-hidden>*</span>}
</Label>
{React.cloneElement(children, {
id: controlId,
"aria-describedby": describedBy,
"aria-invalid": Boolean(error) || children.props["aria-invalid"],
})}
{description && !error && <p id={descriptionId} className="text-xs text-muted">{description}</p>}
{error && <p id={errorId} className="text-xs font-medium text-danger">{error}</p>}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "./button";
import { cn } from "../lib/cn";
export interface PaginationProps {
page: number;
pageCount: number;
onPageChange: (page: number) => void;
className?: string;
label?: string;
}
export function Pagination({
page,
pageCount,
onPageChange,
className,
label = "Pagination",
}: PaginationProps) {
const current = Math.min(Math.max(1, page), Math.max(1, pageCount));
return (
<nav aria-label={label} className={cn("flex items-center justify-between gap-3", className)}>
<Button
type="button"
variant="outline"
size="sm"
disabled={current <= 1}
onClick={() => onPageChange(current - 1)}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted" aria-live="polite">
Page <strong className="font-semibold text-text">{current}</strong> of {Math.max(1, pageCount)}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={current >= pageCount}
onClick={() => onPageChange(current + 1)}
>
Next
<ChevronRight />
</Button>
</nav>
);
}
Executable → Regular
+102 -63
View File
@@ -1,23 +1,11 @@
"use client";
import * as React from "react"; import * as React from "react";
import * as RS from "@radix-ui/react-select"; import * as RS from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
/**
* Themed Select — a drop-in for a native <select>, but the open menu is a fully
* styleable popover (dark/gold), unlike the OS-drawn native dropdown.
*
* Keeps the native API on purpose so it swaps 1:1 with existing code:
* <Select value={x} onChange={(e) => setX(e.target.value)}>
* <option value="">None</option>
* <option value="a">A</option>
* </Select>
*
* Radix forbids empty-string item values, but apps use <option value=""> for
* real, selectable choices (e.g. "No procedure"). We map "" to a sentinel
* internally and translate back on change, so empty options stay selectable.
*/
const selectVariants = cva( const selectVariants = cva(
"inline-flex w-full items-center justify-between gap-2 rounded border border-border bg-bg/40 text-text " + "inline-flex w-full items-center justify-between gap-2 rounded border border-border bg-bg/40 text-text " +
"transition-shadow duration-base ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 " + "transition-shadow duration-base ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 " +
@@ -29,61 +17,101 @@ const selectVariants = cva(
); );
const EMPTY = "__ui_select_empty__"; const EMPTY = "__ui_select_empty__";
const toRadix = (v?: string) => (v === "" ? EMPTY : v);
const fromRadix = (v: string) => (v === EMPTY ? "" : v);
type OptionData = { value: string; label: React.ReactNode; disabled?: boolean }; type OptionData = { value: string; label: React.ReactNode; disabled?: boolean };
type OptionGroup = { label?: React.ReactNode; options: OptionData[] };
function collectOptions(children: React.ReactNode): OptionData[] { function collectOptions(children: React.ReactNode): OptionGroup[] {
const out: OptionData[] = []; const groups: OptionGroup[] = [];
const walk = (nodes: React.ReactNode) => const ungrouped: OptionData[] = [];
React.Children.forEach(nodes, (child) => {
if (!React.isValidElement(child)) return; React.Children.forEach(children, (child) => {
if (child.type === "option") { if (!React.isValidElement(child)) return;
const p = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean }; if (child.type === "option") {
out.push({ value: String(p.value ?? ""), label: p.children, disabled: p.disabled }); const props = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
} else if (child.type === "optgroup") { ungrouped.push({ value: String(props.value ?? ""), label: props.children, disabled: props.disabled });
walk((child.props as { children?: React.ReactNode }).children); return;
} }
}); if (child.type === "optgroup") {
walk(children); const props = child.props as { label?: React.ReactNode; children?: React.ReactNode };
return out; const options: OptionData[] = [];
React.Children.forEach(props.children, (option) => {
if (!React.isValidElement(option) || option.type !== "option") return;
const optionProps = option.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
options.push({
value: String(optionProps.value ?? ""),
label: optionProps.children,
disabled: optionProps.disabled,
});
});
groups.push({ label: props.label, options });
}
});
return ungrouped.length ? [{ options: ungrouped }, ...groups] : groups;
} }
export interface SelectProps extends VariantProps<typeof selectVariants> { type RootProps = React.ComponentPropsWithoutRef<typeof RS.Root>;
type TriggerProps = React.ComponentPropsWithoutRef<typeof RS.Trigger>;
export interface SelectProps
extends VariantProps<typeof selectVariants>,
Omit<RootProps, "children" | "value" | "defaultValue" | "onValueChange"> {
value?: string; value?: string;
defaultValue?: string; defaultValue?: string;
onChange?: (e: { target: { value: string } }) => void;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
disabled?: boolean; /** Compatibility callback for native-select migrations. Prefer onValueChange. */
name?: string; onChange?: (event: { target: { value: string } }) => void;
placeholder?: string; placeholder?: React.ReactNode;
className?: string; className?: string;
contentClassName?: string;
triggerProps?: Omit<TriggerProps, "children" | "className">;
children?: React.ReactNode; children?: React.ReactNode;
"aria-label"?: string;
id?: string; id?: string;
} }
export const Select = React.forwardRef<HTMLButtonElement, SelectProps>( export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
( (
{ value, defaultValue, onChange, onValueChange, disabled, name, placeholder, size, className, children, id, ...rest }, {
value,
defaultValue,
onChange,
onValueChange,
placeholder,
size,
className,
contentClassName,
triggerProps,
children,
id,
...rootProps
},
ref ref
) => { ) => {
const options = collectOptions(children); const groups = collectOptions(children);
const handle = (v: string) => { const values = groups.flatMap((group) => group.options.map((option) => option.value));
const real = fromRadix(v); const emptySentinel = values.includes(EMPTY) ? `${EMPTY}_fallback` : EMPTY;
onValueChange?.(real); const toRadix = (next?: string) => (next === "" ? emptySentinel : next);
onChange?.({ target: { value: real } }); const fromRadix = (next: string) => (next === emptySentinel ? "" : next);
const handleValueChange = (next: string) => {
const realValue = fromRadix(next);
onValueChange?.(realValue);
onChange?.({ target: { value: realValue } });
}; };
return ( return (
<RS.Root <RS.Root
{...rootProps}
value={value === undefined ? undefined : toRadix(value)} value={value === undefined ? undefined : toRadix(value)}
defaultValue={defaultValue === undefined ? undefined : toRadix(defaultValue)} defaultValue={defaultValue === undefined ? undefined : toRadix(defaultValue)}
onValueChange={handle} onValueChange={handleValueChange}
disabled={disabled}
name={name}
> >
<RS.Trigger ref={ref} id={id} aria-label={rest["aria-label"]} className={cn(selectVariants({ size }), className)}> <RS.Trigger
{...triggerProps}
ref={ref}
id={id}
className={cn(selectVariants({ size }), className)}
>
<span className="min-w-0 truncate text-left"> <span className="min-w-0 truncate text-left">
<RS.Value placeholder={placeholder} /> <RS.Value placeholder={placeholder} />
</span> </span>
@@ -97,26 +125,36 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
sideOffset={6} sideOffset={6}
className={cn( className={cn(
"z-50 max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)]", "z-50 max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)]",
"overflow-hidden rounded-md border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in" "overflow-hidden rounded-md border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in",
contentClassName
)} )}
> >
<RS.Viewport className="p-1"> <RS.Viewport className="p-1">
{options.map((o, i) => ( {groups.map((group, groupIndex) => (
<RS.Item <RS.Group key={groupIndex}>
key={`${o.value}-${i}`} {group.label && (
value={toRadix(o.value)!} <RS.Label className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
disabled={o.disabled} {group.label}
className={cn( </RS.Label>
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
"data-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
)} )}
> {group.options.map((option, optionIndex) => (
<RS.ItemText>{o.label}</RS.ItemText> <RS.Item
<RS.ItemIndicator className="absolute right-2 inline-flex"> key={`${option.value}-${optionIndex}`}
<Check className="size-4" /> value={toRadix(option.value)!}
</RS.ItemIndicator> disabled={option.disabled}
</RS.Item> className={cn(
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
"data-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
)}
>
<RS.ItemText>{option.label}</RS.ItemText>
<RS.ItemIndicator className="absolute right-2 inline-flex">
<Check className="size-4" />
</RS.ItemIndicator>
</RS.Item>
))}
</RS.Group>
))} ))}
</RS.Viewport> </RS.Viewport>
</RS.Content> </RS.Content>
@@ -126,4 +164,5 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
} }
); );
Select.displayName = "Select"; Select.displayName = "Select";
export { selectVariants }; export { selectVariants };
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "../lib/cn";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden
className={cn(
"animate-pulse rounded bg-surface-2",
"motion-reduce:animate-none",
className
)}
{...props}
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "../lib/cn";
export const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-border bg-surface-2 p-0.5 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:border-brand data-[state=checked]:bg-brand",
className
)}
{...props}
>
<SwitchPrimitive.Thumb className="block size-5 rounded-full bg-text shadow-sm transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:bg-brand-foreground" />
</SwitchPrimitive.Root>
));
Switch.displayName = "Switch";
+40
View File
@@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../lib/cn";
export const Tabs = TabsPrimitive.Root;
export const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List ref={ref} className={cn("inline-flex min-h-10 items-center rounded bg-surface-2 p-1", className)} {...props} />
));
TabsList.displayName = "TabsList";
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex min-h-8 items-center justify-center rounded-sm px-3 text-sm font-medium text-muted transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
export const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn("mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60", className)} {...props} />
));
TabsContent.displayName = "TabsContent";
+21 -15
View File
@@ -1,20 +1,26 @@
import { Toaster as Sonner, toast } from "sonner"; import { Toaster as Sonner, toast } from "sonner";
import { useOptionalTheme } from "../lib/theme";
/** Pre-themed sonner Toaster. Drop <Toaster /> once near your app root. */ /** Pre-themed sonner Toaster. Drop <Toaster /> once near your app root. */
export const Toaster = (props: React.ComponentProps<typeof Sonner>) => ( export const Toaster = ({ toastOptions, ...props }: React.ComponentProps<typeof Sonner>) => {
<Sonner const theme = useOptionalTheme();
theme="dark" return (
position="bottom-right" <Sonner
toastOptions={{ theme={theme?.resolvedTheme ?? "system"}
classNames: { position="bottom-right"
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg", toastOptions={{
description: "!text-muted", ...toastOptions,
actionButton: "!bg-brand !text-bg", classNames: {
cancelButton: "!bg-surface-2 !text-muted", toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
}, description: "!text-muted",
}} actionButton: "!bg-brand !text-brand-foreground",
{...props} cancelButton: "!bg-surface-2 !text-muted",
/> ...toastOptions?.classNames,
); },
}}
{...props}
/>
);
};
export { toast }; export { toast };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "../lib/cn";
export const TooltipProvider = TooltipPrimitive.Provider;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-w-xs rounded bg-text px-2.5 py-1.5 text-xs font-medium text-bg shadow-md",
"data-[state=delayed-open]:animate-fade-in",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = "TooltipContent";
+16 -1
View File
@@ -1,7 +1,15 @@
// Utilities // Utilities
export { cn } from "./lib/cn"; export { cn } from "./lib/cn";
export { tokens, type Tokens } from "./lib/tokens"; export { tokens, type Tokens } from "./lib/tokens";
export { setTheme, toggleTheme } from "./lib/theme"; export {
ThemeProvider,
ThemeScript,
setTheme,
toggleTheme,
useTheme,
type Theme,
type ResolvedTheme,
} from "./lib/theme";
// Primitives // Primitives
export { Button, buttonVariants, type ButtonProps } from "./components/button"; export { Button, buttonVariants, type ButtonProps } from "./components/button";
@@ -10,6 +18,13 @@ export { Input, inputVariants, type InputProps } from "./components/input";
export { Select, selectVariants, type SelectProps } from "./components/select"; export { Select, selectVariants, type SelectProps } from "./components/select";
export { Textarea, type TextareaProps } from "./components/textarea"; export { Textarea, type TextareaProps } from "./components/textarea";
export { Label } from "./components/label"; export { Label } from "./components/label";
export { FormField, type FormFieldProps } from "./components/form-field";
export { Checkbox } from "./components/checkbox";
export { Switch } from "./components/switch";
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/tabs";
export { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "./components/tooltip";
export { Skeleton } from "./components/skeleton";
export { Pagination, type PaginationProps } from "./components/pagination";
export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; export { Badge, badgeVariants, type BadgeProps } from "./components/badge";
export { export {
Dialog, DialogTrigger, DialogClose, DialogContent, Dialog, DialogTrigger, DialogClose, DialogContent,
-9
View File
@@ -1,9 +0,0 @@
/** Toggle the .light class on <html>. Dark is the default (:root). */
export function setTheme(mode: "dark" | "light") {
const el = document.documentElement;
el.classList.toggle("light", mode === "light");
}
export function toggleTheme() {
const isLight = document.documentElement.classList.toggle("light");
return isLight ? "light" : "dark";
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import * as React from "react";
export type Theme = "dark" | "light" | "system";
export type ResolvedTheme = Exclude<Theme, "system">;
const DEFAULT_STORAGE_KEY = "jason-ui-theme";
const mediaQuery = "(prefers-color-scheme: light)";
function systemTheme(): ResolvedTheme {
return typeof window !== "undefined" && window.matchMedia(mediaQuery).matches ? "light" : "dark";
}
function resolveTheme(theme: Theme): ResolvedTheme {
return theme === "system" ? systemTheme() : theme;
}
/** Apply a theme immediately. For persisted React state, use ThemeProvider. */
export function setTheme(theme: Theme): ResolvedTheme {
const resolved = resolveTheme(theme);
if (typeof document === "undefined") return resolved;
const root = document.documentElement;
root.classList.toggle("light", resolved === "light");
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
return resolved;
}
/** Toggle the currently rendered mode. */
export function toggleTheme(): ResolvedTheme {
const current = typeof document !== "undefined" && document.documentElement.classList.contains("light");
return setTheme(current ? "dark" : "light");
}
export interface ThemeContextValue {
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
export function useOptionalTheme() {
return React.useContext(ThemeContext);
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = DEFAULT_STORAGE_KEY,
}: {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}) {
const [theme, updateTheme] = React.useState<Theme>(defaultTheme);
const [resolvedTheme, updateResolvedTheme] = React.useState<ResolvedTheme>(() => resolveTheme(defaultTheme));
React.useEffect(() => {
const stored = window.localStorage.getItem(storageKey);
if (stored === "dark" || stored === "light" || stored === "system") updateTheme(stored);
}, [storageKey]);
React.useEffect(() => {
const query = window.matchMedia(mediaQuery);
const apply = () => updateResolvedTheme(setTheme(theme));
apply();
if (theme !== "system") return;
query.addEventListener("change", apply);
return () => query.removeEventListener("change", apply);
}, [theme]);
const changeTheme = React.useCallback((next: Theme) => {
window.localStorage.setItem(storageKey, next);
updateTheme(next);
}, [storageKey]);
const toggle = React.useCallback(() => {
changeTheme(resolvedTheme === "light" ? "dark" : "light");
}, [changeTheme, resolvedTheme]);
const value = React.useMemo(
() => ({ theme, resolvedTheme, setTheme: changeTheme, toggleTheme: toggle }),
[changeTheme, resolvedTheme, theme, toggle]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const context = useOptionalTheme();
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
return context;
}
/** Inline this before app CSS to avoid a light/dark flash during hydration. */
export function ThemeScript({
defaultTheme = "system",
storageKey = DEFAULT_STORAGE_KEY,
}: {
defaultTheme?: Theme;
storageKey?: string;
}) {
const script = `(()=>{try{const k=${JSON.stringify(storageKey)},d=${JSON.stringify(defaultTheme)},s=localStorage.getItem(k)||d,m=s==="system"?(matchMedia(${JSON.stringify(mediaQuery)}).matches?"light":"dark"):s,e=document.documentElement;e.classList.remove("light","dark");e.classList.add(m);e.style.colorScheme=m}catch{}})();`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
+70
View File
@@ -0,0 +1,70 @@
{
"color": {
"dark": {
"bg": "#232022",
"surface": "#2B282A",
"surface2": "#333031",
"border": "#3A3638",
"text": "#F5F1EC",
"muted": "#A99F97",
"brand": "#DCBB4F",
"brandBright": "#F5CD15",
"brandDark": "#998643",
"brandForeground": "#232022",
"success": "#62C890",
"successForeground": "#232022",
"warning": "#E4C85B",
"warningForeground": "#232022",
"danger": "#D76F4A",
"dangerForeground": "#232022",
"info": "#76A9D6",
"infoForeground": "#232022"
},
"light": {
"bg": "#FAF8F4",
"surface": "#FFFFFF",
"surface2": "#F4F0EA",
"border": "#DED8D1",
"text": "#272326",
"muted": "#665F62",
"brand": "#79640E",
"brandBright": "#665308",
"brandDark": "#5C4B07",
"brandForeground": "#FFFFFF",
"success": "#217A4A",
"successForeground": "#FFFFFF",
"warning": "#7A5B00",
"warningForeground": "#FFFFFF",
"danger": "#9B3D22",
"dangerForeground": "#FFFFFF",
"info": "#366B99",
"infoForeground": "#FFFFFF"
}
},
"radius": {
"sm": "6px",
"md": "10px",
"lg": "14px",
"xl": "20px"
},
"font": {
"display": "\"Montserrat Variable\", \"Montserrat\", system-ui, sans-serif",
"sans": "\"Open Sans Variable\", \"Open Sans\", system-ui, sans-serif",
"mono": "\"JetBrains Mono Variable\", \"JetBrains Mono\", ui-monospace, monospace"
},
"shadow": {
"sm": "0 1px 2px 0 rgb(20 16 18 / 0.20)",
"md": "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
"lg": "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
"glow": "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)"
},
"ease": {
"out": "cubic-bezier(0.22, 1, 0.36, 1)",
"inOut": "cubic-bezier(0.65, 0, 0.35, 1)"
},
"duration": {
"fast": "120ms",
"base": "180ms",
"slow": "320ms"
}
}
+7 -42
View File
@@ -1,46 +1,11 @@
/** import source from "./tokens.json";
* Design tokens — the single source of truth for the brand.
* These mirror the CSS variables in styles/globals.css and feed the Tailwind preset. /** Canonical brand values are maintained in tokens.json. */
* Change the brand once, here, and every app that consumes the preset updates.
*/
export const tokens = { export const tokens = {
color: { ...source,
// Neutrals are tinted (warm), never pure black/gray — an Impeccable anti-slop rule. /** Backward-compatible default palette. Prefer themes for mode-aware work. */
bg: "#232022", color: source.color.dark,
surface: "#2b282a", themes: source.color,
surface2: "#333031",
border: "#3a3638",
text: "#F5F1EC",
muted: "#A99F97",
// Brand — warm gold on warm dark.
brand: "#DCBB4F",
brandBright: "#F5CD15",
brandDark: "#998643",
// Semantic
success: "#3F9E6A",
warning: "#DCBB4F",
danger: "#C0562F",
info: "#5B8DB8",
},
radius: { sm: "6px", md: "10px", lg: "14px", xl: "20px" },
font: {
display: '"Montserrat", system-ui, sans-serif',
sans: '"Open Sans", system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
},
// Layered, tinted shadows — depth instead of a flat plane.
shadow: {
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)",
md: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
lg: "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
glow: "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)",
},
// Motion — smooth, never bouncy/elastic (Impeccable anti-slop rule).
ease: {
out: "cubic-bezier(0.22, 1, 0.36, 1)",
inOut: "cubic-bezier(0.65, 0, 0.35, 1)",
},
duration: { fast: "120ms", base: "180ms", slow: "320ms" },
} as const; } as const;
export type Tokens = typeof tokens; export type Tokens = typeof tokens;
+6 -4
View File
@@ -22,7 +22,7 @@ export function FadeIn({
initial={reduce ? false : { opacity: 0, y }} initial={reduce ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10% 0px" }} viewport={{ once: true, margin: "-10% 0px" }}
transition={{ duration: 0.4, ease: easeOut, delay }} transition={reduce ? { duration: 0 } : { duration: 0.4, ease: easeOut, delay }}
> >
{children} {children}
</motion.div> </motion.div>
@@ -40,11 +40,12 @@ export const staggerItem: Variants = {
}; };
export function Stagger({ children, className }: { children: React.ReactNode; className?: string }) { export function Stagger({ children, className }: { children: React.ReactNode; className?: string }) {
const reduce = useReducedMotion();
return ( return (
<motion.div <motion.div
className={className} className={className}
variants={staggerContainer} variants={reduce ? undefined : staggerContainer}
initial="hidden" initial={reduce ? false : "hidden"}
whileInView="show" whileInView="show"
viewport={{ once: true, margin: "-10% 0px" }} viewport={{ once: true, margin: "-10% 0px" }}
> >
@@ -53,7 +54,8 @@ export function Stagger({ children, className }: { children: React.ReactNode; cl
); );
} }
export function StaggerItem({ children, className }: { children: React.ReactNode; className?: string }) { export function StaggerItem({ children, className }: { children: React.ReactNode; className?: string }) {
return <motion.div className={className} variants={staggerItem}>{children}</motion.div>; const reduce = useReducedMotion();
return <motion.div className={className} variants={reduce ? undefined : staggerItem}>{children}</motion.div>;
} }
export { motion }; export { motion };
+24
View File
@@ -0,0 +1,24 @@
/* Optional, self-hosted Latin variable fonts. Import before styles.css. */
@font-face {
font-family: "Montserrat Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("@fontsource-variable/montserrat/files/montserrat-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "Open Sans Variable";
font-style: normal;
font-display: swap;
font-weight: 300 800;
src: url("@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "JetBrains Mono Variable";
font-style: normal;
font-display: swap;
font-weight: 100 800;
src: url("@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
}
+16 -32
View File
@@ -1,52 +1,36 @@
/* Import once in your app entry: import "@jason/ui-kit/styles.css"; */ /* Import once in your app entry: import "@jason/ui-kit/styles.css"; */
@import "./theme.generated.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/*
* Brand tokens as HSL channels so Tailwind's <alpha-value> works.
* :root = dark (default brand). .light overrides for light surfaces.
*/
@layer base { @layer base {
:root {
--bg: 330 4% 13%;
--surface: 330 4% 16%;
--surface-2: 330 3% 20%;
--border: 330 3% 22%;
--text: 33 33% 94%;
--muted: 28 9% 63%;
--brand: 46 68% 59%;
--brand-bright: 51 91% 52%;
--brand-dark: 46 40% 43%;
--success: 146 43% 43%;
--warning: 46 68% 59%;
--danger: 17 61% 47%;
--info: 205 37% 54%;
}
.light {
--bg: 36 33% 97%;
--surface: 0 0% 100%;
--surface-2: 36 20% 95%;
--border: 33 12% 86%;
--text: 330 6% 15%;
--muted: 330 4% 40%;
}
* { border-color: hsl(var(--border)); } * { border-color: hsl(var(--border)); }
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
body { body {
background-color: hsl(var(--bg)); background-color: hsl(var(--bg));
color: hsl(var(--text)); color: hsl(var(--text));
font-family: "Open Sans", system-ui, sans-serif; font-family: "Open Sans Variable", "Open Sans", system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
h1, h2, h3, h4, h5 { font-family: "Montserrat", system-ui, sans-serif; } h1, h2, h3, h4, h5, h6 {
font-family: "Montserrat Variable", "Montserrat", system-ui, sans-serif;
text-wrap: balance;
}
/* Respect reduced-motion everywhere. */ /* Respect reduced-motion everywhere. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
animation-delay: 0s !important;
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
+42
View File
@@ -0,0 +1,42 @@
/* Generated by scripts/generate-theme.mjs from src/lib/tokens.json. */
:root, .dark {
--bg: 320 4% 13%;
--surface: 320 4% 16%;
--surface-2: 340 3% 19%;
--border: 330 4% 22%;
--text: 33 31% 94%;
--muted: 27 9% 63%;
--brand: 46 67% 59%;
--brand-bright: 49 92% 52%;
--brand-dark: 47 39% 43%;
--brand-foreground: 320 4% 13%;
--success: 147 48% 58%;
--success-foreground: 320 4% 13%;
--warning: 48 72% 63%;
--warning-foreground: 320 4% 13%;
--danger: 16 64% 57%;
--danger-foreground: 320 4% 13%;
--info: 208 54% 65%;
--info-foreground: 320 4% 13%;
}
.light {
--bg: 40 37% 97%;
--surface: 0 0% 100%;
--surface-2: 36 31% 94%;
--border: 32 16% 85%;
--text: 315 5% 15%;
--muted: 334 4% 39%;
--brand: 48 79% 26%;
--brand-bright: 48 85% 22%;
--brand-dark: 48 86% 19%;
--brand-foreground: 0 0% 100%;
--success: 148 57% 30%;
--success-foreground: 0 0% 100%;
--warning: 45 100% 24%;
--warning-foreground: 0 0% 100%;
--danger: 13 64% 37%;
--danger-foreground: 0 0% 100%;
--info: 208 48% 41%;
--info-foreground: 0 0% 100%;
}
+1
View File
@@ -0,0 +1 @@
export { tokens, type Tokens } from "./lib/tokens";
+35 -17
View File
@@ -12,7 +12,9 @@
* Colors are driven by CSS variables (see styles/globals.css) so light/dark and * Colors are driven by CSS variables (see styles/globals.css) so light/dark and
* per-app rebranding work without recompiling the preset. * per-app rebranding work without recompiling the preset.
*/ */
const tokens = require("./src/lib/tokens.json");
const v = (name) => `hsl(var(${name}) / <alpha-value>)`; const v = (name) => `hsl(var(${name}) / <alpha-value>)`;
const family = (value) => value.split(",").map((part) => part.trim().replace(/^"|"$/g, ""));
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
@@ -28,30 +30,46 @@ module.exports = {
ring: v("--brand"), ring: v("--brand"),
text: v("--text"), text: v("--text"),
muted: v("--muted"), muted: v("--muted"),
brand: { DEFAULT: v("--brand"), bright: v("--brand-bright"), dark: v("--brand-dark") }, brand: {
success: v("--success"), DEFAULT: v("--brand"),
warning: v("--warning"), bright: v("--brand-bright"),
danger: v("--danger"), dark: v("--brand-dark"),
info: v("--info"), foreground: v("--brand-foreground"),
},
success: { DEFAULT: v("--success"), foreground: v("--success-foreground") },
warning: { DEFAULT: v("--warning"), foreground: v("--warning-foreground") },
danger: { DEFAULT: v("--danger"), foreground: v("--danger-foreground") },
info: { DEFAULT: v("--info"), foreground: v("--info-foreground") },
}, },
fontFamily: { fontFamily: {
display: ['"Montserrat"', "system-ui", "sans-serif"], display: family(tokens.font.display),
sans: ['"Open Sans"', "system-ui", "sans-serif"], sans: family(tokens.font.sans),
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], mono: family(tokens.font.mono),
},
borderRadius: {
sm: tokens.radius.sm,
DEFAULT: tokens.radius.md,
md: tokens.radius.md,
lg: tokens.radius.lg,
xl: tokens.radius.xl,
}, },
borderRadius: { sm: "6px", DEFAULT: "10px", md: "10px", lg: "14px", xl: "20px" },
boxShadow: { boxShadow: {
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)", sm: tokens.shadow.sm,
DEFAULT: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)", DEFAULT: tokens.shadow.md,
md: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)", md: tokens.shadow.md,
lg: "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)", lg: tokens.shadow.lg,
glow: "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)", glow: tokens.shadow.glow,
}, },
transitionTimingFunction: { transitionTimingFunction: {
out: "cubic-bezier(0.22, 1, 0.36, 1)", out: tokens.ease.out,
"in-out": "cubic-bezier(0.65, 0, 0.35, 1)", "in-out": tokens.ease.inOut,
},
transitionDuration: {
fast: tokens.duration.fast,
DEFAULT: tokens.duration.base,
base: tokens.duration.base,
slow: tokens.duration.slow,
}, },
transitionDuration: { fast: "120ms", DEFAULT: "180ms", base: "180ms", slow: "320ms" },
keyframes: { keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } }, "fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"fade-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } }, "fade-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
presets: [require("./tailwind-preset.cjs")],
content: ["./src/**/*.{ts,tsx}", "./showcase/**/*.{html,ts,tsx}"],
};
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react";
import axe from "axe-core";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
AppShell,
Button,
FormField,
Input,
ThemeProvider,
useTheme,
} from "../src";
function ThemeControl() {
const { resolvedTheme, toggleTheme } = useTheme();
return <Button onClick={toggleTheme}>Current: {resolvedTheme}</Button>;
}
describe("core UI contracts", () => {
it("persists and applies theme changes", () => {
render(<ThemeProvider defaultTheme="dark"><ThemeControl /></ThemeProvider>);
fireEvent.click(screen.getByRole("button", { name: "Current: dark" }));
expect(document.documentElement).toHaveClass("light");
expect(window.localStorage.getItem("jason-ui-theme")).toBe("light");
});
it("associates form help and errors with the control", () => {
render(
<FormField label="Workspace name" description="Used in navigation" error="Name is required">
<Input />
</FormField>
);
const input = screen.getByLabelText("Workspace name");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input.getAttribute("aria-describedby")).toContain("-error");
});
it("provides mobile navigation and current-page semantics", () => {
const onClick = vi.fn();
render(
<AppShell
brand="Acme"
nav={[{ label: "Dashboard", active: true, onClick }]}
>
Content
</AppShell>
);
fireEvent.click(screen.getByRole("button", { name: "Open navigation" }));
const current = screen.getAllByRole("button", { name: "Dashboard" }).at(-1)!;
expect(current).toHaveAttribute("aria-current", "page");
fireEvent.click(current);
expect(onClick).toHaveBeenCalledOnce();
});
it("has no serious accessibility violations in representative primitives", async () => {
const { container } = render(
<main>
<FormField label="Email" description="We never share it."><Input type="email" /></FormField>
<Button>Save</Button>
</main>
);
const results = await axe.run(container, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] } });
expect(results.violations.filter((violation) => violation.impact === "serious" || violation.impact === "critical")).toEqual([]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
globalThis.requestAnimationFrame = (callback) => window.setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (handle) => window.clearTimeout(handle);
HTMLCanvasElement.prototype.getContext = vi.fn();
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock;
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import source from "../src/lib/tokens.json";
function luminance(hex: string) {
const channels = hex.match(/[a-f\d]{2}/gi)!.map((value) => parseInt(value, 16) / 255);
const linear = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrast(foreground: string, background: string) {
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
return (values[0] + 0.05) / (values[1] + 0.05);
}
describe("brand tokens", () => {
it.each(["dark", "light"] as const)("%s theme meets normal-text contrast targets", (mode) => {
const colors = source.color[mode];
expect(contrast(colors.text, colors.bg)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.muted, colors.bg)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.brandForeground, colors.brand)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.successForeground, colors.success)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.warningForeground, colors.warning)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.dangerForeground, colors.danger)).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.infoForeground, colors.info)).toBeGreaterThanOrEqual(4.5);
});
it("keeps generated CSS synchronized with the canonical source", () => {
const generated = readFileSync(path.resolve("src/styles/theme.generated.css"), "utf8");
expect(generated).toContain("--brand-foreground:");
expect(generated).toContain(":root, .dark");
expect(generated).toContain(".light");
});
});
+1
View File
@@ -5,6 +5,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"resolveJsonModule": true,
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"esModuleInterop": true, "esModuleInterop": true,
Executable → Regular
+17 -5
View File
@@ -1,11 +1,23 @@
import { defineConfig } from "tsup"; import { defineConfig, type Options } from "tsup";
export default defineConfig({ const shared: Options = {
entry: ["src/index.ts"],
format: ["esm"], format: ["esm"],
dts: true, dts: true,
sourcemap: true, sourcemap: true,
clean: true, clean: false,
treeshake: true, treeshake: true,
external: ["react", "react-dom", "tailwindcss"], external: ["react", "react-dom", "tailwindcss"],
}); };
export default defineConfig([
{
...shared,
entry: ["src/index.ts"],
outDir: "dist/client",
},
{
...shared,
entry: ["src/tokens-entry.ts"],
outDir: "dist/tokens",
},
]);
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
css: {
postcss: fileURLToPath(new URL("./postcss.config.cjs", import.meta.url)),
},
build: {
outDir: "dist-demo",
emptyOutDir: true,
rollupOptions: {
input: fileURLToPath(new URL("./demo.html", import.meta.url)),
},
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
css: false,
},
});