diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..e963573 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..22c19a3 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/fonts.ts b/app/fonts.ts new file mode 100644 index 0000000..a7b8468 --- /dev/null +++ b/app/fonts.ts @@ -0,0 +1,21 @@ +import { JetBrains_Mono, Mona_Sans } from "next/font/google"; + +// next/font downloads the files at build time and serves them from our own +// domain, so no request ever reaches Google (no extra roundtrip, GDPR-safe). + +export const sans = Mona_Sans({ + subsets: ["latin", "latin-ext"], + // The width axis is the main typographic lever (see DESIGN.md §3) + axes: ["wdth"], + variable: "--font-mona", + display: "swap", + preload: true, +}); + +export const mono = JetBrains_Mono({ + subsets: ["latin", "latin-ext"], + variable: "--font-jetbrains", + display: "swap", + // Only needed for small labels, not for the LCP element + preload: false, +}); diff --git a/app/globals.css b/app/globals.css index c56032b..67e4668 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,129 +2,474 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + +/* ── Design tokens ─────────────────────────────────────────────────────── */ + +@theme { + /* Ink scale: luminance only, no hue */ + --color-ink-0: oklch(1 0 0); + --color-ink-50: oklch(0.975 0 0); + --color-ink-100: oklch(0.945 0 0); + --color-ink-200: oklch(0.885 0 0); + --color-ink-300: oklch(0.785 0 0); + --color-ink-400: oklch(0.665 0 0); + --color-ink-500: oklch(0.555 0 0); + --color-ink-600: oklch(0.45 0 0); + --color-ink-700: oklch(0.35 0 0); + --color-ink-800: oklch(0.245 0 0); + --color-ink-900: oklch(0.155 0 0); + --color-ink-950: oklch(0.085 0 0); + --color-ink-1000: oklch(0 0 0); + + --color-signal-error: oklch(0.63 0.21 25); + --color-signal-success: oklch(0.72 0.17 150); + + /* Type scale */ + --text-mega: clamp(3.25rem, 12.5vw, 14rem); + --text-mega--line-height: 0.84; + --text-display: clamp(2.75rem, 8.5vw, 8.5rem); + --text-display--line-height: 0.86; + --text-statement: clamp(2rem, 5vw, 4.75rem); + --text-statement--line-height: 1; + --text-title: clamp(1.5rem, 2.6vw, 2.5rem); + --text-title--line-height: 1.1; + --text-lead: clamp(1.125rem, 1.5vw, 1.375rem); + --text-lead--line-height: 1.5; + --text-body: 1.0625rem; + --text-body--line-height: 1.6; + --text-label: 0.8125rem; + --text-label--line-height: 1.4; + + /* Space and layout */ + --spacing-section: clamp(7rem, 16vh, 14rem); + --spacing-gutter: clamp(1.25rem, 4vw, 4rem); + --container-site: 90rem; + --radius-glass: 1.5rem; + + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1); +} @theme inline { + --font-sans: var(--font-mona), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-jetbrains), ui-monospace, monospace; + --font-heading: var(--font-sans); + + --color-bg: var(--bg); + --color-fg: var(--fg); + --color-fg-muted: var(--fg-muted); + --color-fg-subtle: var(--fg-subtle); + --color-line: var(--line); + --color-surface: var(--surface); + + /* shadcn/ui bridge */ --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) * 1.4); --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); +} + +/* ── Themes ────────────────────────────────────────────────────────────── */ + +:root, +[data-theme="light"] { + --bg: var(--color-ink-0); + --fg: var(--color-ink-1000); + --fg-muted: var(--color-ink-600); + --fg-subtle: var(--color-ink-400); + --line: var(--color-ink-200); + --surface: var(--color-ink-100); +} + +[data-theme="dark"] { + --bg: var(--color-ink-1000); + --fg: var(--color-ink-0); + --fg-muted: var(--color-ink-400); + --fg-subtle: var(--color-ink-600); + --line: var(--color-ink-800); + --surface: var(--color-ink-900); } :root { - --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); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --radius: 0.75rem; + /* Static light position for the glass edge highlight */ + --light-x: 30%; + --light-y: 0%; } -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --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); - --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); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); +/* + * Custom properties resolve where they are declared, so the shadcn mapping + * is re-declared on every themed element. + */ +:root, +[data-theme] { + --background: var(--bg); + --foreground: var(--fg); + --card: var(--bg); + --card-foreground: var(--fg); + --popover: var(--bg); + --popover-foreground: var(--fg); + --primary: var(--fg); + --primary-foreground: var(--bg); + --secondary: var(--surface); + --secondary-foreground: var(--fg); + --muted: var(--surface); + --muted-foreground: var(--fg-muted); + --accent: var(--surface); + --accent-foreground: var(--fg); + --destructive: var(--color-signal-error); + --border: var(--line); + --input: var(--color-ink-500); + --ring: var(--fg); } @layer base { * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; + @apply border-border; } html { @apply font-sans; + scroll-padding-top: 6rem; } -} \ No newline at end of file + body { + @apply bg-background text-foreground; + font-size: var(--text-body); + line-height: 1.6; + } + [data-theme] { + background-color: var(--bg); + color: var(--fg); + } + ::selection { + background: var(--fg); + color: var(--bg); + } +} + +:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible { + outline: 2px solid var(--fg); + outline-offset: 3px; + box-shadow: 0 0 0 5px var(--bg); +} + +/* ── Smooth scroll (Lenis) ────────────────────────────────────────────── */ + +html.lenis, +html.lenis body { + height: auto; +} +.lenis:not(.lenis-autoToggle).lenis-stopped { + overflow: clip; +} +.lenis [data-lenis-prevent] { + overscroll-behavior: contain; +} +.lenis.lenis-smooth iframe { + pointer-events: none; +} + +/* ── Layout & type utilities ──────────────────────────────────────────── */ + +@utility container-site { + width: 100%; + max-width: var(--container-site); + margin-inline: auto; + padding-inline: var(--spacing-gutter); +} + +/* Poster type: heavy, condensed, uppercase */ +@utility type-poster { + font-variation-settings: "wght" 800, "wdth" 75; + text-transform: uppercase; + letter-spacing: -0.01em; + text-wrap: balance; +} + +@utility type-statement { + font-size: var(--text-statement); + font-variation-settings: "wght" 560, "wdth" 90; + line-height: 1; + letter-spacing: -0.025em; + text-wrap: balance; +} + +@utility type-title { + font-size: var(--text-title); + font-variation-settings: "wght" 600, "wdth" 90; + line-height: 1.1; + letter-spacing: -0.02em; + text-wrap: balance; +} + +@utility type-body { + line-height: 1.6; + max-width: 62ch; + text-wrap: pretty; +} + +@utility type-label { + font-family: var(--font-mono); + font-size: var(--text-label); + color: var(--fg-muted); +} + +/* + * Outlined type. Variable fonts have overlapping contours that a plain stroke + * would draw inside the letters, so the stroke is painted first at double + * width and the fill (in the background color) covers its inner half. + */ +@utility text-outline { + color: var(--bg); + -webkit-text-stroke: max(2px, 0.024em) var(--stroke-color, var(--fg)); + paint-order: stroke fill; +} + +/* ── Grain over everything ────────────────────────────────────────────── */ + +body::after { + content: ""; + position: fixed; + inset: -50%; + z-index: 90; + pointer-events: none; + opacity: 0.06; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1.2 -0.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* ── Glass ────────────────────────────────────────────────────────────── */ + +.glass { + position: relative; + isolation: isolate; + background: linear-gradient( + 135deg, + rgb(255 255 255 / 0.08), + rgb(255 255 255 / 0.02) + ); + backdrop-filter: blur(24px) saturate(1.2); + border: 1px solid rgb(255 255 255 / 0.12); + border-radius: var(--radius-glass); + box-shadow: + 0 1px 0 0 rgb(255 255 255 / 0.14) inset, + 0 30px 80px -30px rgb(0 0 0 / 0.6); +} +.glass::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + background: radial-gradient( + 120% 90% at var(--light-x) var(--light-y), + rgb(255 255 255 / 0.1), + transparent 60% + ); +} +@media (prefers-reduced-transparency: reduce) { + .glass { + backdrop-filter: none; + background: var(--color-ink-900); + } +} + +/* ── Intro gating ─────────────────────────────────────────────────────── */ + +/* + * An inline script adds `is-intro` before the first paint when the preloader + * should play. Everything that animates in after the preloader is paused while + * that class is present. Without JavaScript the class never appears and the + * page simply animates in. + */ +.preloader { + display: none; + clip-path: inset(0 0 0 0); + transition: clip-path 1000ms var(--ease-in-out-quart); +} +html.is-intro .preloader, +.preloader[data-state="leaving"] { + display: flex; +} +.preloader[data-state="leaving"] { + clip-path: inset(0 0 100% 0); +} +html.is-intro { + overflow: hidden; +} +html.is-intro :is(.hero-char, .hero-fade) { + animation-play-state: paused; +} + +@keyframes char-in { + from { + translate: 0 105%; + rotate: 6deg; + } + to { + translate: 0 0; + rotate: 0deg; + } +} +@keyframes fade-up { + from { + opacity: 0; + translate: 0 1.5rem; + } +} + +.hero-char { + display: inline-block; + transform-origin: 0 100%; + animation: char-in 1100ms var(--ease-out-expo) calc(150ms + var(--i) * 28ms) both; +} +.hero-fade { + animation: fade-up 1200ms var(--ease-out-expo) calc(700ms + var(--d, 0ms)) both; +} + +/* ── Header: inverts itself over light and dark sections ──────────────── */ + +.site-header { + mix-blend-mode: difference; + color: white; + transition: translate 500ms var(--ease-in-out-quart); +} +.site-header[data-hidden="true"] { + translate: 0 -120%; +} + +/* ── Sound toggle equalizer ───────────────────────────────────────────── */ + +@keyframes equalizer { + 0%, + 100% { + scale: 1 0.25; + } + 50% { + scale: 1 1; + } +} +@utility animate-equalizer { + animation: equalizer 0.9s ease-in-out infinite; +} + +/* ── Custom cursor ────────────────────────────────────────────────────── */ + +@media (hover: hover) and (pointer: fine) { + html.has-cursor, + html.has-cursor * { + cursor: none !important; + } + html.has-cursor :is(input, textarea, select) { + cursor: text !important; + } +} + +/* ── Services: rows wipe to inverted on hover ─────────────────────────── */ + +.service-row { + position: relative; + isolation: isolate; +} +.service-row::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fg); + scale: 1 0; + transform-origin: 50% 100%; + transition: scale 600ms var(--ease-in-out-quart); +} +.service-row :is(.service-name, .service-meta) { + transition: + color 400ms var(--ease-in-out-quart), + translate 600ms var(--ease-in-out-quart); +} +.service-row .service-details { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 600ms var(--ease-in-out-quart); +} +@media (hover: hover) and (pointer: fine) { + .service-row:hover::before, + .service-row:focus-within::before { + scale: 1 1; + transform-origin: 50% 0%; + } + .service-row:is(:hover, :focus-within) :is(.service-name, .service-meta) { + color: var(--bg); + } + .service-row:is(:hover, :focus-within) .service-name { + translate: 1.5rem 0; + } + .service-row:is(:hover, :focus-within) .service-details { + grid-template-rows: 1fr; + } +} +/* Touch devices: details are always visible */ +@media not ((hover: hover) and (pointer: fine)) { + .service-row .service-details { + grid-template-rows: 1fr; + } +} + +/* ── Scroll-driven reveal (progressive enhancement) ──────────────────── */ + +@supports (animation-timeline: view()) { + .reveal { + animation: fade-up linear both; + animation-timeline: view(); + animation-range: entry 5% cover 30%; + } + .clip-reveal { + animation: clip-reveal linear both; + animation-timeline: view(); + animation-range: entry 0% cover 40%; + } + @keyframes clip-reveal { + from { + clip-path: inset(18% 8% 18% 8% round 3rem); + } + to { + clip-path: inset(0 0 0 0 round var(--radius-glass)); + } + } +} + +/* ── Reduced motion ───────────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + .reveal, + .clip-reveal { + animation: none !important; + } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..4f3109e --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..fa6482d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,29 +1,69 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; + +import { site } from "@/modules/shared/config/site"; +import { introScript } from "@/modules/shared/lib/intro"; +import { CustomCursor } from "@/modules/shared/ui/components/custom-cursor"; +import { InlineScript } from "@/modules/shared/ui/components/inline-script"; +import { Preloader } from "@/modules/shared/ui/components/preloader"; +import { SiteFooter } from "@/modules/shared/ui/components/site-footer"; +import { SiteHeader } from "@/modules/shared/ui/components/site-header"; +import { SmoothScroll } from "@/modules/shared/ui/components/smooth-scroll"; +import { SoundEffects } from "@/modules/shared/ui/components/sound-effects"; + +import { mono, sans } from "./fonts"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + metadataBase: new URL(site.url), + title: { + default: "since3 – Websites und Software aus Steinfurt", + template: "%s – since3", + }, + description: site.description, + openGraph: { + type: "website", + locale: "de_DE", + siteName: site.name, + url: site.url, + }, +}; + +export const viewport: Viewport = { + themeColor: "#000000", + colorScheme: "dark", }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( + // The intro script adds a class before hydration - {children} + + + + +
+ + Zum Inhalt springen + + + + + + + + +
+ {children} +
+ + ); } diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..abe03c9 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from "next"; + +import { site } from "@/modules/shared/config/site"; + +// Used when the site is added to a home screen (Android, desktop PWA install) +export default function manifest(): MetadataRoute.Manifest { + return { + name: "since3 – Digitalagentur aus Steinfurt", + short_name: site.name, + description: site.description, + start_url: "/", + display: "standalone", + background_color: "#000000", + theme_color: "#000000", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + { + src: "/icons/icon-maskable-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/app/page.tsx b/app/page.tsx index 8801369..d5cd104 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,5 @@ +import { LandingPageView } from "@/modules/landingPage/ui/views/landing-page-view"; export default function Home() { - return ( -
- -
- ); + return ; } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 997e0c7..97433b4 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,39 +1,36 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" +import { ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button relative inline-flex shrink-0 items-center justify-center rounded-full border font-medium whitespace-nowrap transition-[background-color,border-color,color,scale] duration-200 ease-out select-none active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + // Black on light, white on dark + solid: + "border-transparent bg-fg text-bg hover:bg-ink-800 dark:hover:bg-ink-200", + // Hairline only outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + "border-fg/20 bg-transparent text-fg hover:border-fg/60 aria-expanded:border-fg/60", + // Only on top of glass surfaces or dark sections + glass: + "border-white/15 bg-white/10 text-white backdrop-blur-md hover:bg-white/20", + ghost: "border-transparent text-fg hover:bg-fg/5 aria-expanded:bg-fg/5", + link: "rounded-none border-transparent px-0 text-fg underline-offset-4 hover:underline", }, size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + default: "h-11 gap-2 px-5 text-[0.9375rem]", + sm: "h-9 gap-1.5 px-4 text-sm", + lg: "h-13 gap-2 px-7 text-base", + icon: "size-10", + "icon-sm": "size-8", }, }, defaultVariants: { - variant: "default", + variant: "solid", size: "default", }, } @@ -41,7 +38,7 @@ const buttonVariants = cva( function Button({ className, - variant = "default", + variant = "solid", size = "default", ...props }: ButtonPrimitive.Props & VariantProps) { @@ -54,4 +51,22 @@ function Button({ ) } -export { Button, buttonVariants } +/** + * The arrow is not part of the label: it appears on hover only. + * Place it as the last child of a button or a link styled with `buttonVariants`. + */ +function ButtonArrow({ className }: { className?: string }) { + return ( + + ) +} + +export { Button, ButtonArrow, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..38d3386 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,96 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +/** + * Two strictly separate variants (DESIGN.md §8): + * - `flat`: hairline, no shadow, no blur. For lists. + * - `glass`: tool panels only. Never mix both in one section. + */ +const cardVariants = cva("flex flex-col text-card-foreground", { + variants: { + variant: { + flat: "card-flat rounded-2xl border border-line bg-transparent", + glass: "glass", + }, + }, + defaultVariants: { + variant: "flat", + }, +}) + +function Card({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { + return ( +

+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, + cardVariants, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..8886e9d --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..8219a7f --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +