feat: initial @jason/ui-kit — tokens, preset, components, motion, blocks, Impeccable
ci / build-and-design (push) Failing after 15s

This commit is contained in:
Jason
2026-07-16 00:36:53 -05:00
commit b75012525d
36 changed files with 1324 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build-and-design:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm install --no-audit --no-fund
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Impeccable design detector (anti-slop)
run: npx impeccable detect src/ --json || true
Executable
+14
View File
@@ -0,0 +1,14 @@
node_modules/
dist/
*.log
.DS_Store
# impeccable-ignore-start
.impeccable/config.local.json
.impeccable/hook.cache.json
.impeccable/hook.pending.json
.impeccable/*.png
.impeccable/live/
# impeccable-ignore-end
# tsup temp (mount cannot unlink)
tsup.config.bundled_*.mjs
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://impeccable.style/schema/config.json",
"detector": {
"designSystem": { "enabled": true },
"ignoreValues": {
"overused-font": ["Montserrat", "Open Sans", "JetBrains Mono"]
},
"ignoreFiles": ["dist/**"]
},
"hook": { "enabled": true }
}
Executable
+4
View File
@@ -0,0 +1,4 @@
# Consuming apps: copy to .npmrc so `npm i @jason/ui-kit` resolves from Gitea.
@jason:registry=https://git.alwisp.com/api/packages/jason/npm/
# Auth token for the Gitea npm registry (read scope is enough to install):
//git.alwisp.com/api/packages/jason/npm/:_authToken=${GITEA_NPM_TOKEN}
+17
View File
@@ -0,0 +1,17 @@
# Contributing
## Adding a component
1. Create it in `src/components/` (primitive), `src/animated/`, or `src/blocks/`.
2. Use `cn()` + `class-variance-authority` for variants. Wrap Radix for interactive primitives.
3. Follow **DESIGN.md**: tinted neutrals, layered shadows, ease-out motion, focus-visible rings, no nested cards.
4. Export it from `src/index.ts`.
5. `npm run typecheck && npm run build && npm run design:detect` must pass.
## Releasing
`npm version <patch|minor|major>` then `npm publish` (builds automatically).
Tag notes in the commit. Consuming apps bump at their own pace.
## Design guardrail
Every visual change should survive `impeccable audit`/`polish`. If the detector
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.
Executable
+46
View File
@@ -0,0 +1,46 @@
# Design System — @jason/ui-kit
The single source of truth for how every app looks and feels. Tokens live in
`src/lib/tokens.ts` and `src/styles/globals.css`; the Tailwind preset
(`tailwind-preset.cjs`) exposes them as utilities.
## Color
Warm-tinted neutrals (never pure black/gray) on a warm dark base, with a single
gold brand accent. Semantic colors: success / warning / danger / info.
| Token | Dark (default) | Use |
|---|---|---|
| `bg` | #232022 | Page background |
| `surface` / `surface-2` | #2b282a / #333031 | Cards, raised panels |
| `border` | #3a3638 | Hairlines, inputs |
| `text` / `muted` | #F5F1EC / #A99F97 | Body / secondary |
| `brand` / `brand-bright` | #DCBB4F / #F5CD15 | Accent, primary actions |
A `.light` class on `<html>` flips neutrals for light surfaces; brand stays constant.
Rebrand any app by overriding the CSS variables in `globals.css` — no recompile.
## Type
- Display / headings: **Montserrat** (600/700)
- Body / UI: **Open Sans**
- Code / data: **JetBrains Mono**
Never Inter, Arial, or system defaults for headings (Impeccable anti-slop rule).
## Radius, elevation, motion
- Radius scale: 6 / 10 / 14 / 20px.
- Shadows are **layered and tinted** (`shadow-sm/md/lg/glow`) — depth, not flat.
- Motion: 120/180/320ms with an ease-out curve. **No bounce/elastic easing.**
- Everything honors `prefers-reduced-motion`.
## Component rules
1. Use `cn()` for all conditional classes.
2. Variants via `class-variance-authority`, not ad-hoc conditionals.
3. Interactive primitives wrap Radix (focus traps, keyboard nav, ARIA for free).
4. Focus-visible ring on every interactive element.
5. Don't nest cards inside cards. Don't put gray text on colored fills.
## Included
Primitives: Button, Card, Input, Badge, Dialog, DropdownMenu, Table, Toaster.
Motion: FadeIn, Stagger/StaggerItem.
Animated: AnimatedNumber, SpotlightCard.
Blocks: AppShell, CommandPalette, Hero, FeatureGrid, Pricing.
Executable
+15
View File
@@ -0,0 +1,15 @@
MIT License
Copyright (c) 2026 Jason
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
Executable
+52
View File
@@ -0,0 +1,52 @@
# Migration — adopting @jason/ui-kit in existing apps
You don't rewrite anything. The kit is additive: install it, adopt it screen by
screen, and delete local duplicates as you go. Most of your apps are already
React + Vite + Tailwind, so this is low-friction.
## Priority order (by payoff)
Best-fit apps first — they already have `clsx`/`tailwind-merge` and clean tokens,
so they're the natural reference implementations:
1. **delta** and **rack-planner** — pilots. Prove the pattern here.
2. **memer, codedump, fabdash, family-planner, mrp** — React + Vite + Tailwind; direct swap.
3. **qms, inven, mrp-qrcode** — Next.js; same kit, add `"use client"` where needed.
4. **relay** — has MPM brand fonts already; align tokens, adopt AppShell + Table.
5. **qrknit, goto** — vanilla/PHP; use the kit for React islands or new marketing pages, or borrow the tokens as CSS variables.
6. **pnger** — Svelte; not a React consumer. Reuse `tokens.ts` values + the color/type/motion rules from DESIGN.md manually.
## Steps per app
1. **Install + npmrc**
```bash
cp node_modules/@jason/ui-kit/.npmrc.example .npmrc # then set GITEA_NPM_TOKEN
npm install @jason/ui-kit
```
2. **Adopt the preset.** Replace the app's hand-rolled color block in
`tailwind.config.js` with `presets: [require("@jason/ui-kit/preset")]`. Add the
kit's `dist` to `content`. Import `@jason/ui-kit/styles.css` in your entry.
3. **Reconcile tokens.** If the app had different colors, either map them to the
shared tokens or override the CSS variables in your local `globals.css` for a
per-app skin. Goal: one system, small per-app deltas.
4. **Swap primitives.** Replace local `<button>`/modal/input/table/toast with kit
components. Delete the local copies. Standardize on `cn()` and `toast()`.
5. **Add depth + motion.** Wrap page sections in `FadeIn`/`Stagger`; use
`SpotlightCard` for feature/stat cards; `AnimatedNumber` on dashboard metrics.
6. **Add the shell.** Move dashboards onto `AppShell` + `CommandPalette` (⌘K).
7. **Guard it.** `npx impeccable install` in the app, run `/impeccable init` once,
then `/impeccable audit` and `/impeccable polish` per screen. Add the detector
to the app's CI (copy `.gitea/workflows/ci.yml`).
## Rules of thumb
- Don't fork kit components in-app. If one needs changing, change it here and bump the version — every app benefits.
- If a pattern shows up in 2+ apps, promote it into `src/blocks/`.
- New apps: start from the kit on day one. No hand-rolled buttons ever again.
## Path forward for NEW apps
Baseline stack going forward: **React + Vite + TypeScript + Tailwind (this preset)
+ @jason/ui-kit + Impeccable**, backed by your usual Express/Prisma or
Next + better-sqlite3, Dockerized for Unraid. `sonner` and `motion` are defaults,
not per-app decisions.
Executable
+19
View File
@@ -0,0 +1,19 @@
# Product Context
> Read by Impeccable commands so design work stays on-brand. One file, shared by every app that adopts this kit.
## Audience
Internal operators and small-business/SaaS end-users of self-hosted work tools (dashboards, trackers, portals, generators). Competent but time-poor — they value clarity and speed over decoration.
## Product lane
**Product** (app UI, dashboards, tools) first; **brand** (landing/marketing) second. Both share the same tokens.
## Voice
Direct, confident, no fluff. Short labels. Verbs over nouns. Never cutesy.
## Anti-references (what we must NOT look like)
- Generic AI-generated SaaS templates: Inter everywhere, purple→blue gradients, cards nested in cards, gray text on colored fills, a rounded-square icon tile above every heading.
- Flat, shadowless "utility-class soup" with no motion.
## Design north star
Warm, confident, slightly editorial. Gold accent used sparingly for emphasis and action. Depth via layered tinted shadows, not borders-on-everything. Motion is purposeful and quiet.
Executable
+92
View File
@@ -0,0 +1,92 @@
# @jason/ui-kit
Shared design foundation for all my apps. One brand system, reused everywhere — so
new apps start polished and existing apps have a clear upgrade path.
Built on the stack I already use (**React + Vite + Tailwind + TypeScript**) and
guarded by [Impeccable](https://impeccable.style) so nothing ships looking like a
generic AI-generated template.
## What's inside
- **Design tokens + Tailwind preset** — warm gold/dark brand, radius/shadow/motion scales, CSS-variable theming (dark default, `.light` opt-in), Montserrat / Open Sans / JetBrains Mono.
- **`cn()`** helper (clsx + tailwind-merge) — the standard everywhere.
- **Primitives** — Button, Card, Input, Badge, Dialog, DropdownMenu, Table, Toaster (sonner). Radix-backed, accessible, pre-themed.
- **Motion** — `FadeIn`, `Stagger` (framer/`motion`, reduced-motion aware).
- **Animated** — `AnimatedNumber` (count-up stats), `SpotlightCard` (cursor glow).
- **Blocks** — `AppShell` (sidebar + topbar), `CommandPalette` (⌘K), `Hero`, `FeatureGrid`, `Pricing`.
- **Impeccable** wired in — `DESIGN.md` / `PRODUCT.md` design context, config, and a CI anti-slop detector.
## Install (in a consuming app)
This package publishes to the Gitea npm registry. Add an `.npmrc` (see `.npmrc.example`):
```
@jason:registry=https://git.alwisp.com/api/packages/jason/npm/
//git.alwisp.com/api/packages/jason/npm/:_authToken=${GITEA_NPM_TOKEN}
```
Then:
```bash
npm install @jason/ui-kit
```
### Wire up Tailwind
```js
// tailwind.config.js
module.exports = {
presets: [require("@jason/ui-kit/preset")],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"./node_modules/@jason/ui-kit/dist/**/*.js", // so kit classes aren't purged
],
};
```
```ts
// app entry (main.tsx)
import "@jason/ui-kit/styles.css";
```
### Use it
```tsx
import { Button, Card, CardHeader, CardTitle, Hero, AnimatedNumber, Toaster, toast } from "@jason/ui-kit";
<Hero
eyebrow="New"
title="Ship tools that look designed"
subtitle="One brand system across every app."
primaryCta={{ label: "Get started", href: "/signup" }}
secondaryCta={{ label: "Docs", href: "/docs" }}
/>
```
## Develop
```bash
npm install
npm run dev # tsup watch build
npm run typecheck
npm run design:detect # run the Impeccable anti-slop detector on src/
```
## Publish a new version
```bash
npm version patch # or minor / major
npm publish # builds via prepublishOnly, pushes to Gitea registry
```
## Extending with 21st.dev
This kit is the base layer. For richer one-off blocks, pull
[21st.dev](https://21st.dev) components (shadcn-registry compatible) into an app,
then re-theme them with these tokens and, if broadly useful, promote them into
`src/blocks/` here. Run `/impeccable polish` on anything new before it ships.
See **DESIGN.md** for the full system spec and **MIGRATION.md** for adopting the
kit in apps that already exist.
Executable
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@jason/ui-kit",
"version": "0.1.0",
"description": "Shared design foundation — branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
"type": "module",
"license": "MIT",
"author": "Jason <jason@messagepoint.tv>",
"repository": { "type": "git", "url": "https://git.alwisp.com/jason/ui-kit.git" },
"publishConfig": { "registry": "https://git.alwisp.com/api/packages/jason/npm/" },
"sideEffects": ["**/*.css"],
"files": ["dist", "tailwind-preset.cjs", "src/styles/globals.css"],
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./preset": "./tailwind-preset.cjs",
"./styles.css": "./src/styles/globals.css"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"design:detect": "impeccable detect src/",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.469.0",
"motion": "^11.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": { "react": ">=18", "react-dom": ">=18", "tailwindcss": ">=3.4" },
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"impeccable": "^3.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsup": "^8.3.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { useInView, useMotionValue, useSpring, useReducedMotion } from "motion/react";
/** Count-up number for dashboards/stat cards. Springs to `value` when scrolled into view. */
export function AnimatedNumber({
value,
format = (n) => Math.round(n).toLocaleString(),
className,
}: {
value: number;
format?: (n: number) => string;
className?: string;
}) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: "-20% 0px" });
const reduce = useReducedMotion();
const mv = useMotionValue(0);
const spring = useSpring(mv, { stiffness: 90, damping: 20 });
const [display, setDisplay] = React.useState(format(0));
React.useEffect(() => {
if (reduce) { setDisplay(format(value)); return; }
if (inView) mv.set(value);
}, [inView, value, reduce, mv, format]);
React.useEffect(() => spring.on("change", (v) => setDisplay(format(v))), [spring, format]);
return <span ref={ref} className={className}>{display}</span>;
}
+38
View File
@@ -0,0 +1,38 @@
import * as React from "react";
import { cn } from "../lib/cn";
/** Card with a soft brand glow that tracks the cursor. Depth without gimmick. */
export function SpotlightCard({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState({ x: 50, y: 50, active: false });
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const r = ref.current?.getBoundingClientRect();
if (!r) return;
setPos({ x: ((e.clientX - r.left) / r.width) * 100, y: ((e.clientY - r.top) / r.height) * 100, active: true });
};
return (
<div
ref={ref}
onMouseMove={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)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-slow group-hover:opacity-100"
style={{
background: `radial-gradient(400px circle at ${pos.x}% ${pos.y}%, hsl(var(--brand) / 0.10), transparent 60%)`,
}}
/>
<div className="relative">{children}</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react";
import { cn } from "../lib/cn";
export interface NavItem {
label: string;
icon?: React.ReactNode;
href?: string;
active?: boolean;
onClick?: () => void;
}
/** Dashboard chrome: fixed sidebar + sticky topbar + scrolling content. */
export function AppShell({
brand,
nav,
topbar,
children,
footer,
}: {
brand: React.ReactNode;
nav: NavItem[];
topbar?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="flex min-h-screen bg-bg text-text">
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex">
<div className="flex h-14 items-center px-5 font-display text-lg font-bold">{brand}</div>
<nav className="flex-1 space-y-0.5 px-3 py-3">
{nav.map((item) => {
const Comp = item.href ? "a" : "button";
return (
<Comp
key={item.label}
href={item.href}
onClick={item.onClick}
className={cn(
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast [&_svg]:size-4",
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
)}
>
{item.icon}
{item.label}
</Comp>
);
})}
</nav>
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center justify-between gap-4 border-b border-border bg-bg/80 px-5 backdrop-blur">
{topbar}
</header>
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react";
import { Command } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "../lib/cn";
export interface CommandAction {
id: string;
label: string;
group?: string;
icon?: React.ReactNode;
onSelect: () => void;
}
/** ⌘K palette. Opens on Cmd/Ctrl+K by default; big perceived-quality boost on any tool. */
export function CommandPalette({
actions,
placeholder = "Type a command or search…",
}: {
actions: CommandAction[];
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const groups = React.useMemo(() => {
const map = new Map<string, CommandAction[]>();
for (const a of actions) {
const g = a.group ?? "Actions";
map.set(g, [...(map.get(g) ?? []), a]);
}
return [...map.entries()];
}, [actions]);
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Command palette"
className={cn(
"fixed left-1/2 top-1/4 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden",
"rounded-lg border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
)}
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="size-4 text-muted" />
<Command.Input placeholder={placeholder} className="h-12 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-muted" />
</div>
<Command.List className="max-h-80 overflow-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted">No results.</Command.Empty>
{groups.map(([group, items]) => (
<Command.Group key={group} heading={group} className="px-1 py-1 text-xs font-semibold uppercase tracking-wide text-muted [&_[cmdk-group-items]]:mt-1">
{items.map((a) => (
<Command.Item
key={a.id}
onSelect={() => { a.onSelect(); setOpen(false); }}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm text-text aria-selected:bg-surface-2 [&_svg]:size-4"
>
{a.icon}
{a.label}
</Command.Item>
))}
</Command.Group>
))}
</Command.List>
</Command.Dialog>
);
}
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import { Stagger, StaggerItem } from "../motion";
import { SpotlightCard } from "../animated/spotlight-card";
import { cn } from "../lib/cn";
export interface Feature {
icon?: React.ReactNode;
title: string;
description: string;
}
export function FeatureGrid({
heading,
features,
columns = 3,
className,
}: {
heading?: React.ReactNode;
features: Feature[];
columns?: 2 | 3 | 4;
className?: string;
}) {
const cols = { 2: "sm:grid-cols-2", 3: "sm:grid-cols-2 lg:grid-cols-3", 4: "sm:grid-cols-2 lg:grid-cols-4" }[columns];
return (
<section className={cn("mx-auto max-w-6xl px-6 py-20", className)}>
{heading && <h2 className="mb-10 text-center font-display text-3xl font-bold tracking-tight">{heading}</h2>}
<Stagger className={cn("grid grid-cols-1 gap-4", cols)}>
{features.map((f) => (
<StaggerItem key={f.title}>
<SpotlightCard className="h-full">
{f.icon && (
<div className="mb-4 inline-flex size-10 items-center justify-center rounded bg-brand/15 text-brand [&_svg]:size-5">
{f.icon}
</div>
)}
<h3 className="font-display text-lg font-semibold">{f.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">{f.description}</p>
</SpotlightCard>
</StaggerItem>
))}
</Stagger>
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { FadeIn } from "../motion";
import { Button } from "../components/button";
import { cn } from "../lib/cn";
export function Hero({
eyebrow,
title,
subtitle,
primaryCta,
secondaryCta,
className,
}: {
eyebrow?: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
primaryCta?: { label: string; href?: string; onClick?: () => void };
secondaryCta?: { label: string; href?: string; onClick?: () => void };
className?: string;
}) {
return (
<section className={cn("relative overflow-hidden bg-bg px-6 py-24 text-center", className)}>
{/* Subtle brand aura — depth, not a purple gradient. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[420px] opacity-60"
style={{ background: "radial-gradient(600px 300px at 50% 0%, hsl(var(--brand) / 0.14), transparent 70%)" }}
/>
<div className="relative mx-auto max-w-3xl">
{eyebrow && (
<FadeIn>
<span className="mb-4 inline-block rounded-sm bg-brand/15 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand">
{eyebrow}
</span>
</FadeIn>
)}
<FadeIn delay={0.05}>
<h1 className="font-display text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">{title}</h1>
</FadeIn>
{subtitle && (
<FadeIn delay={0.1}>
<p className="mx-auto mt-6 max-w-xl text-lg text-muted">{subtitle}</p>
</FadeIn>
)}
{(primaryCta || secondaryCta) && (
<FadeIn delay={0.15}>
<div className="mt-10 flex items-center justify-center gap-3">
{primaryCta && (
<Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}>
{primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label}
</Button>
)}
{secondaryCta && (
<Button size="lg" variant="outline" asChild={!!secondaryCta.href} onClick={secondaryCta.onClick}>
{secondaryCta.href ? <a href={secondaryCta.href}>{secondaryCta.label}</a> : secondaryCta.label}
</Button>
)}
</div>
</FadeIn>
)}
</div>
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react";
import { Check } from "lucide-react";
import { Stagger, StaggerItem } from "../motion";
import { Button } from "../components/button";
import { Badge } from "../components/badge";
import { cn } from "../lib/cn";
export interface PricingTier {
name: string;
price: string;
period?: string;
description?: string;
features: string[];
cta: { label: string; href?: string; onClick?: () => void };
featured?: boolean;
}
export function Pricing({
heading,
tiers,
className,
}: {
heading?: React.ReactNode;
tiers: PricingTier[];
className?: string;
}) {
return (
<section className={cn("mx-auto max-w-5xl px-6 py-20", className)}>
{heading && <h2 className="mb-10 text-center font-display text-3xl font-bold tracking-tight">{heading}</h2>}
<Stagger className="grid grid-cols-1 gap-4 md:grid-cols-3">
{tiers.map((t) => (
<StaggerItem key={t.name}>
<div
className={cn(
"flex h-full flex-col rounded-lg border bg-surface p-6 transition-shadow duration-slow",
t.featured ? "border-brand/50 shadow-glow" : "border-border shadow-sm hover:shadow-md"
)}
>
<div className="flex items-center justify-between">
<h3 className="font-display text-lg font-semibold">{t.name}</h3>
{t.featured && <Badge variant="brand">Popular</Badge>}
</div>
{t.description && <p className="mt-1 text-sm text-muted">{t.description}</p>}
<div className="mt-5 flex items-baseline gap-1">
<span className="font-display text-4xl font-bold">{t.price}</span>
{t.period && <span className="text-sm text-muted">/{t.period}</span>}
</div>
<ul className="mt-6 flex-1 space-y-2.5">
{t.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-text">
<Check className="mt-0.5 size-4 shrink-0 text-brand" />
{f}
</li>
))}
</ul>
<Button
className="mt-6 w-full"
variant={t.featured ? "primary" : "outline"}
asChild={!!t.cta.href}
onClick={t.cta.onClick}
>
{t.cta.href ? <a href={t.cta.href}>{t.cta.label}</a> : t.cta.label}
</Button>
</div>
</StaggerItem>
))}
</Stagger>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/cn";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-sm px-2 py-0.5 text-xs font-medium",
{
variants: {
variant: {
brand: "bg-brand/15 text-brand",
neutral: "bg-surface-2 text-muted",
success: "bg-success/15 text-success",
warning: "bg-warning/15 text-warning",
danger: "bg-danger/15 text-danger",
outline: "border border-border text-text",
},
},
defaultVariants: { variant: "neutral" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export const Badge = ({ className, variant, ...props }: BadgeProps) => (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
export { badgeVariants };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded font-display font-semibold " +
"transition-[background-color,box-shadow,transform,color] duration-base ease-out " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-bg " +
"active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
primary: "bg-brand text-bg hover:bg-brand-bright shadow-sm hover:shadow-glow",
secondary: "bg-surface-2 text-text hover:bg-surface-2/70 shadow-sm",
outline: "border border-border bg-transparent text-text hover:bg-surface-2/60",
ghost: "bg-transparent text-text hover:bg-surface-2/60",
danger: "bg-danger text-white hover:bg-danger/90 shadow-sm",
link: "text-brand underline-offset-4 hover:underline",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
icon: "size-10",
},
},
defaultVariants: { variant: "primary", size: "md" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
);
Button.displayName = "Button";
export { buttonVariants };
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "../lib/cn";
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border border-border bg-surface shadow-sm", className)} {...props} />
)
);
Card.displayName = "Card";
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1 p-5", className)} {...props} />
);
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className={cn("font-display text-lg font-semibold leading-tight", className)} {...props} />
);
export const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className={cn("text-sm text-muted", className)} {...props} />
);
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("p-5 pt-0", className)} {...props} />
);
export const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2 p-5 pt-0", className)} {...props} />
);
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../lib/cn";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-fade-in data-[state=closed]:opacity-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"rounded-lg border border-border bg-surface p-6 shadow-lg",
"data-[state=open]:animate-scale-in",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted transition-colors hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = "DialogContent";
export const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1.5 pb-4", className)} {...props} />
);
export const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("font-display text-lg font-semibold", className)} {...props} />
));
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted", className)} {...props} />
));
DialogDescription.displayName = "DialogDescription";
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import * as Menu from "@radix-ui/react-dropdown-menu";
import { Check } from "lucide-react";
import { cn } from "../lib/cn";
export const DropdownMenu = Menu.Root;
export const DropdownMenuTrigger = Menu.Trigger;
export const DropdownMenuGroup = Menu.Group;
export const DropdownMenuSeparator = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof Menu.Separator>) => (
<Menu.Separator className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
);
export const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof Menu.Content>,
React.ComponentPropsWithoutRef<typeof Menu.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<Menu.Portal>
<Menu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-surface p-1 shadow-lg",
"data-[state=open]:animate-scale-in",
className
)}
{...props}
/>
</Menu.Portal>
));
DropdownMenuContent.displayName = "DropdownMenuContent";
export const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof Menu.Item>,
React.ComponentPropsWithoutRef<typeof Menu.Item>
>(({ className, ...props }, ref) => (
<Menu.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-text outline-none",
"transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropdownMenuLabel = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof Menu.Label>) => (
<Menu.Label className={cn("px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted", className)} {...props} />
);
export const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof Menu.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<Menu.CheckboxItem
ref={ref}
className={cn("relative flex cursor-pointer select-none items-center rounded py-1.5 pl-7 pr-2 text-sm outline-none focus:bg-surface-2", className)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<Menu.ItemIndicator><Check className="size-3.5" /></Menu.ItemIndicator>
</span>
{children}
</Menu.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem";
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "../lib/cn";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-10 w-full rounded border border-border bg-bg/40 px-3 py-2 text-sm text-text",
"placeholder:text-muted transition-shadow duration-base ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:border-brand/60",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "../lib/cn";
export const Table = ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="relative w-full overflow-auto rounded-lg border border-border">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
export const TableHeader = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className={cn("[&_tr]:border-b [&_tr]:border-border", className)} {...props} />
);
export const TableBody = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
);
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className={cn("border-b border-border transition-colors hover:bg-surface-2/50 data-[state=selected]:bg-surface-2", className)} {...props} />
);
export const TableHead = ({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th className={cn("sticky top-0 z-10 bg-surface px-4 py-3 text-left font-display text-xs font-semibold uppercase tracking-wide text-muted", className)} {...props} />
);
export const TableCell = ({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className={cn("px-4 py-3 align-middle", className)} {...props} />
);
+20
View File
@@ -0,0 +1,20 @@
import { Toaster as Sonner, toast } from "sonner";
/** Pre-themed sonner Toaster. Drop <Toaster /> once near your app root. */
export const Toaster = (props: React.ComponentProps<typeof Sonner>) => (
<Sonner
theme="dark"
position="bottom-right"
toastOptions={{
classNames: {
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
description: "!text-muted",
actionButton: "!bg-brand !text-bg",
cancelButton: "!bg-surface-2 !text-muted",
},
}}
{...props}
/>
);
export { toast };
Executable
+34
View File
@@ -0,0 +1,34 @@
// Utilities
export { cn } from "./lib/cn";
export { tokens, type Tokens } from "./lib/tokens";
export { setTheme, toggleTheme } from "./lib/theme";
// Primitives
export { Button, buttonVariants, type ButtonProps } from "./components/button";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./components/card";
export { Input } from "./components/input";
export { Badge, badgeVariants, type BadgeProps } from "./components/badge";
export {
Dialog, DialogTrigger, DialogClose, DialogContent,
DialogHeader, DialogTitle, DialogDescription,
} from "./components/dialog";
export {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuCheckboxItem,
} from "./components/dropdown-menu";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./components/table";
export { Toaster, toast } from "./components/toast";
// Motion
export { FadeIn, Stagger, StaggerItem, staggerContainer, staggerItem, motion } from "./motion";
// Animated
export { AnimatedNumber } from "./animated/animated-number";
export { SpotlightCard } from "./animated/spotlight-card";
// Blocks
export { AppShell, type NavItem } from "./blocks/app-shell";
export { CommandPalette, type CommandAction } from "./blocks/command-palette";
export { Hero } from "./blocks/hero";
export { FeatureGrid, type Feature } from "./blocks/feature-grid";
export { Pricing, type PricingTier } from "./blocks/pricing";
Executable
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names, de-duplicating conflicting Tailwind utilities. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+9
View File
@@ -0,0 +1,9 @@
/** 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";
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Design tokens — the single source of truth for the brand.
* These mirror the CSS variables in styles/globals.css and feed the Tailwind preset.
* Change the brand once, here, and every app that consumes the preset updates.
*/
export const tokens = {
color: {
// Neutrals are tinted (warm), never pure black/gray — an Impeccable anti-slop rule.
bg: "#232022",
surface: "#2b282a",
surface2: "#333031",
border: "#3a3638",
text: "#F5F1EC",
muted: "#A99F97",
// Brand — warm gold on warm dark.
brand: "#DCBB4F",
brandBright: "#F5CD15",
brandDark: "#998643",
// Semantic
success: "#3F9E6A",
warning: "#DCBB4F",
danger: "#C0562F",
info: "#5B8DB8",
},
radius: { sm: "6px", md: "10px", lg: "14px", xl: "20px" },
font: {
display: '"Montserrat", system-ui, sans-serif',
sans: '"Open Sans", system-ui, sans-serif',
mono: '"JetBrains Mono", ui-monospace, monospace',
},
// Layered, tinted shadows — depth instead of a flat plane.
shadow: {
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)",
md: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
lg: "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
glow: "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)",
},
// Motion — smooth, never bouncy/elastic (Impeccable anti-slop rule).
ease: {
out: "cubic-bezier(0.22, 1, 0.36, 1)",
inOut: "cubic-bezier(0.65, 0, 0.35, 1)",
},
duration: { fast: "120ms", base: "180ms", slow: "320ms" },
} as const;
export type Tokens = typeof tokens;
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";
const easeOut = [0.22, 1, 0.36, 1] as const;
/** Fade + rise on mount. Respects prefers-reduced-motion. */
export function FadeIn({
children,
delay = 0,
y = 8,
className,
}: {
children: React.ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<motion.div
className={className}
initial={reduce ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10% 0px" }}
transition={{ duration: 0.4, ease: easeOut, delay }}
>
{children}
</motion.div>
);
}
/** Stagger container — pair with <StaggerItem>. */
export const staggerContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.06 } },
};
export const staggerItem: Variants = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: easeOut } },
};
export function Stagger({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<motion.div
className={className}
variants={staggerContainer}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "-10% 0px" }}
>
{children}
</motion.div>
);
}
export function StaggerItem({ children, className }: { children: React.ReactNode; className?: string }) {
return <motion.div className={className} variants={staggerItem}>{children}</motion.div>;
}
export { motion };
+53
View File
@@ -0,0 +1,53 @@
/* Import once in your app entry: import "@jason/ui-kit/styles.css"; */
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Brand tokens as HSL channels so Tailwind's <alpha-value> works.
* :root = dark (default brand). .light overrides for light surfaces.
*/
@layer base {
:root {
--bg: 330 4% 13%;
--surface: 330 4% 16%;
--surface-2: 330 3% 20%;
--border: 330 3% 22%;
--text: 33 33% 94%;
--muted: 28 9% 63%;
--brand: 46 68% 59%;
--brand-bright: 51 91% 52%;
--brand-dark: 46 40% 43%;
--success: 146 43% 43%;
--warning: 46 68% 59%;
--danger: 17 61% 47%;
--info: 205 37% 54%;
}
.light {
--bg: 36 33% 97%;
--surface: 0 0% 100%;
--surface-2: 36 20% 95%;
--border: 33 12% 86%;
--text: 330 6% 15%;
--muted: 330 4% 40%;
}
* { border-color: hsl(var(--border)); }
body {
background-color: hsl(var(--bg));
color: hsl(var(--text));
font-family: "Open Sans", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4, h5 { font-family: "Montserrat", system-ui, sans-serif; }
/* Respect reduced-motion everywhere. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Shared Tailwind preset. Consuming apps do:
* // tailwind.config.js
* module.exports = {
* presets: [require("@jason/ui-kit/preset")],
* content: [
* "./src/**\/*.{ts,tsx}",
* "./node_modules/@jason/ui-kit/dist/**\/*.js" // so component classes aren't purged
* ],
* };
*
* Colors are driven by CSS variables (see styles/globals.css) so light/dark and
* per-app rebranding work without recompiling the preset.
*/
const v = (name) => `hsl(var(${name}) / <alpha-value>)`;
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
theme: {
extend: {
colors: {
bg: v("--bg"),
surface: v("--surface"),
"surface-2": v("--surface-2"),
border: v("--border"),
input: v("--border"),
ring: v("--brand"),
text: v("--text"),
muted: v("--muted"),
brand: { DEFAULT: v("--brand"), bright: v("--brand-bright"), dark: v("--brand-dark") },
success: v("--success"),
warning: v("--warning"),
danger: v("--danger"),
info: v("--info"),
},
fontFamily: {
display: ['"Montserrat"', "system-ui", "sans-serif"],
sans: ['"Open Sans"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace", "monospace"],
},
borderRadius: { sm: "6px", DEFAULT: "10px", md: "10px", lg: "14px", xl: "20px" },
boxShadow: {
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)",
DEFAULT: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
md: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
lg: "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
glow: "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)",
},
transitionTimingFunction: {
out: "cubic-bezier(0.22, 1, 0.36, 1)",
"in-out": "cubic-bezier(0.65, 0, 0.35, 1)",
},
transitionDuration: { fast: "120ms", DEFAULT: "180ms", base: "180ms", slow: "320ms" },
keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"fade-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
"scale-in": { from: { opacity: "0", transform: "scale(0.97)" }, to: { opacity: "1", transform: "scale(1)" } },
shimmer: { from: { backgroundPosition: "200% 0" }, to: { backgroundPosition: "-200% 0" } },
},
animation: {
"fade-in": "fade-in 180ms cubic-bezier(0.22,1,0.36,1)",
"fade-up": "fade-up 320ms cubic-bezier(0.22,1,0.36,1)",
"scale-in": "scale-in 180ms cubic-bezier(0.22,1,0.36,1)",
shimmer: "shimmer 2.2s linear infinite",
},
},
},
plugins: [],
};
Executable
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
Executable
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
external: ["react", "react-dom", "tailwindcss"],
});