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:
112
handover/component-library/ComponentShowcase.tsx
Normal file
112
handover/component-library/ComponentShowcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
handover/component-library/README.md
Normal file
58
handover/component-library/README.md
Normal 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.6–10.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.
|
||||
88
handover/component-library/components/BottomNav.tsx
Normal file
88
handover/component-library/components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
handover/component-library/components/Button.tsx
Normal file
112
handover/component-library/components/Button.tsx
Normal 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";
|
||||
62
handover/component-library/components/GlassCard.tsx
Normal file
62
handover/component-library/components/GlassCard.tsx
Normal 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";
|
||||
121
handover/component-library/components/PlateChart.tsx
Normal file
121
handover/component-library/components/PlateChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
handover/component-library/lib/cx.ts
Normal file
4
handover/component-library/lib/cx.ts
Normal 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(" ");
|
||||
}
|
||||
BIN
handover/component-library/public/fonts/albert-sans-400.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-400.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/albert-sans-500.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-500.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/albert-sans-700.woff2
Normal file
BIN
handover/component-library/public/fonts/albert-sans-700.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/unbounded-700.woff2
Normal file
BIN
handover/component-library/public/fonts/unbounded-700.woff2
Normal file
Binary file not shown.
BIN
handover/component-library/public/fonts/unbounded-900.woff2
Normal file
BIN
handover/component-library/public/fonts/unbounded-900.woff2
Normal file
Binary file not shown.
192
handover/component-library/styles/theme.css
Normal file
192
handover/component-library/styles/theme.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user