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
with:
node-version: "22"
- run: npm install --no-audit --no-fund
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Impeccable design detector (anti-slop)
run: npx impeccable detect src/ --json || true
- run: npm ci --no-audit --no-fund
- name: Full library verification
run: npm run check
- name: Published package smoke test
run: npm pack --dry-run
+2
View File
@@ -1,5 +1,7 @@
node_modules/
dist/
dist-showcase/
dist-demo/
*.log
.DS_Store
# impeccable-ignore-start
+5 -3
View File
@@ -2,9 +2,11 @@
"$schema": "https://impeccable.style/schema/config.json",
"detector": {
"designSystem": { "enabled": true },
"ignoreValues": {
"overused-font": ["Montserrat", "Open Sans", "JetBrains Mono"]
},
"ignoreValues": [
{ "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/**"]
},
"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`
- 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.
- 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
kit component in-app — request the change upstream instead.
- 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.
3. Follow **DESIGN.md**: tinted neutrals, layered shadows, ease-out motion, focus-visible rings, no nested cards.
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
`npm version <patch|minor|major>` then `npm publish` (builds automatically).
Tag notes in the commit. Consuming apps bump at their own pace.
## 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
`.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
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
(`tailwind-preset.cjs`) exposes them as utilities.
`src/lib/tokens.json`; TypeScript consumes it directly, the CSS theme is generated,
and the Tailwind preset exposes it as utilities. `npm run tokens:check` prevents drift.
## Color
Warm-tinted neutrals (never pure black/gray) on a warm dark base, with a single
gold brand accent. Semantic colors: success / warning / danger / info.
| Token | Dark (default) | Use |
|---|---|---|
| `bg` | #232022 | Page background |
| `surface` / `surface-2` | #2b282a / #333031 | Cards, raised panels |
| `border` | #3a3638 | Hairlines, inputs |
| `text` / `muted` | #F5F1EC / #A99F97 | Body / secondary |
| `brand` / `brand-bright` | #DCBB4F / #F5CD15 | Accent, primary actions |
| Token | Dark (default) | Light | Use |
|---|---|---|---|
| `bg` | #232022 | #FAF8F4 | Page background |
| `surface` / `surface-2` | #2B282A / #333031 | #FFFFFF / #F4F0EA | Cards, raised panels |
| `border` | #3A3638 | #DED8D1 | Hairlines, inputs |
| `text` / `muted` | #F5F1EC / #A99F97 | #272326 / #665F62 | Body / secondary |
| `brand` | #DCBB4F | #79640E | Accent and action |
A `.light` class on `<html>` flips neutrals for light surfaces; brand stays constant.
Rebrand any app by overriding the CSS variables in `globals.css` — no recompile.
Every filled semantic color has a matching `*-foreground` token. Do not reuse a
background color as text without a contrast check. `ThemeProvider` supports dark,
light, and system modes; `ThemeScript` prevents a flash before hydration.
## Type
- Display / headings: **Montserrat** (600/700)
- Body / UI: **Open Sans**
- 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 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.
3. Interactive primitives wrap Radix (focus traps, keyboard nav, ARIA for free).
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
Primitives: Button, Card, Input, Select (themed Radix popover), Textarea, Label,
Badge, Dialog, DropdownMenu, Table, Toaster, Spinner, EmptyState, StatCard.
Primitives: Button, Card, FormField, Input, Select, Textarea, Label, Checkbox,
Switch, Tabs, Tooltip, Badge, Dialog, DropdownMenu, Table, Pagination, Skeleton,
Toaster, Spinner, EmptyState, StatCard.
Motion: FadeIn, Stagger/StaggerItem.
Animated: AnimatedNumber, SpotlightCard.
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
`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.
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
shared tokens or override the CSS variables in your local `globals.css` for a
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()`.
5. **Add depth + motion.** Wrap page sections in `FadeIn`/`Stagger`; use
`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,
then `/impeccable audit` and `/impeccable polish` per screen. Add the detector
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
- **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.
- **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).
- **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.
## Install (in a consuming app)
@@ -51,34 +53,48 @@ module.exports = {
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
```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
eyebrow="New"
title="Ship tools that look designed"
subtitle="One brand system across every app."
primaryCta={{ label: "Get started", href: "/signup" }}
secondaryCta={{ label: "Docs", href: "/docs" }}
/>
<ThemeScript />
<ThemeProvider>
<FormField label="Workspace name" description="Shown in navigation.">
<Input />
</FormField>
<Button>Save</Button>
<Toaster />
</ThemeProvider>
```
## Develop
```bash
npm install
npm ci
npm run dev # tsup watch build
npm run typecheck
npm run design:detect # run the Impeccable anti-slop detector on src/
npm run demo # root demo.html using the real kit components
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
```bash
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
+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",
"version": "0.4.1",
"description": "Shared design foundation \u2014 branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
"version": "0.5.0",
"description": "Shared design foundation branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
"type": "module",
"license": "MIT",
"author": "Jason <jason@messagepoint.tv>",
@@ -18,7 +18,10 @@
"files": [
"dist",
"tailwind-preset.cjs",
"src/styles/globals.css",
"src/lib/tokens.json",
"src/styles",
".npmrc.example",
"ADOPT.md",
"DESIGN.md",
"PRODUCT.md",
"MIGRATION.md",
@@ -26,27 +29,47 @@
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./tokens": {
"types": "./dist/tokens/tokens-entry.d.ts",
"import": "./dist/tokens/tokens-entry.js"
},
"./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",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"main": "./dist/client/index.js",
"module": "./dist/client/index.js",
"types": "./dist/client/index.d.ts",
"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",
"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/",
"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"
},
"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-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.3.3",
"@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",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@@ -56,18 +79,29 @@
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"tailwindcss": ">=3.4"
"react": ">=18 <20",
"react-dom": ">=18 <20",
"tailwindcss": ">=3.4 <4"
},
"overrides": {
"esbuild": "^0.28.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.18",
"@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",
"jsdom": "^29.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.17",
"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 { 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. */
export function AnimatedNumber({
value,
format = (n) => Math.round(n).toLocaleString(),
format = defaultFormat,
className,
}: {
value: number;
@@ -25,5 +27,10 @@ export function AnimatedNumber({
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;
}) {
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();
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 (
<div
ref={ref}
onMouseMove={onMove}
onMouseLeave={() => setPos((p) => ({ ...p, active: false }))}
onPointerMove={onMove}
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
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-slow group-hover:opacity-100"
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>
Executable → Regular
+110 -27
View File
@@ -1,4 +1,9 @@
"use client";
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";
export interface NavItem {
@@ -9,50 +14,128 @@ export interface NavItem {
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({
brand,
nav,
topbar,
children,
footer,
navLabel = "Primary navigation",
className,
mainClassName,
}: {
brand: React.ReactNode;
nav: NavItem[];
topbar?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
navLabel?: string;
className?: string;
mainClassName?: string;
}) {
return (
<div className="flex min-h-screen bg-bg text-text">
<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>
<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>
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 (
<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">
{sidebar()}
</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">
<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">
{topbar}
<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">
<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>
<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>
);
+4 -2
View File
@@ -45,9 +45,11 @@ export function CommandPalette({
open={open}
onOpenChange={setOpen}
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(
"fixed left-1/2 top-1/4 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden",
"rounded-lg border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
"overflow-hidden rounded-lg border border-border bg-surface shadow-lg",
"data-[state=open]:animate-scale-in"
)}
>
<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) && (
<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 && (
<Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}>
{primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label}
+2 -2
View File
@@ -11,11 +11,11 @@ const buttonVariants = cva(
{
variants: {
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",
outline: "border border-border 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",
},
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
ref={ref}
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",
"data-[state=open]:animate-scale-in",
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
+88 -49
View File
@@ -1,23 +1,11 @@
"use client";
import * as React from "react";
import * as RS from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
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(
"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 " +
@@ -29,61 +17,101 @@ const selectVariants = cva(
);
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 OptionGroup = { label?: React.ReactNode; options: OptionData[] };
function collectOptions(children: React.ReactNode): OptionData[] {
const out: OptionData[] = [];
const walk = (nodes: React.ReactNode) =>
React.Children.forEach(nodes, (child) => {
function collectOptions(children: React.ReactNode): OptionGroup[] {
const groups: OptionGroup[] = [];
const ungrouped: OptionData[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return;
if (child.type === "option") {
const p = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
out.push({ value: String(p.value ?? ""), label: p.children, disabled: p.disabled });
} else if (child.type === "optgroup") {
walk((child.props as { children?: React.ReactNode }).children);
const props = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
ungrouped.push({ value: String(props.value ?? ""), label: props.children, disabled: props.disabled });
return;
}
if (child.type === "optgroup") {
const props = child.props as { label?: React.ReactNode; children?: React.ReactNode };
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 });
}
});
walk(children);
return out;
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;
defaultValue?: string;
onChange?: (e: { target: { value: string } }) => void;
onValueChange?: (value: string) => void;
disabled?: boolean;
name?: string;
placeholder?: string;
/** Compatibility callback for native-select migrations. Prefer onValueChange. */
onChange?: (event: { target: { value: string } }) => void;
placeholder?: React.ReactNode;
className?: string;
contentClassName?: string;
triggerProps?: Omit<TriggerProps, "children" | "className">;
children?: React.ReactNode;
"aria-label"?: string;
id?: string;
}
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
) => {
const options = collectOptions(children);
const handle = (v: string) => {
const real = fromRadix(v);
onValueChange?.(real);
onChange?.({ target: { value: real } });
const groups = collectOptions(children);
const values = groups.flatMap((group) => group.options.map((option) => option.value));
const emptySentinel = values.includes(EMPTY) ? `${EMPTY}_fallback` : EMPTY;
const toRadix = (next?: string) => (next === "" ? emptySentinel : next);
const fromRadix = (next: string) => (next === emptySentinel ? "" : next);
const handleValueChange = (next: string) => {
const realValue = fromRadix(next);
onValueChange?.(realValue);
onChange?.({ target: { value: realValue } });
};
return (
<RS.Root
{...rootProps}
value={value === undefined ? undefined : toRadix(value)}
defaultValue={defaultValue === undefined ? undefined : toRadix(defaultValue)}
onValueChange={handle}
disabled={disabled}
name={name}
onValueChange={handleValueChange}
>
<RS.Trigger
{...triggerProps}
ref={ref}
id={id}
className={cn(selectVariants({ size }), className)}
>
<RS.Trigger ref={ref} id={id} aria-label={rest["aria-label"]} className={cn(selectVariants({ size }), className)}>
<span className="min-w-0 truncate text-left">
<RS.Value placeholder={placeholder} />
</span>
@@ -97,27 +125,37 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
sideOffset={6}
className={cn(
"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">
{options.map((o, i) => (
{groups.map((group, groupIndex) => (
<RS.Group key={groupIndex}>
{group.label && (
<RS.Label className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
{group.label}
</RS.Label>
)}
{group.options.map((option, optionIndex) => (
<RS.Item
key={`${o.value}-${i}`}
value={toRadix(o.value)!}
disabled={o.disabled}
key={`${option.value}-${optionIndex}`}
value={toRadix(option.value)!}
disabled={option.disabled}
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>{o.label}</RS.ItemText>
<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.Content>
</RS.Portal>
@@ -126,4 +164,5 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
}
);
Select.displayName = "Select";
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";
+9 -3
View File
@@ -1,20 +1,26 @@
import { Toaster as Sonner, toast } from "sonner";
import { useOptionalTheme } from "../lib/theme";
/** 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>) => {
const theme = useOptionalTheme();
return (
<Sonner
theme="dark"
theme={theme?.resolvedTheme ?? "system"}
position="bottom-right"
toastOptions={{
...toastOptions,
classNames: {
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
description: "!text-muted",
actionButton: "!bg-brand !text-bg",
actionButton: "!bg-brand !text-brand-foreground",
cancelButton: "!bg-surface-2 !text-muted",
...toastOptions?.classNames,
},
}}
{...props}
/>
);
};
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
export { cn } from "./lib/cn";
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
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 { Textarea, type TextareaProps } from "./components/textarea";
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 {
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 @@
/**
* Design tokens — the single source of truth for the brand.
* These mirror the CSS variables in styles/globals.css and feed the Tailwind preset.
* Change the brand once, here, and every app that consumes the preset updates.
*/
import source from "./tokens.json";
/** Canonical brand values are maintained in tokens.json. */
export const tokens = {
color: {
// Neutrals are tinted (warm), never pure black/gray — an Impeccable anti-slop rule.
bg: "#232022",
surface: "#2b282a",
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" },
...source,
/** Backward-compatible default palette. Prefer themes for mode-aware work. */
color: source.color.dark,
themes: source.color,
} as const;
export type Tokens = typeof tokens;
+6 -4
View File
@@ -22,7 +22,7 @@ export function FadeIn({
initial={reduce ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10% 0px" }}
transition={{ duration: 0.4, ease: easeOut, delay }}
transition={reduce ? { duration: 0 } : { duration: 0.4, ease: easeOut, delay }}
>
{children}
</motion.div>
@@ -40,11 +40,12 @@ export const staggerItem: Variants = {
};
export function Stagger({ children, className }: { children: React.ReactNode; className?: string }) {
const reduce = useReducedMotion();
return (
<motion.div
className={className}
variants={staggerContainer}
initial="hidden"
variants={reduce ? undefined : staggerContainer}
initial={reduce ? false : "hidden"}
whileInView="show"
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 }) {
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 };
+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");
}
+15 -31
View File
@@ -1,52 +1,36 @@
/* Import once in your app entry: import "@jason/ui-kit/styles.css"; */
@import "./theme.generated.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Brand tokens as HSL channels so Tailwind's <alpha-value> works.
* :root = dark (default brand). .light overrides for light surfaces.
*/
@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)); }
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
body {
background-color: hsl(var(--bg));
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;
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. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-delay: 0s !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !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
* per-app rebranding work without recompiling the preset.
*/
const tokens = require("./src/lib/tokens.json");
const v = (name) => `hsl(var(${name}) / <alpha-value>)`;
const family = (value) => value.split(",").map((part) => part.trim().replace(/^"|"$/g, ""));
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -28,30 +30,46 @@ module.exports = {
ring: v("--brand"),
text: v("--text"),
muted: v("--muted"),
brand: { DEFAULT: v("--brand"), bright: v("--brand-bright"), dark: v("--brand-dark") },
success: v("--success"),
warning: v("--warning"),
danger: v("--danger"),
info: v("--info"),
brand: {
DEFAULT: v("--brand"),
bright: v("--brand-bright"),
dark: v("--brand-dark"),
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: {
display: ['"Montserrat"', "system-ui", "sans-serif"],
sans: ['"Open Sans"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
display: family(tokens.font.display),
sans: family(tokens.font.sans),
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: {
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)",
DEFAULT: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px 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)",
sm: tokens.shadow.sm,
DEFAULT: tokens.shadow.md,
md: tokens.shadow.md,
lg: tokens.shadow.lg,
glow: tokens.shadow.glow,
},
transitionTimingFunction: {
out: "cubic-bezier(0.22, 1, 0.36, 1)",
"in-out": "cubic-bezier(0.65, 0, 0.35, 1)",
out: tokens.ease.out,
"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: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"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",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"resolveJsonModule": true,
"strict": true,
"declaration": 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({
entry: ["src/index.ts"],
const shared: Options = {
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
clean: false,
treeshake: true,
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,
},
});