feat: flatten routes and merge marketing and onboarding directories

This commit is contained in:
2026-06-06 18:52:03 -07:00
parent 47417d13a0
commit 0480b306f1
139 changed files with 36409 additions and 229 deletions

View File

@@ -0,0 +1,120 @@
# Vibn AI Templates
A small, themable starter kit for building modern SaaS UIs. Pure React + CSS variables — no build step, no dependencies. Designed to be copy-pasted into any project.
## What's in it
- **`tokens.css`** — Every color, radius, shadow, and type token, exposed as CSS custom properties. Four themes ship out of the box:
- `.theme-minimal` — soft warm light (Linear / Notion school)
- `.theme-dark` — black-and-white surface (Vercel / Stripe school)
- `.theme-glass` — aurora gradient + frosted glass
- `.theme-editorial` — paper, serif display, hairline rules
- **`icons.jsx`** — A small Tabler-style stroke icon set (`<Icon name="search"/>`) plus a `<VibnMark/>` brand glyph.
- **`components.jsx`** — Atoms + composites. Every visual property reads from a CSS variable:
- **Forms** · `Button`, `IconButton`, `Field`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FieldGroup`
- **Containers** · `Card`, `CardHeader`, `Divider`, `Modal`, `Banner`
- **Display** · `Badge` (tones: neutral / accent / success / warn / danger / info), `Avatar`, `AvatarStack`, `Tabs`, `Table`, `Spinner`, `KBD`
- **`shells.jsx`** — Page-level layouts:
- **In-product** · `SidebarShell`, `TopbarShell`, `RailShell`
- **Auth** · `AuthCenteredShell`, `AuthSplitShell`, `AuthGlassShell`
## How theming works
Tokens are CSS custom properties on `:root` (the default minimal theme). Each `.theme-*` class overrides a subset. Apply a theme by adding the class anywhere — usually on `<html>` or a top-level wrapper.
```html
<html class="theme-glass">
<!-- the whole page uses the glass theme -->
</html>
```
```html
<!-- or scope a theme to one region -->
<div class="theme-editorial">
<Card>… this card is editorial …</Card>
</div>
```
Themes can nest. Setting `theme-*` on a child element overrides only the tokens that theme defines; the rest inherit from the parent.
### Adding a fifth theme
Add a new class to `tokens.css` that overrides whichever tokens differ from `:root`:
```css
.theme-sunset {
--bg: #2b0d0e;
--surface: #3a1316;
--accent: #ff8a3a;
--accent-2: #f43f5e;
--text: #fef7ee;
--text-2: #f0c8b0;
--border: #4a1f23;
--button-bg: #ff8a3a;
--button-fg: #2b0d0e;
}
```
You don't need to redefine the whole token set — just the differences. Components don't change.
## Usage in plain HTML (no bundler)
```html
<link rel="stylesheet" href="vibn-ai-templates/tokens.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel" src="vibn-ai-templates/icons.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/components.jsx"></script>
<script type="text/babel" src="vibn-ai-templates/shells.jsx"></script>
<div id="root" class="theme-dark"></div>
<script type="text/babel">
function App() {
return (
<AuthCenteredShell brand={{ name: "Acme" }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600 }}>Welcome back</h1>
<p style={{ color: "var(--text-2)", marginTop: 6 }}>Sign in to continue.</p>
<Field label="Email"><Input value="mira@acme.io"/></Field>
<Field label="Password"><Input type="password" value="••••••••••"/></Field>
<Button full>Sign in</Button>
</AuthCenteredShell>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
```
## Usage in a React codebase
Convert the three `.jsx` files from `Object.assign(window, …)` to named `export` statements. The components have no runtime dependencies beyond React.
```jsx
// In your app
import "vibn-ai-templates/tokens.css";
import { Button, Card, Input, Field, Tabs } from "vibn-ai-templates/components";
import { SidebarShell } from "vibn-ai-templates/shells";
// Pick a theme on your root
<html className="theme-dark">
```
## Conventions
- **Inline styles read from CSS vars** — `style={{ background: "var(--surface)" }}`. This is intentional: it lets the entire library reskin with one class swap, and avoids a CSS-in-JS dependency.
- **Components are presentational.** State (open/closed modals, active tabs, form values) lives in your app. Pass `active` + `onChange` to controlled components.
- **No external icon dependency.** `icons.jsx` ships a curated set. Add to it freely.
- **Avatars hash a color from the name** unless you pass `color="#…"`.
- **Tables and tabs are uncontrolled-friendly** — pass `rows`/`items`, omit selection props if you don't need them.
## Showcase
`Vibn UI Showcase.html` at the project root renders every component across every theme. Use it as the visual reference and as a starting point for new screens.
## Versioning
This is a starter — fork it. There's no semver, no changelog. Edit `tokens.css` to match your brand, prune what you don't use, extend what you do.

View File

@@ -0,0 +1,737 @@
// ============================================================
// vibn-ai-templates/components.jsx
// ------------------------------------------------------------
// The core component set. Every visual property is wired to a
// CSS variable from tokens.css — flipping `class="theme-glass"`
// (or any other theme class) reskins the whole library.
//
// Components export to `window` for use in script-tag HTML
// projects. In a real codebase, swap the bottom-of-file
// assignment for `export { … }`.
//
// Components included:
// Button, IconButton, Field, Input, Textarea, Select,
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
// FieldGroup, KBD, Spinner.
// ============================================================
// ─── Helpers ─────────────────────────────────────────────────
const cx = (...names) => names.filter(Boolean).join(" ");
const noop = () => {};
// ─── Button ──────────────────────────────────────────────────
// variant: primary (default), secondary, ghost, destructive
// size: sm | md (default) | lg
// leadingIcon / trailingIcon: <Icon name="…"/>
// loading: disables and shows a spinner
const Button = ({
children, variant = "primary", size = "md", full = false,
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
...rest
}) => {
const sizing = {
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
}[size];
const variants = {
primary: {
background: "var(--button-bg)",
color: "var(--button-fg)",
border: "1px solid var(--button-border)",
},
secondary: {
background: "var(--button-secondary-bg)",
color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)",
},
ghost: {
background: "transparent",
color: "var(--button-ghost-fg)",
border: "1px solid transparent",
},
destructive: {
background: "var(--danger)",
color: "#ffffff",
border: "1px solid var(--danger)",
},
}[variant];
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
style={{
...variants,
padding: `${sizing.padY}px ${sizing.padX}px`,
borderRadius: "var(--button-radius)",
fontFamily: "var(--font-sans)",
fontSize: sizing.font,
fontWeight: "var(--weight-medium)",
lineHeight: 1.2,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: full ? "100%" : "auto",
whiteSpace: "nowrap",
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
...style,
}}
{...rest}
>
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
<span>{children}</span>
{!loading && trailingIcon}
</button>
);
};
// ─── IconButton ──────────────────────────────────────────────
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
const dims = { sm: 28, md: 32, lg: 38 }[size];
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
const variants = {
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
border: "1px solid var(--button-secondary-border)" },
}[variant];
return (
<button
aria-label={label}
onClick={onClick}
style={{
...variants,
width: dims, height: dims, borderRadius: "var(--radius-sm)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", padding: 0,
...style,
}}
>
{icon ?? <Icon name={name} size={iconSize} />}
</button>
);
};
// ─── Spinner ─────────────────────────────────────────────────
const Spinner = ({ size = 14, stroke = 2 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate"
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
</path>
</svg>
);
// ─── Field (wraps a labelled input with hint / error) ────────
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
<div style={{ marginBottom: "var(--space-4)", ...style }}>
{label && (
<label htmlFor={htmlFor} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
color: "var(--text)", marginBottom: 6,
}}>
<span>{label}</span>
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
</label>
)}
{children}
{(hint || error) && (
<div style={{
fontSize: "var(--text-xs)", marginTop: 5,
color: error ? "var(--danger)" : "var(--text-3)",
}}>{error || hint}</div>
)}
</div>
);
// ─── Input ───────────────────────────────────────────────────
// Bare input (use inside <Field>). leadingIcon / trailingIcon
// add an inner ornament. invalid red-rings the border.
const Input = ({
value, placeholder, type = "text", leadingIcon, trailingIcon,
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
}) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
fontSize: "var(--text-md)",
color: "var(--text)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
opacity: disabled ? 0.5 : 1,
transition: "border-color var(--duration), box-shadow var(--duration)",
...style,
}}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value, e)}
style={{
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
padding: 0,
}}
{...rest}
/>
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
</div>
);
// ─── Textarea ────────────────────────────────────────────────
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
<textarea
id={id}
value={value}
placeholder={placeholder}
rows={rows}
onChange={(e) => onChange(e.target.value, e)}
style={{
width: "100%", display: "block", padding: "10px 12px",
borderRadius: "var(--field-radius)",
background: "var(--field-bg)",
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
fontSize: "var(--text-md)", color: "var(--text)",
fontFamily: "var(--font-sans)", resize: "vertical",
outline: "none", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}}
{...rest}
/>
);
// ─── Select (presentation only — clicks the menu open visually) ─
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
<div style={{
display: "flex", alignItems: "center", gap: 8,
padding: "10px 12px", borderRadius: "var(--field-radius)",
background: "var(--field-bg)", border: "1px solid var(--field-border)",
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
cursor: "pointer", boxShadow: "var(--shadow-sm)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
...style,
}} {...rest}>
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
<span style={{ flex: 1 }}>{value || placeholder}</span>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
</div>
);
// ─── Checkbox ────────────────────────────────────────────────
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="checkbox"
aria-checked={indeterminate ? "mixed" : !!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
color: "var(--text-on-accent)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
transition: "background var(--duration), border-color var(--duration)",
}}
>
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Radio ───────────────────────────────────────────────────
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
<label style={{
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="radio"
aria-checked={!!checked}
onClick={() => !disabled && onChange(true)}
style={{
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
background: "var(--surface)",
position: "relative",
}}
>
{checked && <span style={{
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
background: "var(--accent)", borderRadius: "50%",
}}/>}
</span>
{(label || hint) && (
<span style={{ minWidth: 0 }}>
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Switch ──────────────────────────────────────────────────
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
<label style={{
display: "flex", alignItems: "center", gap: 12,
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
}}>
<span
role="switch"
aria-checked={!!checked}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 34, height: 20, borderRadius: 999,
background: checked ? "var(--accent)" : "var(--surface-alt)",
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
position: "relative", flexShrink: 0,
transition: "background var(--duration), border-color var(--duration)",
}}
>
<span style={{
position: "absolute", top: 1, left: checked ? 15 : 1,
width: 16, height: 16, borderRadius: "50%",
background: checked ? "var(--text-on-accent)" : "var(--surface)",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
transition: "left var(--duration) var(--ease)",
}}/>
</span>
{(label || hint) && (
<span style={{ flex: 1, minWidth: 0 }}>
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
</span>
)}
</label>
);
// ─── Card / Surface ──────────────────────────────────────────
// Card paints a `surface` background with border + shadow.
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
const shadows = {
default: "var(--shadow-sm)",
raised: "var(--shadow)",
floating:"var(--shadow-lg)",
flat: "none",
};
return (
<div style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--card-radius)",
padding,
boxShadow: shadows[variant] || shadows.default,
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
color: "var(--text)",
...style,
}} {...rest}>{children}</div>
);
};
const CardHeader = ({ title, subtitle, action, style }) => (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
marginBottom: "var(--space-4)", gap: 16, ...style,
}}>
<div style={{ minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
}}>{title}</div>}
{subtitle && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>}
</div>
{action}
</div>
);
// ─── Badge / Tag ─────────────────────────────────────────────
// tone: neutral | accent | success | warn | danger | info
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
const palette = {
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
}[tone] || {};
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "2px 8px", borderRadius: "var(--radius-pill)",
background: palette.bg, color: palette.fg,
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
whiteSpace: "nowrap", lineHeight: 1.4,
...style,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
{leadingIcon}
{children}
</span>
);
};
const Tag = Badge; // alias
// ─── Avatar ──────────────────────────────────────────────────
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
const hashName = (s = "") => {
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return avatarPalette[Math.abs(h) % avatarPalette.length];
};
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
return (
<span style={{
position: "relative", display: "inline-flex", flexShrink: 0,
width: size, height: size, borderRadius: "50%",
background: color || hashName(name), color: "#3a2820",
alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
overflow: "hidden", ...style,
}}>
{src
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
: initials}
{status && <span style={{
position: "absolute", bottom: 0, right: 0,
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
borderRadius: "50%", border: "2px solid var(--surface)",
background: status === "online" ? "var(--success)" :
status === "busy" ? "var(--danger)" : "var(--text-3)",
}}/>}
</span>
);
};
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
const shown = items.slice(0, max);
const remaining = items.length - shown.length;
return (
<div style={{ display: "inline-flex" }}>
{shown.map((p, i) => (
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
))}
{remaining > 0 && (
<span style={{
width: size, height: size, borderRadius: "50%",
background: "var(--surface-alt)", color: "var(--text-2)",
display: "inline-flex", alignItems: "center", justifyContent: "center",
fontSize: Math.round(size * 0.4), fontWeight: 600,
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
}}>+{remaining}</span>
)}
</div>
);
};
// ─── Tabs ────────────────────────────────────────────────────
// Controlled: pass `active` (label of active tab) + `onChange`.
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
return (
<div style={{
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
...style,
}}>
{items.map(t => {
const isActive = t.label === active || t.id === active;
if (variant === "pill") {
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "6px 12px", borderRadius: "var(--radius-pill)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
background: isActive ? "var(--accent)" : "transparent",
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", fontWeight: 500,
display: "inline-flex", alignItems: "center", gap: 6,
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{t.count}</span>
)}
</button>
);
}
return (
<button key={t.id || t.label}
onClick={() => onChange(t.id || t.label)}
style={{
padding: "10px 4px", margin: "0 12px 0 0",
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
fontWeight: "var(--weight-medium)",
background: "transparent", border: "none", cursor: "pointer",
color: isActive ? "var(--text)" : "var(--text-2)",
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
position: "relative", top: 1,
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
}}>
{t.icon}{t.label}
{t.count != null && (
<span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
color: isActive ? "var(--accent)" : "var(--text-3)",
}}>{t.count}</span>
)}
</button>
);
})}
</div>
);
};
// ─── Table ───────────────────────────────────────────────────
// columns: [{ key, label, width?, align?, render? }]
// rows: [{ id, [key]: value, … }]
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
const padY = density === "compact" ? 8 : 12;
const allChecked = rows.length > 0 && selected.length === rows.length;
const someChecked = selected.length > 0 && !allChecked;
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
const toggleOne = (id) => onSelectionChange(
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
);
const headerCell = {
padding: `10px 12px`, fontSize: "var(--text-xs)",
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
borderBottom: "1px solid var(--border)", background: "var(--surface)",
};
const bodyCell = {
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
};
return (
<div style={{
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", overflow: "hidden",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}>
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
<thead>
<tr>
{selectable && (
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
</th>
)}
{columns.map(c => (
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>
{selectable && (
<td style={{ ...bodyCell, paddingRight: 0 }}>
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
</td>
)}
{columns.map(c => (
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
{c.render ? c.render(r) : r[c.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
// ─── Modal (presentational — wrap your own state) ────────────
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
if (!open) return null;
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100,
background: "rgba(0,0,0,0.5)",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
background: "var(--surface)", color: "var(--text)",
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
boxShadow: "var(--shadow-modal)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
}}
>
<div style={{
padding: "var(--space-6)", display: "flex",
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
}}>
<div style={{ minWidth: 0 }}>
{title && <h2 style={{
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
}}>{title}</h2>}
{description && <p style={{
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
}}>{description}</p>}
</div>
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
</div>
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
{footer && (
<div style={{
padding: "var(--space-4) var(--space-6)",
borderTop: "1px solid var(--divider)",
display: "flex", justifyContent: "flex-end", gap: 8,
background: "var(--surface-2)",
}}>{footer}</div>
)}
</div>
</div>
);
};
// ─── Banner / Alert ──────────────────────────────────────────
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
const palette = {
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
}[tone];
return (
<div style={{
display: "flex", gap: 12, padding: "12px 16px",
borderRadius: "var(--radius)",
background: palette.bg, color: "var(--text)",
border: `1px solid ${palette.fg}33`,
alignItems: "flex-start",
...style,
}}>
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{title && <div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{title}</div>}
{children && <div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
}}>{children}</div>}
</div>
{action}
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
</div>
);
};
// ─── Divider ─────────────────────────────────────────────────
const Divider = ({ label, vertical, style }) => {
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
return (
<div style={{
display: "flex", alignItems: "center", gap: 12,
fontSize: "var(--text-xs)", color: "var(--text-3)",
letterSpacing: "0.08em", textTransform: "uppercase",
margin: "var(--space-4) 0", ...style,
}}>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
<span>{label}</span>
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
</div>
);
};
// ─── FieldGroup — horizontal segmented control ───────────────
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
<div style={{
display: "inline-flex", padding: 3, gap: 2,
background: "var(--surface-alt)", border: "1px solid var(--border)",
borderRadius: "var(--radius)", ...style,
}}>
{options.map(o => {
const v = typeof o === "string" ? o : o.value;
const label = typeof o === "string" ? o : o.label;
const sel = v === value;
return (
<button key={v} onClick={() => onChange(v)} style={{
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
background: sel ? "var(--surface)" : "transparent",
color: sel ? "var(--text)" : "var(--text-2)",
border: "none", cursor: "pointer",
boxShadow: sel ? "var(--shadow-sm)" : "none",
fontWeight: sel ? 500 : 400,
}}>{label}</button>
);
})}
</div>
);
// ─── KBD ─────────────────────────────────────────────────────
const KBD = ({ children, style }) => (
<kbd style={{
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
padding: "1px 6px", borderRadius: 4,
background: "var(--surface-alt)", color: "var(--text-2)",
border: "1px solid var(--border)",
...style,
}}>{children}</kbd>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
Button, IconButton, Spinner,
Field, Input, Textarea, Select, FieldGroup,
Checkbox, Radio, Switch,
Card, CardHeader, Divider,
Badge, Tag, Avatar, AvatarStack,
Tabs, Table, Modal, Banner, KBD,
});

View File

@@ -0,0 +1,89 @@
// ============================================================
// vibn-ai-templates/icons.jsx
// ------------------------------------------------------------
// A tiny Tabler-style stroke-icon helper + a curated set of
// paths used by the components. All inherit `currentColor` so
// they re-tint to whatever the parent's CSS color is.
//
// Usage:
// <Icon name="search" />
// <Icon path={icons.bell} size={20} stroke={2} />
// ============================================================
const icons = {
// Navigation / surface
home: <><path d="m3 12 9-9 9 9"/><path d="M5 10v10h14V10"/></>,
inbox: <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v7H2v-7z"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></>,
bell: <><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
settings:<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8L4.2 7a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
// Objects
people: <><circle cx="9" cy="8" r="4"/><path d="M3 21a6 6 0 0 1 12 0"/><circle cx="17" cy="6" r="3"/><path d="M21 17a4 4 0 0 0-6-3.5"/></>,
building: <><path d="M3 21h18M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2"/></>,
target: <><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></>,
doc: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>,
check: <><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></>,
bar: <><path d="M3 3v18h18"/><rect x="7" y="11" width="3" height="7"/><rect x="13" y="7" width="3" height="11"/></>,
workflow: <><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></>,
// Actions
plus: <path d="M12 5v14M5 12h14"/>,
x: <path d="M6 6l12 12M18 6L6 18"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
chevDown:<path d="m6 9 6 6 6-6"/>,
chevUp: <path d="m6 15 6-6 6 6"/>,
chevLeft:<path d="m15 6-6 6 6 6"/>,
chevRight:<path d="m9 6 6 6-6 6"/>,
arrow: <path d="M5 12h14M13 5l7 7-7 7"/>,
arrowUp: <path d="M12 19V5M5 12l7-7 7 7"/>,
arrowDown:<path d="M12 5v14M5 12l7 7 7-7"/>,
// Status
checkOnly: <path d="M5 12l5 5L20 7"/>,
alert: <><path d="M12 9v4M12 17v.01"/><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.4 0z"/></>,
info: <><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></>,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
eyeOff: <><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 7 11 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="M2 2l20 20"/></>,
star: <path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>,
spark: <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l3 3M15 15l3 3M6 18l3-3M15 9l3-3"/>,
bolt: <path d="m13 2-9 13h7l-1 7 9-13h-7z"/>,
shield: <path d="M12 2 4 5v7c0 5 3.5 9 8 10 4.5-1 8-5 8-10V5z"/>,
// Misc
briefcase: <><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/></>,
link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></>,
copy: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
};
const Icon = ({ name, path, size = 16, stroke = 1.6, style, className }) => (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={stroke}
strokeLinecap="round" strokeLinejoin="round"
style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0, ...style }}
className={className}
aria-hidden="true"
>
{path ?? icons[name]}
</svg>
);
// Tiny brand mark — a gradient triangle, the same as we've been
// using everywhere. Exported here so consumers don't redraw it.
const VibnMark = ({ size = 22 }) => {
const id = `vmk_${size}`;
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6e6cff"/>
<stop offset="100%" stopColor="#b15bff"/>
</linearGradient>
</defs>
<path d="M3 20 L12 4 L21 20 Z" fill={`url(#${id})`}/>
</svg>
);
};
Object.assign(window, { Icon, icons, VibnMark });

