Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.

Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Steffi Müller
2026-08-04 12:22:32 +02:00
parent 3785d08e97
commit a482bf5b09
60 changed files with 7366 additions and 72 deletions

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { GlassCard } from "./components/GlassCard";
import { Button } from "./components/Button";
import { BottomNav, type NavItem } from "./components/BottomNav";
import { PlateChart } from "./components/PlateChart";
/**
* Demo page — every component rendered over a few different background
* gradients so the backdrop-blur actually has something to refract.
* Drop this on a route in Cursor (e.g. /showcase) to sanity-check the
* system before wiring it into real screens.
*/
export default function ComponentShowcase() {
const [dark, setDark] = useState(false);
const [active, setActive] = useState("plate");
const [loading, setLoading] = useState(false);
const navItems: NavItem[] = [
{ key: "plate", icon: "🍽️", label: "Teller", active: active === "plate", onClick: () => setActive("plate") },
{ key: "pantry", icon: "🧺", label: "Vorrat", active: active === "pantry", onClick: () => setActive("pantry") },
{ key: "builder", icon: "🧩", label: "Builder", active: active === "builder", onClick: () => setActive("builder") },
{ key: "settings", icon: "⚙️", label: "Mehr", active: active === "settings", onClick: () => setActive("settings") },
];
return (
<div className={dark ? "dark" : ""}>
<div
className="min-h-screen font-body"
style={{ background: "var(--page-gradient)", color: "var(--ink)" }}
>
<div className="mx-auto max-w-md space-y-10 px-5 pb-32 pt-10">
<header className="flex items-center justify-between">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[var(--ink-faint)]">
Pocket Pascal · Component Showcase
</p>
<h1 className="font-display text-3xl font-black">Elevated Surfaces</h1>
</div>
<Button variant="secondary" onClick={() => setDark((d) => !d)}>
{dark ? "☀️ Light" : "🌙 Dark"}
</Button>
</header>
{/* GlassCard variants */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Card
</h2>
<GlassCard className="p-5">
<p className="font-display text-lg font-bold">Default</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Static container low-transparency surface, real elevation shadow, raised bevel edge.
</p>
</GlassCard>
<GlassCard variant="interactive" className="p-5" onClick={() => {}}>
<p className="font-display text-lg font-bold">Interactive</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">Tap me settles down 1px, shadow softens.</p>
</GlassCard>
</section>
{/* Dining Plate chart inside a GlassCard */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Dining Plate Chart
</h2>
<GlassCard className="flex flex-col items-center gap-4 p-6">
<PlateChart
size={220}
centerValue="420"
centerLabel="kcal"
slices={[
{ id: "protein", label: "Protein", value: 30, colors: ["#D9F227", "#B8CE1E"] },
{ id: "obstgem", label: "Obst/Gemüse", value: 30, colors: ["#FF6A3D", "#FF8F6B"] },
{ id: "fett", label: "Fett", value: 15, colors: ["#FFB300", "#FFC94A"] },
{ id: "kh", label: "KH", value: 25, colors: ["#1FD9C4", "#5FE0C4"] },
]}
/>
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs font-bold text-[var(--ink-soft)]">
<span>🟡 Protein 30%</span>
<span>🟠 Obst/Gemüse 30%</span>
<span>🟨 Fett 15%</span>
<span>🟢 KH 25%</span>
</div>
</GlassCard>
</section>
{/* Buttons */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Buttons
</h2>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Guter Teller!</Button>
<Button variant="secondary">Details</Button>
<Button variant="fab" icon="" aria-label="Hinzufügen" />
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" loading={loading} onClick={() => setLoading((l) => !l)}>
{loading ? "Speichert…" : "Speichern (Loading toggle)"}
</Button>
<Button variant="secondary" disabled>
Disabled
</Button>
</div>
</section>
</div>
<BottomNav items={navItems} />
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
# Pocket Pascal — Elevated Surface Component Library
Drop this folder's contents into your Cursor React + TypeScript + Tailwind project. It's framework-agnostic (no Next.js-specific imports), works in Vite or Next.
> **Pivoted from glassmorphism to elevation (2026-08-03).** The first pass used real see-through glass (12% white fill, 16px blur) — over a plain white/dark background it barely reads as anything, which is exactly the feedback that came back: "I don't see the glass effect." Rather than fight a page background that has nothing distinct behind it to refract, the system now uses **low-transparency solid surfaces (~90% opaque) with visible elevation shadows**, and buttons that visibly **press inward** on tap (inset shadow + 1px downward shift + slight darkening) instead of just scaling down. The `--glass-*` variable names in `theme.css` were kept to minimize the diff across components, but they now mean "surface fill / surface blur," not transparent glass — see the comment block at the top of `theme.css`.
## Setup
1. **Copy files**
- `styles/theme.css` → your global stylesheet, imported once in your app root (e.g. `main.tsx` or `app/layout.tsx`), **after** `@tailwind base/components/utilities`.
- `lib/cx.ts`, `components/*.tsx` → into your `src/` at matching paths, or adjust the relative imports.
- `public/fonts/*.woff2` → your project's `public/fonts/` (already the correct static latin-subset files, no need to re-fetch from Google).
- `ComponentShowcase.tsx` → mount on a scratch route to sanity-check everything renders before wiring into real screens.
2. **Tailwind config** — enable class-based dark mode so the `.dark` class in `theme.css` works:
```ts
// tailwind.config.ts
export default {
darkMode: "class",
content: ["./src/**/*.{ts,tsx}"],
theme: { extend: {} },
};
```
No plugin needed — `backdrop-filter`, `active:`, and arbitrary values (`bg-[image:var(--x)]`) are all core Tailwind v3+. This library leans on CSS variables + inline `style` for the gradient/blur values rather than baking them into the Tailwind theme, so it works the same whether or not you extend the config further.
3. **Toggle dark mode** by adding/removing the `dark` class on `<html>` (or any ancestor — see `ComponentShowcase.tsx` for a self-contained example that wraps in a `div` instead, for demo purposes only; in a real app put it on `<html>`).
## What's in here
| File | Purpose |
|---|---|
| `styles/theme.css` | All design tokens: surface fill/blur/bevel-border, three-state shadows (resting/elevated/pressed), brand colors, surface + action gradients, `@font-face`, the `.glass-border` masked-bevel utility, PWA polish resets. |
| `components/GlassCard.tsx` | Primary container. `variant="default" \| "interactive"`. Real elevation shadow, settles down 1px on tap. |
| `components/BottomNav.tsx` | Floating pill nav, safe-area aware, gradient-on-active-icon, items press inward on tap. |
| `components/Button.tsx` | `variant="primary" \| "secondary" \| "fab"`, plus `loading`/`disabled` states. Press state = inset shadow + 1px shift + darken, not just scale. |
| `components/PlateChart.tsx` | SVG donut styled as a ceramic plate (rim + recessed well + gapped gradient slices + center label). |
| `ComponentShowcase.tsx` | Demo page rendering all of the above over the ambient page gradient, with a light/dark toggle. |
## Deliberate deviations from the original brief — and why
**Text color on gradient buttons/badges is `--ink` (near-black), not white.**
The brand's action gradients (lime → teal, coral → amber) are light/high-luminance by design — they're the same tokens already WCAG-audited for the rest of the Pocket Pascal system (see `/docs/CONCEPT.html`, decision log). White text on them fails AA contrast outright; a text-shadow doesn't fix a 2:1 ratio. Dark `--ink` text on these gradients measures 5.610.6:1 in both themes. If you introduce a new, genuinely dark gradient (e.g. for a destructive/caution CTA), white text + `text-shadow: 0 1px 2px rgba(0,0,0,0.25)` is fine there — just check the ratio first.
**`--gradient-caution` was added** (coral → deep red) for destructive actions, since the brief's two named gradients (`primary`, `accent`) didn't cover that case and the brand already has a `--color-caution` token.
**Fonts are loaded via local `@font-face` + static `.woff2` files, not `next/font` or a Google Fonts `<link>`.** Keeps `theme.css` portable across Vite/Next, and avoids a runtime dependency on Google's CDN. The five files in `public/fonts/` are the exact latin-subset statics used everywhere else in this project.
**`.glass-border` uses a masked pseudo-gradient, not `border-image`.** `border-image` ignores `border-radius` — it would square off every rounded corner. The mask-composite technique in `theme.css` respects `border-radius: inherit`, so it works on the 3xl card, the pill nav, and the 2xl buttons without extra per-component CSS.
**Pressed state is inset-shadow + 1px shift + darken, applied via `active:` Tailwind classes referencing CSS vars (`active:shadow-[var(--shadow-pressed)]`), not JS state or `active:scale`.** Scale alone reads as "shrinking," not "pushed in" — the eye needs the shadow to flip from outside (elevated) to inside (pressed) to sell the depth illusion. `Button` and the `BottomNav` items use this; `GlassCard`'s interactive variant deliberately uses a milder version (settle 1px + soften shadow, no inset) since a card is a container you tap *into*, not a switch you press.
## Known follow-ups (not done here)
- `PlateChart` gap is computed from a **physical pixel gap → angular gap** at a fixed average radius; if you resize the chart a lot at runtime, re-check that 2px still reads as a visible gap at very small sizes (below ~120px it may visually disappear).
- No unit/visual regression tests included — this is a first-pass scaffold for Cursor, not a hardened library.
- `BottomNav` active-icon gradient uses `background-clip: text`, which needs `-webkit-background-clip: text` too in some older WebKit builds; not added here since Tailwind's `bg-clip-text` utility already handles the prefix.

View File

@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
import { cx } from "../lib/cx";
export interface NavItem {
key: string;
icon: ReactNode;
label: string;
active?: boolean;
onClick?: () => void;
href?: string;
}
export interface BottomNavProps {
items: NavItem[];
className?: string;
}
/**
* Floating Glass Pill bottom navigation.
*
* - Fixed to the bottom, centered, floats above content (not edge-to-edge).
* - Respects `env(safe-area-inset-bottom)` so it clears the iOS home indicator.
* - Active item gets the accent gradient applied directly to icon + label
* (bg-clip-text) rather than a pill behind it, so it reads against the
* glass background instead of competing with it.
*/
export function BottomNav({ items, className }: BottomNavProps) {
return (
<nav
className={cx("fixed inset-x-0 bottom-0 z-50 flex justify-center pointer-events-none", className)}
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) + 12px)" }}
>
<div
className="relative flex items-center gap-1 rounded-full px-2 py-2 pointer-events-auto"
style={{ boxShadow: "var(--shadow-ambient)" }}
>
<div
className="absolute inset-0 rounded-full"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<div className="glass-border pointer-events-none absolute inset-0 rounded-full" />
{items.map((item) => {
const content = (
<>
<span
aria-hidden
className={cx(
"text-xl leading-none",
item.active ? "bg-clip-text text-transparent" : "text-[var(--ink-soft)]"
)}
style={item.active ? { backgroundImage: "var(--gradient-accent)" } : undefined}
>
{item.icon}
</span>
<span
className={cx(
"font-body text-[10px] font-bold leading-none",
item.active ? "bg-clip-text text-transparent" : "text-[var(--ink-soft)]"
)}
style={item.active ? { backgroundImage: "var(--gradient-accent)" } : undefined}
>
{item.label}
</span>
</>
);
const itemClass = cx(
"relative z-10 flex min-w-[48px] min-h-[48px] flex-col items-center justify-center gap-0.5",
"elevation-transition rounded-full px-3 no-select active:translate-y-px active:brightness-95"
);
return item.href ? (
<a key={item.key} href={item.href} className={itemClass}>
{content}
</a>
) : (
<button key={item.key} type="button" onClick={item.onClick} className={itemClass}>
{content}
</button>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,112 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { cx } from "../lib/cx";
export type ButtonVariant = "primary" | "secondary" | "fab";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
loading?: boolean;
/** Required for `variant="fab"` — icon-only, no visible label. */
icon?: ReactNode;
}
function Spinner({ dark = false }: { dark?: boolean }) {
return (
<span
aria-hidden
className={cx(
"inline-block h-4 w-4 animate-spin rounded-full border-2",
dark ? "border-[var(--ink)]/25 border-t-[var(--ink)]" : "border-white/40 border-t-white"
)}
/>
);
}
/**
* Shared press behavior: 1px downward shift + swap from elevated to an
* inset shadow + slight darkening. This is what actually reads as
* "physically pushed in" — scale alone doesn't sell it, the inset shadow
* does the work.
*/
const pressable = cx(
"elevation-transition active:translate-y-px active:brightness-95",
"shadow-[var(--shadow-elevated)] active:shadow-[var(--shadow-pressed)]"
);
const base = cx(
"relative inline-flex items-center justify-center gap-2",
"font-body font-bold no-select [-webkit-tap-highlight-color:transparent]",
"disabled:opacity-40 disabled:pointer-events-none disabled:active:translate-y-0 disabled:active:shadow-[var(--shadow-elevated)]"
);
/**
* Touch-optimized button set. All variants meet the 48px minimum touch target.
*
* Text color rule: our brand action gradients (lime→teal, coral→amber) are
* light/high-luminance by design — see theme.css. Buttons use --ink text on
* them, not white, to stay at WCAG AA without a text-shadow crutch. The
* `secondary` variant uses --ink for the same reason (low-transparency
* surface fill, not a dark background).
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = "primary", loading = false, disabled, icon, className, children, ...props }, ref) => {
const isDisabled = disabled || loading;
if (variant === "fab") {
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(base, pressable, "h-14 w-14 rounded-full text-[var(--ink)]", className)}
style={{ backgroundImage: "var(--gradient-accent)" }}
{...props}
>
{loading ? <Spinner dark /> : icon ?? children}
</button>
);
}
if (variant === "secondary") {
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(
base,
pressable,
"min-h-[48px] overflow-hidden rounded-2xl px-6 text-[var(--ink)]",
className
)}
{...props}
>
<span
className="absolute inset-0 rounded-2xl"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<span className="glass-border pointer-events-none absolute inset-0 rounded-2xl" />
<span className="relative z-10 flex items-center gap-2">
{loading ? <Spinner dark /> : children}
</span>
</button>
);
}
// primary
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(base, pressable, "min-h-[48px] rounded-2xl px-6 text-[var(--ink)]", className)}
style={{ backgroundImage: "var(--gradient-primary)" }}
{...props}
>
{loading ? <Spinner dark /> : children}
</button>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,62 @@
import { forwardRef, type HTMLAttributes } from "react";
import { cx } from "../lib/cx";
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* `default` — static, sits at rest elevation.
* `interactive` — lifts on hover-capable devices, settles down 1px + shadow
* softens on press (not a full inward "pressed" look — that's reserved
* for `Button`; a card is a container you tap into, not a switch).
*/
variant?: "default" | "interactive";
}
/**
* Elevated Surface Card — the primary container of the design system.
*
* Low-transparency (not see-through glass): a solid-reading surface fill
* (~90% opaque) with a visible drop shadow for real elevation, plus a faint
* top-light/bottom-shadow bevel on the edge so it reads as raised off the
* page rather than flat or floating-glass.
*
* Structure (three stacked layers, all inside one rounded-3xl shell):
* 1. Base: subtle surface gradient (--surface-gradient) for texture.
* 2. Surface fill: ~90% opaque fill + 8px blur (softens whatever's behind
* it without reading as transparent) + masked bevel border.
* 3. Content: rendered above both, in normal flow.
*/
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
({ variant = "default", className, children, ...props }, ref) => {
const interactive = variant === "interactive";
return (
<div
ref={ref}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
className={cx(
"elevation-transition relative rounded-3xl no-select",
"shadow-[var(--shadow-elevated)]",
interactive && "cursor-pointer active:translate-y-px active:shadow-[var(--shadow-resting)]",
className
)}
style={{ backgroundImage: "var(--surface-gradient)" }}
{...props}
>
{/* surface fill + blur */}
<div
className="absolute inset-0 rounded-3xl"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
{/* bevel border, 1px, radius-safe */}
<div className="glass-border pointer-events-none absolute inset-0 rounded-3xl" />
{/* content */}
<div className="relative z-10">{children}</div>
</div>
);
}
);
GlassCard.displayName = "GlassCard";

View File

@@ -0,0 +1,121 @@
import { useId } from "react";
export interface PlateSlice {
id: string;
label: string;
value: number;
/** 2-stop gradient for this slice, e.g. ["#D9F227", "#1FD9C4"]. */
colors: [string, string];
}
export interface PlateChartProps {
slices: PlateSlice[];
size?: number;
centerLabel?: string;
centerValue?: string;
/** Physical gap between slices in px, converted to an angular gap at render time. */
gapPx?: number;
}
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
/** Donut-segment path between two radii, from startAngle to endAngle (degrees, 0 = top). */
function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number) {
const startOuter = polarToCartesian(cx, cy, rOuter, endAngle);
const endOuter = polarToCartesian(cx, cy, rOuter, startAngle);
const startInner = polarToCartesian(cx, cy, rInner, endAngle);
const endInner = polarToCartesian(cx, cy, rInner, startAngle);
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
return [
"M", startOuter.x, startOuter.y,
"A", rOuter, rOuter, 0, largeArc, 0, endOuter.x, endOuter.y,
"L", endInner.x, endInner.y,
"A", rInner, rInner, 0, largeArc, 1, startInner.x, startInner.y,
"Z",
].join(" ");
}
/**
* "Dining Plate" chart — a donut styled to read as a ceramic plate: outer
* rim (gradient stroke), recessed well (radial gradient), slices as
* gradient-filled donut segments with a small gap between each, and a
* center overlay for the headline metric.
*
* Meant to live inside a `GlassCard`. All visuals are gradients — no images.
*/
export function PlateChart({ slices, size = 240, centerLabel, centerValue, gapPx = 2 }: PlateChartProps) {
const uid = useId().replace(/:/g, "");
const cx = size / 2;
const cy = size / 2;
const rimR = size / 2 - 4;
const outerR = rimR - 14;
const innerR = size * 0.32;
const total = slices.reduce((sum, s) => sum + s.value, 0) || 1;
const avgR = (outerR + innerR) / 2;
const gapDeg = avgR > 0 ? (gapPx / (avgR * 2 * Math.PI)) * 360 : 0;
let angle = 0;
const segments = slices
.filter((s) => s.value > 0)
.map((slice) => {
const sweep = (slice.value / total) * 360;
const start = angle + gapDeg / 2;
const end = angle + sweep - gapDeg / 2;
angle += sweep;
return { ...slice, start, end, d: end > start ? arcPath(cx, cy, outerR, innerR, start, end) : null };
});
return (
<div className="relative select-none" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Plate breakdown">
<defs>
<radialGradient id={`${uid}-well`} cx="50%" cy="50%" r="65%">
<stop offset="0%" stopColor="var(--plate-well-center)" />
<stop offset="100%" stopColor="var(--plate-well-edge)" />
</radialGradient>
<linearGradient id={`${uid}-rim`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--plate-rim-light)" />
<stop offset="100%" stopColor="var(--plate-rim-shadow)" />
</linearGradient>
{segments.map((s) => (
<linearGradient key={s.id} id={`${uid}-slice-${s.id}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={s.colors[0]} />
<stop offset="100%" stopColor={s.colors[1]} />
</linearGradient>
))}
</defs>
{/* ceramic rim */}
<circle cx={cx} cy={cy} r={rimR} fill="none" stroke={`url(#${uid}-rim)`} strokeWidth={6} />
{/* recessed well */}
<circle cx={cx} cy={cy} r={rimR - 8} fill={`url(#${uid}-well)`} />
{segments.map(
(s) => s.d && <path key={s.id} d={s.d} fill={`url(#${uid}-slice-${s.id})`} />
)}
{/* center hole, matches well so the label sits on clean ground */}
<circle cx={cx} cy={cy} r={innerR - 6} fill={`url(#${uid}-well)`} />
</svg>
{(centerLabel || centerValue) && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
{centerValue && (
<span className="font-display text-2xl font-black leading-none text-[var(--ink)]">
{centerValue}
</span>
)}
{centerLabel && (
<span className="font-body mt-1 text-[11px] font-bold uppercase tracking-wide text-[var(--ink-soft)]">
{centerLabel}
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
/** Tiny className joiner — avoids pulling in clsx/tailwind-merge as a dependency. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,192 @@
/**
* Pocket Pascal — Elevated Surface Design Tokens
*
* Pivoted from heavy glassmorphism to a low-transparency, tactile system:
* cards read as solid raised surfaces (elevation shadow + faint top/bottom
* bevel), buttons visibly depress on press (inset shadow + 1px downward
* shift) instead of just scaling. `--glass-*` names kept for minimal diff
* across components — they now mean "surface fill / surface blur", not
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
* whatever sits behind a card without reading as transparent.
*
* Toggle dark mode by adding/removing the `dark` class on <html>
* (matches Tailwind `darkMode: 'class'` — see README).
*
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
* section 05/08 for the full rationale and contrast numbers.
*/
:root {
/* ---------- Elevated surface (formerly "glass") ---------- */
--glass-fill: rgba(255, 255, 255, 0.9);
--glass-blur: 8px;
/* top-light / bottom-shadow bevel instead of an even glass edge — reads as raised, not see-through */
--glass-border-from: rgba(255, 255, 255, 0.9);
--glass-border-to: rgba(0, 0, 0, 0.06);
/* ---------- Shadows (three states: resting, elevated/lifted, pressed-in) ---------- */
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.10), 0 14px 28px rgba(0, 0, 0, 0.10);
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.28), inset 0 1px 1px rgba(0, 0, 0, 0.15);
/* kept for anything still referencing the old name */
--shadow-ambient: var(--shadow-elevated);
/* ---------- Brand ink / neutrals ---------- */
--ink: #191800;
--ink-soft: #5c5b48;
--ink-faint: #6b6a56;
--border: #e2e8f0;
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */
--color-protein: #d9f227;
--color-protein-dk: #2e3200;
--color-obstgem: #ff6a3d;
--color-obstgem-dk: #3d1508;
--color-fett: #ffb300;
--color-fett-dk: #3d2900;
--color-kh: #1fd9c4;
--color-kh-dk: #04302b;
--color-pick: #0f7a37;
--color-caution: #c81e1e;
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
/* ---------- Action gradients (vibrant, multi-stop) ---------- */
/* NOTE: brand hues are light/high-luminance by design (see contrast audit),
so action gradients pair with --ink text, not white — this keeps every
button at AA contrast without text-shadow hacks. Do not swap to white
text on these without re-checking contrast. */
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
/* ---------- Dining Plate chart tokens ---------- */
--plate-rim-light: #ffffff;
--plate-rim-shadow: #d8d8d0;
--plate-well-center: #f7f7f2;
--plate-well-edge: #e6e6dd;
}
.dark {
--glass-fill: rgba(28, 28, 21, 0.88);
--glass-blur: 8px;
--glass-border-from: rgba(255, 255, 255, 0.10);
--glass-border-to: rgba(0, 0, 0, 0.35);
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.45), 0 16px 32px rgba(0, 0, 0, 0.4);
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.6), inset 0 1px 1px rgba(0, 0, 0, 0.3);
--shadow-ambient: var(--shadow-elevated);
--ink: #f3f2e6;
--ink-soft: #c2c1ac;
--ink-faint: #8f8e7a;
--border: #33342a;
/* category primaries stay identical (they're the brand, not a theme) */
--color-protein: #d9f227;
--color-protein-dk: #2e3200;
--color-obstgem: #ff6a3d;
--color-obstgem-dk: #3d1508;
--color-fett: #ffb300;
--color-fett-dk: #3d2900;
--color-kh: #1fd9c4;
--color-kh-dk: #04302b;
--color-pick: #4ade80;
--color-caution: #ff6b60;
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
radial-gradient(120% 100% at 100% 100%, #0f2a26 0%, #14140f 45%);
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #ff6b60 100%);
--plate-rim-light: #2a2a22;
--plate-rim-shadow: #0c0c09;
--plate-well-center: #1c1c15;
--plate-well-edge: #111109;
}
/* ---------- Fonts ---------- */
/* Download the two static woff2 files listed in README into /public/fonts,
then this block just works. Using local @font-face (not next/font) keeps
this file framework-agnostic for Cursor. */
@font-face {
font-family: "Unbounded";
font-weight: 700;
font-style: normal;
font-display: swap;
src: url("/fonts/unbounded-700.woff2") format("woff2");
}
@font-face {
font-family: "Unbounded";
font-weight: 900;
font-style: normal;
font-display: swap;
src: url("/fonts/unbounded-900.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 400;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-400.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 500;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-500.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 700;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-700.woff2") format("woff2");
}
.font-display {
font-family: "Unbounded", ui-sans-serif, sans-serif;
letter-spacing: -0.01em;
}
.font-body {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
}
/* ---------- Glass border (gradient 1px border that respects border-radius) ---------- */
/* border-image ignores border-radius, so we use a masked pseudo-element instead.
Apply `.glass-border` to a `relative` container with `overflow-hidden` off
(the pseudo-element is inset:0 and radius:inherit). */
.glass-border {
border-radius: inherit;
padding: 1px;
/* top-light, bottom-shadow — reads as a raised bevel, not a glass sheen */
background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
.elevation-transition {
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
}
/* ---------- PWA polish ---------- */
html,
body {
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
.no-select {
-webkit-user-select: none;
user-select: none;
}