feat(web): unify console + docs on @unom/ui; host OpenAPI via Scalar
apple / swift (push) Successful in 55s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m14s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
ci / bench (push) Successful in 4m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m18s
docker / deploy-docs (push) Successful in 19s
android / android (push) Successful in 3m12s

Move the management console (web/) off shadcn/ui to the shared @unom/ui
design system the marketing site + docs are built on, on the punktfunk
violet brand over dark chrome:

- Add @unom/ui/@unom/style/motion/radix-ui/zod + Geist; web/.npmrc maps the
  @unom scope (packages are public-read, so CI needs no npm auth).
- styles.css: one dark-violet palette (#141019/#1c1530, brand #6c5bf3 ->
  #a79ff8) exposed under BOTH the shadcn token names the routes use and
  @unom/ui's contract, so routes + components both resolve; pulls in
  @unom/ui's material gloss + easings.
- components/ui/* now back onto @unom/ui (AnimatedButton/InputText/Label/
  AnimatedCard); brand-mark/wordmark/logo replace the generic Radio icon in
  the shell + login.
- MaterialProvider (specular gloss) at the root. No UI sounds, like the site.

docs-site: new /api route renders the host management REST API as an
interactive Scalar reference (reads public/openapi.json, a snapshot of
docs/api/openapi.json), branded violet and linked from the top nav, the
docs sidebar, the landing page, and host-cli.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:00:46 +00:00
parent 75d5a6d7fb
commit f3555d5eb5
26 changed files with 3409 additions and 155 deletions
+17 -14
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Activity, Server, Users, KeyRound, LibraryBig, Settings, Radio } from 'lucide-react'
import { Activity, Server, Users, KeyRound, LibraryBig, Settings } from 'lucide-react'
import { BrandMark } from '@/components/brand-mark'
import { Wordmark } from '@/components/wordmark'
import { m } from '@/paraglide/messages'
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
import { cn } from '@/lib/utils'
@@ -21,21 +23,22 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="flex min-h-screen">
{/* Desktop sidebar (≥ sm). */}
<aside className="hidden w-60 shrink-0 flex-col border-r bg-card/40 p-4 sm:flex">
<div className="mb-6 flex items-center gap-2 px-2">
<Radio className="size-5 text-[var(--success)]" />
<div>
<div className="font-semibold leading-tight">{m.app_name()}</div>
<div className="text-xs text-muted-foreground">{m.app_tagline()}</div>
</div>
</div>
<Link
to="/"
aria-label="punktfunk"
className="mb-7 flex items-center gap-2 px-2 pt-1"
>
<BrandMark className="size-7 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
<Wordmark className="h-4" />
</Link>
<nav className="flex flex-col gap-1">
{NAV.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
activeOptions={{ exact: to === '/' }}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
activeProps={{ className: 'bg-accent text-foreground font-medium' }}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
activeProps={{ className: 'bg-primary/15 text-foreground font-medium' }}
>
<Icon className="size-4" />
{label()}
@@ -50,8 +53,8 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="flex flex-1 flex-col overflow-x-hidden">
{/* Mobile top bar (< sm): brand + language. The sidebar is hidden here. */}
<header className="flex items-center gap-2 border-b bg-card/40 px-4 py-3 sm:hidden">
<Radio className="size-5 text-[var(--success)]" />
<div className="font-semibold leading-tight">{m.app_name()}</div>
<BrandMark className="size-6" />
<Wordmark className="h-3.5" />
<div className="ml-auto">
<LanguageSwitcher />
</div>
@@ -74,7 +77,7 @@ export function AppShell({ children }: { children: ReactNode }) {
to={to}
activeOptions={{ exact: to === '/' }}
className="flex flex-1 flex-col items-center gap-1 py-2 text-[10px] text-muted-foreground transition-colors"
activeProps={{ className: 'text-foreground' }}
activeProps={{ className: 'text-[var(--brand-light)]' }}
>
<Icon className="size-5" />
<span className="leading-none">{label()}</span>
@@ -96,7 +99,7 @@ function LanguageSwitcher() {
className={cn(
'rounded px-2 py-1 text-xs uppercase transition-colors',
l === current
? 'bg-secondary text-secondary-foreground font-medium'
? 'bg-primary/20 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
+31
View File
@@ -0,0 +1,31 @@
// punktfunk brand mark: two overlapping circles forming a lens — the violet
// brand identity (flattened from the clients/apple punktfunk_Logo.icon, shared
// verbatim with the marketing site + docs). Back-to-front: large light-violet
// circle, deep-violet circle, light highlight where they overlap.
export function BrandMark({ className }: { className?: string }) {
return (
<svg
aria-label="punktfunk"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"
className={className}
>
<title>punktfunk</title>
<path
d="M403.037,791.672c107.586,0 194.41,-86.824 194.41,-194.41c0,-107.586 -86.824,-194.41 -194.41,-194.41c-107.586,0 -194.41,86.824 -194.41,194.41c0,107.586 86.824,194.41 194.41,194.41Z"
fill="#a79ff8"
/>
<path
d="M735.276,540.321c76.075,-76.075 76.075,-198.862 0,-274.937c-76.075,-76.075 -198.862,-76.075 -274.937,0c-76.075,76.075 -76.075,198.862 0,274.937c76.075,76.075 198.862,76.075 274.937,0Z"
fill="#6c5bf3"
/>
<path
d="M647.84,590.737c-64.853,17.403 -136.871,0.597 -187.885,-50.416c-51.013,-51.013 -67.819,-123.032 -50.416,-187.885c64.853,-17.403 136.871,-0.597 187.885,50.416c51.013,51.013 67.819,123.032 50.416,187.885Z"
fill="#d2c9fb"
/>
</svg>
)
}
export default BrandMark
+17
View File
@@ -0,0 +1,17 @@
import { cn } from '@/lib/utils'
import { BrandMark } from './brand-mark'
import { Wordmark } from './wordmark'
// Full punktfunk lockup: the lens mark anchored to the top-left corner of the
// "funk" wordmark. Size the lockup with a width on the wrapper (e.g. `w-40`);
// the mark scales as a fraction of that width.
export function Logo({ className }: { className?: string }) {
return (
<div className={cn('relative inline-block', className)}>
<BrandMark className="absolute left-0 top-0 w-[24%] -translate-x-[55%] -translate-y-[58%] drop-shadow-[0_4px_24px_rgba(108,91,243,0.45)]" />
<Wordmark className="block h-auto w-full" />
</div>
)
}
export default Logo
+9 -38
View File
@@ -1,41 +1,12 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import type { ComponentProps } from 'react'
import { AnimatedButton, buttonVariants } from '@unom/ui/button'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
// The console's Button IS @unom/ui's animated button — pill shape, specular
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
// the shared brand tokens. Same variant/size vocabulary the routes already use
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
export type ButtonProps = ComponentProps<typeof AnimatedButton>
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = AnimatedButton
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
),
)
Button.displayName = 'Button'
export { Button, buttonVariants }
export { buttonVariants }
+23 -9
View File
@@ -1,14 +1,24 @@
import * as React from 'react'
import type { ComponentProps } from 'react'
import { AnimatedCard } from '@unom/ui/card'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
),
// The console's Card IS @unom/ui's animated card — a `bg-neutral` (#1c1530)
// surface with a soft brand-violet ring, on-mount motion + material gloss
// (enabled via UnomProviders). We keep the composed shadcn-style sub-component
// API (CardHeader/Title/Description/Content/Footer own their own padding), so
// the card defaults to `padding={false}` to avoid doubling it, and soften the
// 2px ring to a subtle 1px brand tint.
type CardProps = ComponentProps<typeof AnimatedCard>
const Card = ({ className, padding = false, children, ...props }: CardProps) => (
<AnimatedCard
padding={padding}
className={cn('ring-1 ring-accent/40', className)}
{...props}
>
{children}
</AnimatedCard>
)
Card.displayName = 'Card'
@@ -21,7 +31,11 @@ CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
)
CardTitle.displayName = 'CardTitle'
+3 -19
View File
@@ -1,19 +1,3 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
)
Input.displayName = 'Input'
export { Input }
// The console's Input IS @unom/ui's form input (shadcn-compatible tokens:
// border-input / muted-foreground / ring, material gloss via UnomProviders).
export { InputText as Input } from '@unom/ui/form/input-text'
+2 -18
View File
@@ -1,18 +1,2 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
),
)
Label.displayName = 'Label'
export { Label }
// The console's Label IS @unom/ui's form label (radix-backed, text-main).
export { Label } from '@unom/ui/form/label'
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
import { MaterialProvider } from '@unom/ui/material'
// Turn on @unom/ui's specular "material" gloss for the whole console — the same
// design system the punktfunk marketing site is built on. SSR-safe (material
// gates its effects on a mounted flag). No SoundProvider: like the marketing
// site, the console stays silent (@unom/ui's useSound no-ops without a provider).
const MATERIAL = {
button: { enabled: true },
card: { enabled: true },
dialog: { enabled: true },
tabs: { enabled: true },
select: { enabled: true },
input: { enabled: true },
checkbox: { enabled: true },
toast: { enabled: true },
} as const
export function UnomProviders({ children }: { children: ReactNode }) {
return <MaterialProvider theme={MATERIAL}>{children}</MaterialProvider>
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils'
// The punktfunk "funk" wordmark — the real brand typo, vectorised from the
// marketing logo. currentColor so it recolours per surface; defaults to the
// light-violet lens highlight that reads on the dark console chrome. Size via
// height (e.g. `h-5`); width follows the viewBox.
export function Wordmark({ className }: { className?: string }) {
return (
<svg
role="img"
aria-label="punktfunk"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 579 136"
fill="currentColor"
className={cn('w-auto text-highlight', className)}
>
<title>punktfunk</title>
<path d="M16.782,16.051l0,102.687l31.253,0l0,-35.563l73.436,0l0,-23.555l-73.436,0l0,-19.398l77.285,0l0,-24.171l-108.537,0Z" />
<path d="M131.785,16.051l0,47.264c0.154,16.627 0.154,16.627 0.308,20.014c0.77,15.087 2.463,21.4 7.544,26.634c7.698,8.16 20.014,10.315 59.272,10.315c23.863,0 34.178,-0.616 43.415,-2.463c11.7,-2.463 19.552,-10.623 21.246,-22.323c0.924,-7.236 1.078,-8.929 1.54,-32.176l0,-47.264l-31.253,0l0,47.264c0,2.155 -0.154,7.082 -0.308,10.623c-0.462,9.699 -1.232,12.47 -3.695,15.087c-3.387,3.695 -9.853,4.619 -31.407,4.619c-26.634,0 -32.638,-1.693 -34.332,-9.853c-0.77,-4.157 -0.77,-4.311 -1.078,-20.476l0,-47.264l-31.253,0Z" />
<path d="M271.575,15.943l0,102.687l31.868,0l-0.77,-76.669l3.387,0l54.038,76.669l54.346,0l0,-102.687l-31.868,0l0.77,76.515l-3.233,0l-53.73,-76.515l-54.808,0Z" />
<path d="M420.91,15.943l0,102.687l31.253,0l0,-39.258l17.089,0l46.032,39.258l47.418,0l-64.353,-52.344l59.426,-50.959l-47.88,0l-40.644,37.873l-17.089,0l0,-37.257l-31.253,0Z" />
</svg>
)
}
export default Wordmark
+10 -6
View File
@@ -7,7 +7,9 @@ import {
useRouterState,
} from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import '@fontsource-variable/geist'
import { AppShell } from '@/components/app-shell'
import { UnomProviders } from '@/components/unom-providers'
import appCss from '@/styles.css?url'
export interface RouterContext {
@@ -36,13 +38,15 @@ function RootComponent() {
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
<UnomProviders>
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</UnomProviders>
<Scripts />
</body>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { Radio } from 'lucide-react'
import { BrandMark } from '@/components/brand-mark'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -54,7 +54,7 @@ function LoginPage() {
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex items-center gap-2">
<Radio className="size-5 text-[var(--success)]" />
<BrandMark className="size-6 drop-shadow-[0_2px_12px_rgba(108,91,243,0.45)]" />
<span className="font-semibold">{m.app_name()}</span>
</div>
<CardTitle>{m.login_title()}</CardTitle>
+109 -37
View File
@@ -1,61 +1,97 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './timing-functions.css';
/* @unom/ui's specular "material" gloss layer (.material / .material-fx). Colours
derive from --neutral / --neutral-accent via the --mat-* knobs it ships. */
@import '@unom/ui/styles/material.css';
@custom-variant dark (&:is(.dark *));
/* shadcn/ui design tokens (neutral base, dark-first to match a streaming console). */
/* Pull @unom/ui's compiled component classes (bg-neutral, rounded-card,
p-padding-card, ring-accent, h-input-height, material…) into the Tailwind 4
scan so its utilities aren't purged. This is the shared design system the
punktfunk marketing site + docs are built on. */
@source '../node_modules/@unom/ui/dist/**/*.{js,mjs}';
/* ── punktfunk brand · dark violet product chrome ───────────────────────────
The console runs dark-only (html.dark). Surfaces are the violet-tinted
app-icon chrome (#141019 / #1c1530), the brand is the violet lens mark
(#6c5bf3 → #a79ff8 in dark). The token set feeds BOTH @unom/ui's semantic
contract (--brand/--primary/--accent/--neutral/--main…) and the shadcn-style
tokens the console's primitives + routes use (--background/--card/--muted…),
mapped onto one palette so both render the same identity. */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--success: oklch(0.6 0.13 160);
}
.dark {
--background: oklch(0.145 0 0);
/* Brand — the violet lens mark (from the punktfunk app icon). */
--pf-brand: #6c5bf3; /* deep violet */
--pf-brand-light: #a79ff8; /* light violet — reads better on dark */
--pf-highlight: #d2c9fb; /* lens highlight */
/* Surfaces (violet-tinted dark chrome). */
--background: #141019;
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: #1c1530;
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: #1c1530;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--muted: #1f1830;
--muted-foreground: oklch(0.728 0.03 286);
--secondary: #241c3d;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
/* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour,
so we tint it toward the brand violet. */
--accent: var(--pf-brand);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--border: #2a2148;
--input: #2a2148;
--ring: var(--pf-brand-light);
/* Primary = the brand (buttons, active nav, default badges). */
--primary: var(--pf-brand-light);
--primary-foreground: #141019;
--success: oklch(0.7 0.15 160);
--destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0);
/* ── @unom/ui semantic token contract (its components read these names). ── */
--main: var(--foreground);
--brand: var(--pf-brand);
--brand-light: var(--pf-brand-light);
--highlight: var(--pf-highlight);
--neutral: var(--card); /* @unom card default surface (bg-neutral) */
--neutral-accent: var(--secondary); /* accent / nested surface (bg-neutral-accent) */
--neutral-highlight: var(--border);
--error: var(--destructive);
--font-display: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
--radius-card-min: var(--radius);
}
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
and @unom/ui's, resolved to one set of values. */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-button: 9999px;
--radius-card: calc(var(--radius) * 2);
--radius-main: calc(var(--radius) * 2);
--spacing-input-height: 3rem;
--spacing-padding-card: 1.25rem;
--spacing-card: 1.5rem;
--spacing-main: 15px;
--font-sans: var(--font-sans);
--font-display: var(--font-display);
/* shadcn-style colour tokens. */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -76,6 +112,41 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* @unom/ui colour tokens. */
--color-main: var(--main);
--color-brand: var(--brand);
--color-brand-light: var(--brand-light);
--color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight);
--color-error: var(--error);
}
/* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */
@theme {
--animate-accordion-down: accordion-down 0.4s var(--ease-out-quart);
--animate-accordion-up: accordion-up 0.4s var(--ease-out-quart);
--animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart);
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
@keyframes accordion-down {
from { height: 0; }
to { height: var(--radix-accordion-content-height); }
}
@keyframes accordion-up {
from { height: var(--radix-accordion-content-height); }
to { height: 0; }
}
@keyframes collapsible-down {
from { height: 0; opacity: 0; }
to { height: var(--radix-collapsible-content-height); opacity: 1; }
}
@keyframes collapsible-up {
from { height: var(--radix-collapsible-content-height); opacity: 1; }
to { height: 0; opacity: 0; }
}
}
@layer base {
@@ -85,6 +156,7 @@
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
+25
View File
@@ -0,0 +1,25 @@
/* Penner easing tokens — shared with the punktfunk marketing site + @unom/ui.
@unom/ui's accordion/collapsible/material animations resolve these by name. */
@theme {
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
}