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
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:
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user