View File

@@ -0,0 +1,399 @@
// ============================================================
// vibn-ai-templates/shells.jsx
// ------------------------------------------------------------
// Layout shells — both in-product navs (Sidebar / Rail /
// Topbar) and auth scaffolds (CenteredCard / SplitHero / Glass).
//
// These are containers. Wrap your page in any shell and the
// shell handles brand, search, nav, footer. Compose with the
// components from components.jsx.
// ============================================================
// ── SidebarShell ─────────────────────────────────────────────
// Props:
// brand: { name, mark? }
// sections: [{ title?, items: [{ id, label, icon, count, active }] }]
// user: { name, email, color? }
// children: main pane
const SidebarShell = ({ brand = { name: "Vibn" }, sections = [], user, search = "Search…", children, width = 248 }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: `${width}px 1fr`,
overflow: "hidden",
}}>
<aside style={{
background: "var(--surface-alt)",
borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
}}>
{/* Brand row */}
<div style={{
padding: "12px 14px", display: "flex", alignItems: "center", gap: 10,
borderBottom: "1px solid var(--border)",
}}>
{brand.mark || <VibnMark size={22}/>}
<div style={{ flex: 1, fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)" }}>
{brand.name}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
{/* Search */}
<div style={{ padding: 12 }}>
<Input
placeholder={search}
leadingIcon={<Icon name="search" size={14}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
{/* Nav */}
<nav style={{ padding: "4px 8px", flex: 1, overflowY: "auto" }}>
{sections.map((s, i) => (
<div key={s.title || `s${i}`}>
{s.title && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
padding: "14px 10px 6px", textTransform: "uppercase",
letterSpacing: "0.04em", fontWeight: 500,
}}>{s.title}</div>}
{s.items.map(it => (
<div key={it.id || it.label} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "6px 10px", borderRadius: "var(--radius-sm)",
fontSize: "var(--text-md)", cursor: "pointer", marginBottom: 1,
color: it.active ? "var(--text)" : "var(--text-2)",
fontWeight: it.active ? 500 : 400,
background: it.active ? "var(--surface)" : "transparent",
boxShadow: it.active ? "var(--shadow-sm)" : "none",
}}>
<span style={{ color: it.active ? "var(--accent)" : "var(--text-3)", display: "flex" }}>
{typeof it.icon === "string"
? <Icon name={it.icon} size={15}/>
: it.icon}
</span>
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
{it.count != null && <span style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
fontVariantNumeric: "tabular-nums",
}}>{it.count}</span>}
</div>
))}
</div>
))}
</nav>
{/* User */}
{user && (
<div style={{
padding: 12, borderTop: "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 10,
}}>
<Avatar name={user.name} color={user.color} size={26}/>
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.2 }}>
<div style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>{user.name}</div>
{user.email && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>{user.email}</div>}
</div>
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }}/>
</div>
)}
</aside>
<main style={{ display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)" }}>
{children}
</main>
</div>
);
};
// ── TopbarShell ──────────────────────────────────────────────
// Dark top with breadcrumb + ⌘K + avatar; tabs strip below.
const TopbarShell = ({ brand = { name: "Vibn" }, breadcrumb, tabs = [], activeTab,
onTabChange = () => {}, user, children, search = "Find or jump to anything…" }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "flex", flexDirection: "column",
overflow: "hidden",
}}>
<header style={{
background: "var(--surface-alt)", color: "var(--text)",
borderBottom: "1px solid var(--border)",
}}>
<div style={{
display: "flex", alignItems: "center", gap: 14, padding: "12px 24px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: "var(--weight-semibold)", fontSize: "var(--text-lg)" }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
{breadcrumb && (
<>
<span style={{ color: "var(--text-3)" }}>/</span>
{breadcrumb.map((b, i) => (
<React.Fragment key={i}>
{i > 0 && <span style={{ color: "var(--text-3)" }}>/</span>}
<span style={{ fontSize: "var(--text-md)", display: "flex", alignItems: "center", gap: 8 }}>
{b.avatar && <Avatar name={b.avatar} size={18}/>}
<span>{b.label}</span>
{b.badge && <Badge tone="neutral">{b.badge}</Badge>}
</span>
</React.Fragment>
))}
</>
)}
<div style={{ flex: 1 }}/>
<div style={{ minWidth: 280 }}>
<Input placeholder={search}
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 12px" }} />
</div>
<Button variant="secondary" size="sm">Feedback</Button>
<IconButton name="bell" size="md"/>
{user && <Avatar name={user.name} color={user.color} size={28}/>}
</div>
{tabs.length > 0 && (
<div style={{ padding: "0 16px" }}>
<Tabs items={tabs} active={activeTab} onChange={onTabChange}/>
</div>
)}
</header>
<main style={{ flex: 1, overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── RailShell ────────────────────────────────────────────────
// Icon rail + secondary panel + content.
const RailShell = ({ brand, items = [], activeRail, onRailChange = () => {},
secondary, secondaryTitle, user, children }) => {
return (
<div className="vibn-app" style={{
width: "100%", height: "100%",
display: "grid", gridTemplateColumns: "64px 240px 1fr", overflow: "hidden",
}}>
{/* Rail */}
<div style={{
background: "var(--surface-alt)", borderRight: "1px solid var(--border)",
padding: "10px 0", display: "flex", flexDirection: "column",
alignItems: "center", gap: 4,
}}>
<div style={{ padding: "0 10px 6px" }}>
{brand?.mark || <VibnMark size={22}/>}
</div>
<Divider />
{items.map(it => {
const sel = (it.id || it.label) === activeRail;
return (
<button key={it.id || it.label}
onClick={() => onRailChange(it.id || it.label)}
aria-label={it.label}
style={{
width: 40, height: 40, borderRadius: "var(--radius)",
background: sel ? "var(--accent)" : "transparent",
color: sel ? "var(--text-on-accent)" : "var(--text-2)",
border: "none", cursor: "pointer", position: "relative",
}}>
{typeof it.icon === "string" ? <Icon name={it.icon} size={18} stroke={2}/> : it.icon}
{it.badge && <span style={{
position: "absolute", top: 2, right: 2, minWidth: 16, height: 16,
padding: "0 4px", borderRadius: 8,
background: "var(--danger)", color: "#fff",
fontSize: 10, fontWeight: 600,
display: "flex", alignItems: "center", justifyContent: "center",
border: "2px solid var(--surface-alt)",
}}>{it.badge}</span>}
</button>
);
})}
<div style={{ flex: 1 }}/>
{user && <Avatar name={user.name} color={user.color} size={30} ring={1}/>}
</div>
{/* Secondary */}
<div style={{
background: "var(--surface-2)", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column", overflow: "hidden",
}}>
{secondaryTitle && (
<div style={{
padding: "16px 14px 10px", borderBottom: "1px solid var(--border)",
}}>
<div style={{
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
marginBottom: 10,
}}>{secondaryTitle}</div>
<Input
placeholder="Jump to…"
leadingIcon={<Icon name="search" size={13}/>}
trailingIcon={<KBD>K</KBD>}
style={{ padding: "6px 10px" }}
/>
</div>
)}
<div style={{ padding: 10, flex: 1, overflowY: "auto" }}>{secondary}</div>
</div>
<main style={{ overflow: "hidden", background: "var(--bg)" }}>{children}</main>
</div>
);
};
// ── AuthCenteredShell ────────────────────────────────────────
// A single centered Card on a soft background, with brand top
// and small footer links. Good for sign-in / sign-up.
const AuthCenteredShell = ({ brand = { name: "Vibn" }, footerLinks = ["Privacy", "Terms", "Security"], cardWidth = 420, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateRows: "auto 1fr auto", overflow: "hidden",
}}>
<header style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "20px 28px",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
{brand.mark || <VibnMark size={20}/>}
{brand.name}
</div>
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", display: "flex", gap: 18 }}>
<span>Status</span><span>Docs</span><span style={{ color: "var(--text)", fontWeight: 500 }}>Sign in </span>
</div>
</header>
<main style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<Card variant="raised" padding={32} style={{ width: cardWidth }}>{children}</Card>
</main>
<footer style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "16px 28px", fontSize: "var(--text-xs)", color: "var(--text-3)",
}}>
<span>© 2026 {brand.name}</span>
<div style={{ display: "flex", gap: 16 }}>{footerLinks.map(l => <span key={l}>{l}</span>)}</div>
</footer>
</div>
);
// ── AuthSplitShell ───────────────────────────────────────────
// Left storytelling panel, right form. Big SaaS / Vercel feel.
const AuthSplitShell = ({ brand = { name: "Vibn" }, hero = {}, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", display: "grid",
gridTemplateColumns: "1fr 1fr", overflow: "hidden",
}}>
<div style={{
padding: "32px 44px", borderRight: "1px solid var(--border)",
display: "flex", flexDirection: "column",
background: "var(--surface-alt)", position: "relative", overflow: "hidden",
}}>
{/* Decorative wash, picks up theme accent */}
<div style={{
position: "absolute", top: -140, left: -120, width: 540, height: 540,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent-2) 40%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{
position: "absolute", bottom: -180, right: -120, width: 480, height: 480,
borderRadius: "50%",
background: "radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent 60%)",
filter: "blur(60px)", pointerEvents: "none",
}}/>
<div style={{ position: "relative", display: "flex", alignItems: "center", gap: 10, fontWeight: 600 }}>
{brand.mark || <VibnMark size={22}/>}
{brand.name}
</div>
<div style={{ position: "relative", marginTop: "auto" }}>
{hero.badge && (
<Badge tone="accent" style={{ marginBottom: 22 }}>{hero.badge}</Badge>
)}
{hero.headline && <h2 style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-3xl)",
lineHeight: 1.05, margin: 0, letterSpacing: "-0.02em",
fontWeight: 500, textWrap: "balance", maxWidth: 360,
}}>{hero.headline}</h2>}
{hero.sub && <p style={{
fontSize: "var(--text-md)", color: "var(--text-2)",
marginTop: 14, lineHeight: 1.5, maxWidth: 340,
}}>{hero.sub}</p>}
{hero.quote && (
<div style={{
position: "relative", marginTop: 28, padding: 18,
borderRadius: "var(--card-radius)", background: "var(--surface)",
border: "1px solid var(--border)",
}}>
<p style={{ fontSize: "var(--text-md)", margin: 0, lineHeight: 1.5, color: "var(--text)" }}>
"{hero.quote.body}"
</p>
<div style={{ marginTop: 12, display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name={hero.quote.author} size={26}/>
<div style={{ fontSize: "var(--text-xs)" }}>
<div style={{ fontWeight: 500 }}>{hero.quote.author}</div>
<div style={{ color: "var(--text-3)" }}>{hero.quote.role}</div>
</div>
</div>
</div>
)}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", padding: "32px 56px" }}>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: "var(--text-sm)", color: "var(--text-2)" }}>
Need help? <span style={{ color: "var(--text)", fontWeight: 500, marginLeft: 4 }}>support</span>
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 380 }}>{children}</div>
</div>
<div style={{ display: "flex", gap: 18, fontSize: "var(--text-xs)", color: "var(--text-3)", justifyContent: "flex-end" }}>
<span>Privacy</span><span>Terms</span><span>Security</span><span>v4.2.1</span>
</div>
</div>
</div>
);
// ── AuthGlassShell ───────────────────────────────────────────
// Aurora background + frosted card. Marketing-leaning.
const AuthGlassShell = ({ brand = { name: "Vibn" }, eyebrow, cardWidth = 460, children }) => (
<div className="vibn-app" style={{
width: "100%", height: "100%", position: "relative", overflow: "hidden",
}}>
{/* Top bar (a thin frosted pill — works in any theme thanks to surface vars) */}
<header style={{
position: "absolute", top: 22, left: "50%", transform: "translateX(-50%)",
zIndex: 10, width: "max-content",
display: "flex", alignItems: "center", gap: 4,
padding: "8px 8px 8px 18px", borderRadius: "var(--radius-pill)",
background: "var(--surface)",
border: "1px solid var(--border)",
backdropFilter: "blur(var(--surface-blur))",
WebkitBackdropFilter: "blur(var(--surface-blur))",
boxShadow: "var(--shadow-lg)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginRight: 16, fontWeight: 600 }}>
{brand.mark || <VibnMark size={18}/>}
{brand.name}
</div>
{["Product", "Pricing", "Docs"].map(l => (
<Button key={l} variant="ghost" size="sm">{l}</Button>
))}
<Divider vertical style={{ margin: "0 6px" }}/>
<Button variant="ghost" size="sm">Sign in</Button>
<Button size="sm">Get started </Button>
</header>
<main style={{
position: "relative", height: "100%",
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
}}>
<Card variant="floating" padding={36} style={{ width: cardWidth, borderRadius: "var(--radius-xl)" }}>
{eyebrow && <Badge tone="accent" style={{ marginBottom: 16 }}>{eyebrow}</Badge>}
{children}
</Card>
</main>
</div>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
SidebarShell, TopbarShell, RailShell,
AuthCenteredShell, AuthSplitShell, AuthGlassShell,
});

View File

@@ -0,0 +1,325 @@
/* ============================================================
vibn-ai-templates · tokens.css
------------------------------------------------------------
The whole library is themed through CSS custom properties.
The :root block holds the DEFAULT theme (minimal). Each
.theme-* class below overrides a subset to flip aesthetics.
------------------------------------------------------------
To use:
<html class="theme-glass"> → glass theme app-wide
<div class="theme-editorial">…</div> → scope to one block
============================================================ */
:root {
/* ── Typography ─────────────────────────────────────────── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-display: var(--font-sans); /* themes may override */
--font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace;
--text-xs: 11px;
--text-sm: 12px;
--text-md: 13px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 28px;
--text-3xl: 38px;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ── Spacing (4 px base) ────────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ── Radii ──────────────────────────────────────────────── */
--radius-xs: 3px;
--radius-sm: 5px;
--radius: 8px;
--radius-lg: 14px;
--radius-xl: 22px;
--radius-pill: 999px;
--button-radius: var(--radius);
--field-radius: 7px;
--card-radius: 12px;
--modal-radius: 16px;
/* ── Colors · MINIMAL (default light theme) ────────────── */
--bg: #f5f5f2;
--surface: #ffffff;
--surface-2: #fafaf8;
--surface-alt: #f1f0eb; /* sidebar / muted regions */
--border: #e8e8e3;
--border-strong:#d8d8d2;
--divider: #ededea;
--text: #111111;
--text-2: #5a5a5e;
--text-3: #8a8a90;
--text-on-accent: #ffffff;
--accent: #5e5cff;
--accent-2: #b15bff;
--accent-soft: #eeedff;
--accent-ring: rgba(94,92,255,0.22);
--success: #16a34a;
--success-soft: #dcfce7;
--warn: #d97706;
--warn-soft: #fef3c7;
--danger: #dc2626;
--danger-soft: #fee2e2;
/* ── Surfaces & effects ────────────────────────────────── */
--surface-blur: 0px; /* glass theme overrides */
--backdrop: transparent; /* glass theme overrides */
--grain: none; /* maximalist themes can use */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 12px -4px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 24px 48px -20px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.05);
--shadow-modal: 0 40px 80px -20px rgba(0,0,0,0.28), 0 2px 8px rgba(0,0,0,0.08);
--shadow-focus: 0 0 0 3px var(--accent-ring);
/* ── Buttons ───────────────────────────────────────────── */
--button-bg: #111111;
--button-fg: #ffffff;
--button-border: #111111;
--button-hover: #2a2a2a;
--button-press: #000000;
--button-secondary-bg: #ffffff;
--button-secondary-fg: #111111;
--button-secondary-border: var(--border);
--button-secondary-hover: #f6f5f0;
--button-ghost-fg: var(--text);
--button-ghost-hover: #00000008;
/* ── Inputs ────────────────────────────────────────────── */
--field-bg: #ffffff;
--field-border: var(--border);
--field-text: var(--text);
--field-placeholder: var(--text-3);
--field-focus-ring: var(--shadow-focus);
/* ── Animation ─────────────────────────────────────────── */
--duration-fast: 120ms;
--duration: 180ms;
--duration-slow: 260ms;
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
}
/* ============================================================
THEME: minimal (default, same as :root)
The class exists so consumers can name-toggle.
============================================================ */
.theme-minimal {}
/* ============================================================
THEME: dark — Vercel / Stripe / Linear web school
============================================================ */
.theme-dark {
--bg: #0a0a0a;
--surface: #101015;
--surface-2: #16161d;
--surface-alt: #0a0a0d;
--border: #1f1f25;
--border-strong:#2a2a32;
--divider: #1a1a20;
--text: #fafafa;
--text-2: #a8a8b0;
--text-3: #6a6a72;
--text-on-accent: #0a0a0a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.08);
--accent-ring: rgba(255,255,255,0.24);
--success: #4ade80;
--success-soft: rgba(74,222,128,0.14);
--warn: #f59e0b;
--warn-soft: rgba(245,158,11,0.14);
--danger: #ff4d5e;
--danger-soft: rgba(255,77,94,0.16);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.5);
--shadow: 0 4px 12px rgba(0,0,0,0.4);
--shadow-lg: 0 24px 60px -20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
--shadow-modal: 0 40px 100px -20px rgba(0,0,0,0.8);
--button-bg: #ffffff;
--button-fg: #0a0a0a;
--button-border: #ffffff;
--button-hover: #e8e8e8;
--button-press: #d4d4d4;
--button-secondary-bg: #16161d;
--button-secondary-fg: #fafafa;
--button-secondary-border: var(--border);
--button-secondary-hover: #1f1f28;
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(255,255,255,0.05);
--field-bg: #16161d;
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: glass — vibrant aurora bg + frosted surfaces
============================================================ */
.theme-glass {
--bg: #08081a;
--surface: rgba(255,255,255,0.06);
--surface-2: rgba(255,255,255,0.10);
--surface-alt: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.14);
--border-strong:rgba(255,255,255,0.22);
--divider: rgba(255,255,255,0.08);
--text: #ffffff;
--text-2: rgba(255,255,255,0.70);
--text-3: rgba(255,255,255,0.50);
--text-on-accent: #08081a;
--accent: #ffffff;
--accent-2: #b15bff;
--accent-soft: rgba(255,255,255,0.12);
--accent-ring: rgba(122,120,255,0.40);
--success: #7aff66;
--success-soft: rgba(122,255,102,0.14);
--warn: #ffce5b;
--warn-soft: rgba(255,206,91,0.14);
--danger: #ff5b6b;
--danger-soft: rgba(255,91,107,0.14);
--button-radius: var(--radius-pill);
--field-radius: 10px;
--card-radius: 22px;
--modal-radius: 22px;
--surface-blur: 20px;
--backdrop: radial-gradient(60% 50% at 20% 20%, rgba(122,120,255,0.55), transparent 60%),
radial-gradient(50% 50% at 80% 30%, rgba(177,91,255,0.50), transparent 60%),
radial-gradient(70% 60% at 50% 100%, rgba(0,229,179,0.35), transparent 60%),
#08081a;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow: 0 10px 40px -10px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.10);
--shadow-lg: 0 30px 80px -30px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.12);
--shadow-modal: 0 40px 100px -30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.14);
--button-bg: #ffffff;
--button-fg: #08081a;
--button-border: transparent;
--button-hover: rgba(255,255,255,0.92);
--button-press: rgba(255,255,255,0.84);
--button-secondary-bg: rgba(255,255,255,0.08);
--button-secondary-fg: #ffffff;
--button-secondary-border: var(--border);
--button-secondary-hover: rgba(255,255,255,0.14);
--button-ghost-fg: #ffffff;
--button-ghost-hover: rgba(255,255,255,0.06);
--field-bg: rgba(255,255,255,0.06);
--field-border: var(--border);
--field-placeholder: var(--text-3);
}
/* ============================================================
THEME: editorial — warm paper, serif display, hairline rules
============================================================ */
.theme-editorial {
--font-display: 'DM Serif Display', 'Times New Roman', Times, serif;
--bg: #f3eee2;
--surface: #fbf8f0;
--surface-2: #f7f2e6;
--surface-alt: #ece6d6;
--border: #d8d0bc;
--border-strong:#b8a988;
--divider: #e2d9c4;
--text: #1c1a14;
--text-2: #5a5044;
--text-3: #8a7d6a;
--text-on-accent: #fbf8f0;
--accent: #1c1a14;
--accent-2: #b85c28; /* terracotta */
--accent-soft: #e8e1cd;
--accent-ring: rgba(28,26,20,0.18);
--success: #3f7a3a;
--success-soft: #dde9d4;
--warn: #a86b14;
--warn-soft: #f3e7c4;
--danger: #a32a1e;
--danger-soft: #f1d6cf;
--button-radius: 3px;
--field-radius: 3px;
--card-radius: 4px;
--modal-radius: 4px;
--shadow-sm: 0 1px 0 rgba(28,26,20,0.06);
--shadow: 0 1px 0 rgba(28,26,20,0.06), 0 6px 24px -12px rgba(28,26,20,0.12);
--shadow-lg: 0 14px 36px -16px rgba(28,26,20,0.18), 0 1px 0 rgba(28,26,20,0.06);
--shadow-modal: 0 30px 60px -20px rgba(28,26,20,0.28);
--button-bg: #1c1a14;
--button-fg: #fbf8f0;
--button-border: #1c1a14;
--button-hover: #2f2a20;
--button-press: #000000;
--button-secondary-bg: transparent;
--button-secondary-fg: #1c1a14;
--button-secondary-border: #1c1a14; /* thick rule */
--button-secondary-hover: rgba(28,26,20,0.06);
--button-ghost-fg: var(--text);
--button-ghost-hover: rgba(28,26,20,0.06);
--field-bg: #fbf8f0;
--field-border: #1c1a14; /* hairline rule */
--field-placeholder: var(--text-3);
}
/* ============================================================
Body backdrop helper — paint --backdrop on the page root.
Glass theme uses this to show the aurora wash.
============================================================ */
.vibn-app {
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
min-height: 100%;
position: relative;
}
.vibn-app::before {
content: "";
position: absolute; inset: 0;
background: var(--backdrop);
z-index: 0; pointer-events: none;
}
.vibn-app > * { position: relative; z-index: 1; }