Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 840504843a | |||
| 822d8e41e6 |
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+6201
File diff suppressed because it is too large
Load Diff
+48
-14
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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: "Let’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
+110
-27
@@ -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;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
<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">
|
const sidebar = (mobile = false) => (
|
||||||
<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">
|
<div className={cn("flex h-14 items-center px-5 font-display text-lg font-bold", mobile && "pr-12")}>
|
||||||
{nav.map((item) => {
|
{brand}
|
||||||
const Comp = item.href ? "a" : "button";
|
</div>
|
||||||
return (
|
<ShellNav items={nav} label={navLabel} onNavigate={mobile ? () => setMobileOpen(false) : undefined} />
|
||||||
<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>}
|
{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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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) => {
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
if (!React.isValidElement(child)) return;
|
if (!React.isValidElement(child)) return;
|
||||||
if (child.type === "option") {
|
if (child.type === "option") {
|
||||||
const p = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
|
const props = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
|
||||||
out.push({ value: String(p.value ?? ""), label: p.children, disabled: p.disabled });
|
ungrouped.push({ value: String(props.value ?? ""), label: props.children, disabled: props.disabled });
|
||||||
} else if (child.type === "optgroup") {
|
return;
|
||||||
walk((child.props as { children?: React.ReactNode }).children);
|
}
|
||||||
|
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;
|
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
|
||||||
|
{...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">
|
<span className="min-w-0 truncate text-left">
|
||||||
<RS.Value placeholder={placeholder} />
|
<RS.Value placeholder={placeholder} />
|
||||||
</span>
|
</span>
|
||||||
@@ -97,27 +125,37 @@ 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.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
|
<RS.Item
|
||||||
key={`${o.value}-${i}`}
|
key={`${option.value}-${optionIndex}`}
|
||||||
value={toRadix(o.value)!}
|
value={toRadix(option.value)!}
|
||||||
disabled={o.disabled}
|
disabled={option.disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
|
"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-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
|
||||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
"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">
|
<RS.ItemIndicator className="absolute right-2 inline-flex">
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
</RS.ItemIndicator>
|
</RS.ItemIndicator>
|
||||||
</RS.Item>
|
</RS.Item>
|
||||||
))}
|
))}
|
||||||
|
</RS.Group>
|
||||||
|
))}
|
||||||
</RS.Viewport>
|
</RS.Viewport>
|
||||||
</RS.Content>
|
</RS.Content>
|
||||||
</RS.Portal>
|
</RS.Portal>
|
||||||
@@ -126,4 +164,5 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
Select.displayName = "Select";
|
Select.displayName = "Select";
|
||||||
|
|
||||||
export { selectVariants };
|
export { selectVariants };
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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>) => {
|
||||||
|
const theme = useOptionalTheme();
|
||||||
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme="dark"
|
theme={theme?.resolvedTheme ?? "system"}
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
...toastOptions,
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
|
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
|
||||||
description: "!text-muted",
|
description: "!text-muted",
|
||||||
actionButton: "!bg-brand !text-bg",
|
actionButton: "!bg-brand !text-brand-foreground",
|
||||||
cancelButton: "!bg-surface-2 !text-muted",
|
cancelButton: "!bg-surface-2 !text-muted",
|
||||||
|
...toastOptions?.classNames,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export { toast };
|
export { toast };
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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 }} />;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { tokens, type Tokens } from "./lib/tokens";
|
||||||
+35
-17
@@ -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)" } },
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [require("./tailwind-preset.cjs")],
|
||||||
|
content: ["./src/**/*.{ts,tsx}", "./showcase/**/*.{html,ts,tsx}"],
|
||||||
|
};
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user