Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button, SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's color aliases onto BoardUI tokens and swap lucide for Remix icons - Retint BoardUI's neutral ramp to cool slate with a salmon accent; drop the dotted background; raise muted text contrast - Simplify the landing page and remove the overlap explorer - Planner: explicit "I have this" toggle on grocery lines, pantry panel above the menu with the staples list spelled out, sticky aisle header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded) - Recipes: replace the family cabbage soup with Cade's Cabbage Soup (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup - Remove unused BoardUI components, dependencies and exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
48
.cursor/rules/boardui.mdc
Normal file
48
.cursor/rules/boardui.mdc
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
description: BoardUI design system rules. Always apply when writing or editing UI code in this project.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# BoardUI design rules
|
||||||
|
|
||||||
|
This project uses BoardUI (React + Tailwind CSS v4, source-owned components under `components/`). These rules always apply when writing UI code. MCP tools are on demand; these rules are not optional context.
|
||||||
|
|
||||||
|
## Components first
|
||||||
|
|
||||||
|
- Before hand-building any UI element, check for an installed BoardUI component under `components/base/` and `components/application/`, and prefer it.
|
||||||
|
- Missing a component? Install it (BoardUI MCP `install_components`, or `npx boardui@latest add <name>`) instead of writing a lookalike.
|
||||||
|
- Import through the `@/` alias, e.g. `import { Button } from "@/components/base/buttons/button"`.
|
||||||
|
|
||||||
|
## Color: semantic tokens only
|
||||||
|
|
||||||
|
- Never use raw palette classes (`text-gray-500`, `bg-white`, `border-neutral-200`) or hex/oklch literals. Every color rides a BoardUI semantic token, which also makes dark mode automatic.
|
||||||
|
- Text: `text-text-primary`, `text-text-secondary`, `text-text-tertiary`, `text-text-placeholder`, errors `text-text-error-primary`.
|
||||||
|
- Surfaces: `bg-background-primary-default`, `bg-background-secondary-default` / `-hover`, `bg-background-tertiary-default`, page ground `bg-background-full`.
|
||||||
|
- Borders: `border-border-button-default` / `-hover`, hairlines `border-separator-border`, tables `border-border-table`, errors `border-border-error-default`.
|
||||||
|
- Icons: `text-foreground-icon-primary` through `text-foreground-icon-quaternary`.
|
||||||
|
- Charts: the `chart-1` … `chart-5` tokens (plus `-active` variants). CTAs and selection states: the `accent-50` … `accent-950` ramp.
|
||||||
|
- Dark mode flips tokens via the `.dark` class on `<html>`. Do not write `dark:` overrides with raw colors; if a token pair looks wrong in dark mode, pick a different token, not a literal.
|
||||||
|
|
||||||
|
## Typography: composite utilities only
|
||||||
|
|
||||||
|
- Use BoardUI's composite type utilities: `text-title-1-medium`, `text-title-2-medium`, `text-title-3-semibold`, `text-headline-medium`, `text-body-medium`, `text-body-regular`, `text-body-2-*`, `text-caption-1-semibold`, and friends. Each sets size, weight, line-height, and letter-spacing together.
|
||||||
|
- Never rebuild type by stacking `text-sm font-medium leading-5`; if a style seems missing, look in `styles/typography.css` before inventing one.
|
||||||
|
|
||||||
|
## Spacing and shape
|
||||||
|
|
||||||
|
- Stay on Tailwind's spacing scale (`gap-2`, `p-4`, `mt-6`); prefer flex/grid `gap` over per-child margins. Arbitrary values (`p-[13px]`) only when matching an existing BoardUI component exactly.
|
||||||
|
- Cards and panels: `rounded-3xl` with `border-border-button-default`. Inputs and menu rows: `rounded-md` to `rounded-xl`. Pills: `rounded-full`.
|
||||||
|
|
||||||
|
## Mechanics
|
||||||
|
|
||||||
|
- Direction: wrap the app (or a mixed-language region) in `DirectionProvider` from `@/components/foundations/direction/direction`, with an explicit BCP 47 `locale` (`ar`, `he`, `en-US`, `zh-CN`). Set matching `lang` and `dir` on `<html>` for full-app RTL. CSS `dir` alone does not configure React Aria keyboard navigation or portal placement. Use `useDirection()` for custom interactions and put its value on custom portal roots.
|
||||||
|
- Use logical styles: `ms`/`me`, `ps`/`pe`, `start`/`end`, `text-start`/`text-end`, `border-s`/`border-e`, `rounded-s`/`rounded-e`, and inline logical CSS properties. Mirror navigation/disclosure glyphs and horizontal drawer motion in RTL. Preserve physical coordinates for measured geometry, charts, canvas, and decorative art. Keep code, OTP digits, email addresses and URLs in explicit LTR regions; isolate mixed text with `<bdi>` or `dir="auto"`. Translating labels and formatting application data is separate from layout direction. See https://www.boardui.com/rtl.
|
||||||
|
|
||||||
|
- Internal vertical scroll regions must soften the top edge with a progressive blur and surface fade once scrolled, rather than sharply clipping content. Keep headers outside the scroll region. Use a stationary, `pointer-events-none`, `aria-hidden` overlay in a relative wrapper: a 24px masked 1px backdrop blur, a 16px masked 4px backdrop blur, and a gradient from the enclosing semantic surface token to transparent. Ramp each layer's opacity from 0 to 1 over the first 24px of scrolling; keep it invisible at scrollTop 0. Apply opacity to the individual layers, never the overlay parent (a translucent parent creates a backdrop root and can break blur at normal browser zoom). Verify top/reset, overflow, light/dark mode, and 100% zoom; preserve keyboard scrolling and do not blur the whole content area. The `scroll-fade` registry item ships this recipe as `ScrollFade` + `useScrollFade` (`@/components/foundations/scroll-fade/scroll-fade`); reuse it rather than rebuilding the layers.
|
||||||
|
|
||||||
|
- Merge classes with `cx()` from `@/utils/cx` (tailwind-merge aware of BoardUI's composite text styles). No string concatenation, no plain `clsx`.
|
||||||
|
- Icons come from `@remixicon/react`, passed as component references (`leadingIcon={RiAddLine}`), not rendered elements.
|
||||||
|
- Form components build on `react-aria-components`; extend the installed BoardUI form components rather than raw `<input>`/`<select>`.
|
||||||
|
- Focus states: `outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring`.
|
||||||
|
|
||||||
|
When unsure about a token, a component's API, or working example code, ask the BoardUI MCP server: `get_theme`, `get_component`, `get_usage_examples`.
|
||||||
46
AGENTS.md
46
AGENTS.md
|
|
@ -7,3 +7,49 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
<!-- boardui:rules:start -->
|
||||||
|
# BoardUI design rules
|
||||||
|
|
||||||
|
This project uses BoardUI (React + Tailwind CSS v4, source-owned components under `components/`). These rules always apply when writing UI code. MCP tools are on demand; these rules are not optional context.
|
||||||
|
|
||||||
|
## Components first
|
||||||
|
|
||||||
|
- Before hand-building any UI element, check for an installed BoardUI component under `components/base/` and `components/application/`, and prefer it.
|
||||||
|
- Missing a component? Install it (BoardUI MCP `install_components`, or `npx boardui@latest add <name>`) instead of writing a lookalike.
|
||||||
|
- Import through the `@/` alias, e.g. `import { Button } from "@/components/base/buttons/button"`.
|
||||||
|
|
||||||
|
## Color: semantic tokens only
|
||||||
|
|
||||||
|
- Never use raw palette classes (`text-gray-500`, `bg-white`, `border-neutral-200`) or hex/oklch literals. Every color rides a BoardUI semantic token, which also makes dark mode automatic.
|
||||||
|
- Text: `text-text-primary`, `text-text-secondary`, `text-text-tertiary`, `text-text-placeholder`, errors `text-text-error-primary`.
|
||||||
|
- Surfaces: `bg-background-primary-default`, `bg-background-secondary-default` / `-hover`, `bg-background-tertiary-default`, page ground `bg-background-full`.
|
||||||
|
- Borders: `border-border-button-default` / `-hover`, hairlines `border-separator-border`, tables `border-border-table`, errors `border-border-error-default`.
|
||||||
|
- Icons: `text-foreground-icon-primary` through `text-foreground-icon-quaternary`.
|
||||||
|
- Charts: the `chart-1` … `chart-5` tokens (plus `-active` variants). CTAs and selection states: the `accent-50` … `accent-950` ramp.
|
||||||
|
- Dark mode flips tokens via the `.dark` class on `<html>`. Do not write `dark:` overrides with raw colors; if a token pair looks wrong in dark mode, pick a different token, not a literal.
|
||||||
|
|
||||||
|
## Typography: composite utilities only
|
||||||
|
|
||||||
|
- Use BoardUI's composite type utilities: `text-title-1-medium`, `text-title-2-medium`, `text-title-3-semibold`, `text-headline-medium`, `text-body-medium`, `text-body-regular`, `text-body-2-*`, `text-caption-1-semibold`, and friends. Each sets size, weight, line-height, and letter-spacing together.
|
||||||
|
- Never rebuild type by stacking `text-sm font-medium leading-5`; if a style seems missing, look in `styles/typography.css` before inventing one.
|
||||||
|
|
||||||
|
## Spacing and shape
|
||||||
|
|
||||||
|
- Stay on Tailwind's spacing scale (`gap-2`, `p-4`, `mt-6`); prefer flex/grid `gap` over per-child margins. Arbitrary values (`p-[13px]`) only when matching an existing BoardUI component exactly.
|
||||||
|
- Cards and panels: `rounded-3xl` with `border-border-button-default`. Inputs and menu rows: `rounded-md` to `rounded-xl`. Pills: `rounded-full`.
|
||||||
|
|
||||||
|
## Mechanics
|
||||||
|
|
||||||
|
- Direction: wrap the app (or a mixed-language region) in `DirectionProvider` from `@/components/foundations/direction/direction`, with an explicit BCP 47 `locale` (`ar`, `he`, `en-US`, `zh-CN`). Set matching `lang` and `dir` on `<html>` for full-app RTL. CSS `dir` alone does not configure React Aria keyboard navigation or portal placement. Use `useDirection()` for custom interactions and put its value on custom portal roots.
|
||||||
|
- Use logical styles: `ms`/`me`, `ps`/`pe`, `start`/`end`, `text-start`/`text-end`, `border-s`/`border-e`, `rounded-s`/`rounded-e`, and inline logical CSS properties. Mirror navigation/disclosure glyphs and horizontal drawer motion in RTL. Preserve physical coordinates for measured geometry, charts, canvas, and decorative art. Keep code, OTP digits, email addresses and URLs in explicit LTR regions; isolate mixed text with `<bdi>` or `dir="auto"`. Translating labels and formatting application data is separate from layout direction. See https://www.boardui.com/rtl.
|
||||||
|
|
||||||
|
- Internal vertical scroll regions must soften the top edge with a progressive blur and surface fade once scrolled, rather than sharply clipping content. Keep headers outside the scroll region. Use a stationary, `pointer-events-none`, `aria-hidden` overlay in a relative wrapper: a 24px masked 1px backdrop blur, a 16px masked 4px backdrop blur, and a gradient from the enclosing semantic surface token to transparent. Ramp each layer's opacity from 0 to 1 over the first 24px of scrolling; keep it invisible at scrollTop 0. Apply opacity to the individual layers, never the overlay parent (a translucent parent creates a backdrop root and can break blur at normal browser zoom). Verify top/reset, overflow, light/dark mode, and 100% zoom; preserve keyboard scrolling and do not blur the whole content area. The `scroll-fade` registry item ships this recipe as `ScrollFade` + `useScrollFade` (`@/components/foundations/scroll-fade/scroll-fade`); reuse it rather than rebuilding the layers.
|
||||||
|
|
||||||
|
- Merge classes with `cx()` from `@/utils/cx` (tailwind-merge aware of BoardUI's composite text styles). No string concatenation, no plain `clsx`.
|
||||||
|
- Icons come from `@remixicon/react`, passed as component references (`leadingIcon={RiAddLine}`), not rendered elements.
|
||||||
|
- Form components build on `react-aria-components`; extend the installed BoardUI form components rather than raw `<input>`/`<select>`.
|
||||||
|
- Focus states: `outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring`.
|
||||||
|
|
||||||
|
When unsure about a token, a component's API, or working example code, ask the BoardUI MCP server: `get_theme`, `get_component`, `get_usage_examples`.
|
||||||
|
<!-- boardui:rules:end -->
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArrowRight, BookHeart } from "lucide-react";
|
import { RiArrowRightLine as ArrowRight, RiHeart3Line as BookHeart } from "@remixicon/react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ViewTransition } from "react";
|
import { ViewTransition } from "react";
|
||||||
|
|
@ -26,7 +26,7 @@ export default function CaydensCornerPage() {
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh">
|
||||||
<SiteHeader active="caydens-corner" />
|
<SiteHeader active="caydens-corner" />
|
||||||
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
||||||
<section className="paper-grain rounded-2xl border border-brand-border bg-brand-950 px-6 py-10 sm:px-10">
|
<section className="rounded-2xl border border-brand-border bg-brand-950 px-6 py-10 sm:px-10">
|
||||||
<p className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-widest text-brand-300 uppercase">
|
<p className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-widest text-brand-300 uppercase">
|
||||||
<BookHeart className="size-4" strokeWidth={2} aria-hidden />
|
<BookHeart className="size-4" strokeWidth={2} aria-hidden />
|
||||||
Cayden's Corner · Heirloom Cookbook
|
Cayden's Corner · Heirloom Cookbook
|
||||||
|
|
@ -69,7 +69,7 @@ export default function CaydensCornerPage() {
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/recipes/${r.id}`}
|
href={`/recipes/${r.id}`}
|
||||||
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
className="group flex h-full flex-col rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||||
>
|
>
|
||||||
<ViewTransition name={`recipe-title-${r.id}`}>
|
<ViewTransition name={`recipe-title-${r.id}`}>
|
||||||
<span className="font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
|
<span className="font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
|
import { RiArrowRightLine as ArrowRight, RiCalendarCheckLine as CalendarPlus, RiCheckLine as Check, RiCloseLine as X } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
|
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
|
||||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||||
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
|
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
|
||||||
import type { MealPlanState } from "@/app/types";
|
import type { MealPlanState } from "@/app/types";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
const STORAGE_KEY = "budget-meal-planner:v2";
|
const STORAGE_KEY = "budget-meal-planner:v2";
|
||||||
|
|
||||||
|
|
@ -56,17 +57,15 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
leadingIcon={CalendarPlus}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPlan(readPlan());
|
setPlan(readPlan());
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
|
|
||||||
>
|
>
|
||||||
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
|
|
||||||
Add to meal plan
|
Add to meal plan
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{addedLabel && (
|
{addedLabel && (
|
||||||
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
|
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
|
||||||
|
|
@ -84,7 +83,7 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
|
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
|
||||||
aria-labelledby="add-to-plan-title"
|
aria-labelledby="add-to-plan-title"
|
||||||
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
|
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-3xl"
|
||||||
>
|
>
|
||||||
<div className="p-5 sm:p-6">
|
<div className="p-5 sm:p-6">
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
|
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
|
import { Meter, type MeterTone } from "@/components/base/meter/meter";
|
||||||
import { formatUsd } from "@/app/lib/units";
|
import { formatUsd } from "@/app/lib/units";
|
||||||
|
|
||||||
/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */
|
/** Success under 85%, warning up to 100%, error over budget. */
|
||||||
function budgetTone(spent: number, budget: number) {
|
function budgetTone(spent: number, budget: number): { tone: MeterTone; text: string; label: string } {
|
||||||
const ratio = budget > 0 ? spent / budget : 0;
|
const ratio = budget > 0 ? spent / budget : 0;
|
||||||
if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" };
|
if (ratio > 1) return { tone: "error", text: "text-status-danger", label: "Over budget" };
|
||||||
if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" };
|
if (ratio > 0.85) return { tone: "warning", text: "text-status-warn", label: "Close to budget" };
|
||||||
return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" };
|
return { tone: "success", text: "text-status-ok", label: "Under budget" };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BudgetMeterProps {
|
interface BudgetMeterProps {
|
||||||
|
|
@ -16,25 +17,22 @@ interface BudgetMeterProps {
|
||||||
|
|
||||||
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
|
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
|
||||||
const tone = budgetTone(spent, budget);
|
const tone = budgetTone(spent, budget);
|
||||||
const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? "w-full" : "w-full min-w-48"}>
|
<div className={compact ? "w-full" : "w-full min-w-48"}>
|
||||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
|
<span className={`text-body-medium tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
|
||||||
<span className="font-mono text-ink-muted tabular-nums">/ {formatUsd(budget)}</span>
|
<span className="text-caption-1-medium text-text-secondary tabular-nums">/ {formatUsd(budget)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Meter
|
||||||
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
|
className="mt-1.5"
|
||||||
role="meter"
|
|
||||||
aria-label="Budget used"
|
aria-label="Budget used"
|
||||||
aria-valuemin={0}
|
tone={tone.tone}
|
||||||
aria-valuemax={budget}
|
minValue={0}
|
||||||
aria-valuenow={Math.round(spent * 100) / 100}
|
maxValue={budget > 0 ? budget : 1}
|
||||||
aria-valuetext={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
|
value={Math.min(spent, budget > 0 ? budget : 0)}
|
||||||
>
|
valueLabel={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
|
||||||
<div className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`} style={{ width: `${percent}%` }} />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Check } from "lucide-react";
|
import { RiCheckLine } from "@remixicon/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…).
|
// A pill-shaped on/off filter toggle on BoardUI tokens.
|
||||||
// Includes a visible Check icon when pressed so state never relies on color alone.
|
// Shows a check when pressed so state never relies on color alone.
|
||||||
|
|
||||||
interface ChipProps {
|
interface ChipProps {
|
||||||
pressed: boolean;
|
pressed: boolean;
|
||||||
|
|
@ -14,24 +15,25 @@ interface ChipProps {
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZES = {
|
|
||||||
sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5",
|
|
||||||
md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
|
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
|
||||||
const look = pressed
|
|
||||||
? "border-selected-line bg-selected font-medium text-ink shadow-2xs"
|
|
||||||
: `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={pressed}
|
aria-pressed={pressed}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`trim-cap inline-flex items-center rounded-full border transition-colors ${SIZES[size]} ${look}`}
|
className={cx(
|
||||||
|
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border transition-colors duration-150 ease",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||||
|
size === "sm" ? "min-h-8 px-2.5 py-1 text-caption-1-medium" : "min-h-9 px-3.5 py-1.5 text-body-medium",
|
||||||
|
pressed
|
||||||
|
? "border-accent-500 bg-button-ghost-background text-button-ghost-foreground"
|
||||||
|
: cx(
|
||||||
|
"border-border-button-default bg-background-primary-default text-text-secondary hover:border-border-button-hover hover:bg-background-secondary-hover hover:text-text-primary",
|
||||||
|
dashed && "border-dashed",
|
||||||
|
),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
|
{pressed && <RiCheckLine className="size-3.5 shrink-0" aria-hidden />}
|
||||||
<span className="inline-flex items-center gap-1.5">{children}</span>
|
<span className="inline-flex items-center gap-1.5">{children}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
|
import { RiDownload2Line, RiArrowDownSLine as ChevronDown, RiResetLeftLine as RotateCcw, RiShuffleLine as Shuffle, RiEqualizerLine as SlidersHorizontal, RiMagicLine as Wand2 } from "@remixicon/react";
|
||||||
import { useState, type Dispatch } from "react";
|
import { useState, type Dispatch } from "react";
|
||||||
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
|
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
|
||||||
|
import { buildPlanModel, renderPlanPdf } from "@/app/lib/exportPlan";
|
||||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||||
import type { FillMode, MealPlanState } from "@/app/types";
|
import type { FillMode, MealPlanState } from "@/app/types";
|
||||||
import { Chip } from "./Chip";
|
import { Chip } from "./Chip";
|
||||||
import { ApplianceIcon, StyleIcon } from "./Icons";
|
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||||||
import { RadioGroup } from "./RadioGroup";
|
import { RadioGroup } from "./RadioGroup";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
state: MealPlanState;
|
state: MealPlanState;
|
||||||
|
|
@ -36,6 +38,19 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
|
||||||
const filledCount = activeRecipeIds(state).length;
|
const filledCount = activeRecipeIds(state).length;
|
||||||
|
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
async function downloadPlan() {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
// Loaded on demand so jsPDF stays out of the planner's initial bundle.
|
||||||
|
const { jsPDF } = await import("jspdf");
|
||||||
|
renderPlanPdf(buildPlanModel(state), jsPDF).save("meal-plan.pdf");
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
|
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
|
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
|
||||||
|
|
@ -51,7 +66,7 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
|
<section aria-labelledby="config-heading" className="rounded-3xl border border-border-button-default bg-surface p-4 sm:p-5">
|
||||||
<h2 id="config-heading" className="sr-only">
|
<h2 id="config-heading" className="sr-only">
|
||||||
Plan parameters
|
Plan parameters
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -155,25 +170,13 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
||||||
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
|
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<Button leadingIcon={Wand2} onClick={() => onGenerate("empty")} disabled={isGenerating}>
|
||||||
type="button"
|
|
||||||
onClick={() => onGenerate("empty")}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
|
||||||
{isGenerating ? "Planning…" : "Auto-fill empty"}
|
{isGenerating ? "Planning…" : "Auto-fill empty"}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button variant="secondary" leadingIcon={Shuffle} onClick={() => onGenerate("all")} disabled={isGenerating}>
|
||||||
type="button"
|
|
||||||
onClick={() => onGenerate("all")}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
|
|
||||||
Shuffle plan
|
Shuffle plan
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -184,6 +187,16 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
|
||||||
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
leadingIcon={RiDownload2Line}
|
||||||
|
onClick={downloadPlan}
|
||||||
|
disabled={isGenerating || isExporting || filledCount === 0}
|
||||||
|
title="Save the week as a PDF, with the full recipe for every meal, organized by day"
|
||||||
|
>
|
||||||
|
{isExporting ? "Preparing PDF…" : "Download PDF"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
|
import { RiCheckLine as Check, RiFileCopyLine as Copy, RiRepeat2Line as Repeat2, RiCloseLine as X } from "@remixicon/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { formatAmount, formatUsd } from "@/app/lib/units";
|
import { formatAmount, formatUsd } from "@/app/lib/units";
|
||||||
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
import { BudgetMeter } from "./BudgetMeter";
|
import { BudgetMeter } from "./BudgetMeter";
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
|
|
@ -35,7 +36,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* --- Tactile Market Receipt Header -------------------------------- */}
|
{/* --- Tactile Market Receipt Header -------------------------------- */}
|
||||||
<div className="paper-grain rounded-xl border border-line bg-canvas p-4">
|
<div className="rounded-3xl border border-border-button-default bg-canvas p-4">
|
||||||
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
|
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
|
||||||
|
|
||||||
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
|
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
|
||||||
|
|
@ -61,7 +62,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
||||||
<div className="mt-5 flex items-center justify-between">
|
<div className="mt-5 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
|
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
|
||||||
<p className="text-xs text-ink-faint">Tap items you already have on hand</p>
|
<p className="text-xs text-ink-faint">Tap “I have this” on anything in your kitchen and it drops off the bill.</p>
|
||||||
</div>
|
</div>
|
||||||
{summary.lines.length > 0 && (
|
{summary.lines.length > 0 && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -102,48 +103,46 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
|
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
|
||||||
const { item } = line;
|
const { item, onHand } = line;
|
||||||
return (
|
return (
|
||||||
<li>
|
<li className="flex min-h-12 items-start justify-between gap-3 py-2.5 text-sm">
|
||||||
<button
|
<span className="min-w-0">
|
||||||
type="button"
|
<span className={cx("block leading-snug", onHand ? "text-ink-faint line-through" : "font-medium text-ink")}>
|
||||||
onClick={() => onToggleOnHand(item.id, !line.onHand)}
|
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
||||||
aria-pressed={line.onHand}
|
{item.name}
|
||||||
aria-label={line.onHand ? `Mark ${item.name} as needed at the store` : `Mark ${item.name} as already on hand`}
|
|
||||||
className={`flex min-h-12 w-full items-start justify-between gap-3 py-2.5 text-left text-sm transition-colors hover:bg-raised/50 ${
|
|
||||||
line.onHand ? "text-ink-faint" : "text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-start gap-2.5">
|
|
||||||
<span className="mt-0.5 shrink-0 text-ink-muted">
|
|
||||||
{line.onHand ? (
|
|
||||||
<SquareCheckBig className="size-4 text-status-ok" strokeWidth={2} aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Square className="size-4 text-line-strong" strokeWidth={1.75} aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className={`block leading-snug ${line.onHand ? "line-through" : "font-medium"}`}>
|
|
||||||
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-ink-muted">
|
|
||||||
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
|
||||||
{line.onHand && " · on hand"}
|
|
||||||
</span>
|
|
||||||
{line.usedBy.length > 1 && (
|
|
||||||
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
|
||||||
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
|
||||||
Used in {line.usedBy.length} meals
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-ink-muted">
|
||||||
|
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
||||||
|
</span>
|
||||||
|
{line.usedBy.length > 1 && (
|
||||||
|
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
||||||
|
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
||||||
|
Used in {line.usedBy.length} meals
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
|
<span className="flex shrink-0 flex-col items-end gap-1.5">
|
||||||
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
<span className={cx("font-mono text-sm font-medium tabular-nums", onHand ? "text-ink-faint" : "text-ink")}>
|
||||||
|
{onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleOnHand(item.id, !onHand)}
|
||||||
|
aria-pressed={onHand}
|
||||||
|
aria-label={`I have ${item.name}`}
|
||||||
|
title={onHand ? "Counted as $0. Tap to add it back to the bill." : "Already have it? Tap to take it off the bill."}
|
||||||
|
className={cx(
|
||||||
|
"inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-caption-1-medium whitespace-nowrap transition-colors duration-150",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||||
|
onHand
|
||||||
|
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
||||||
|
: "border-border-button-default bg-background-primary-default text-ink-muted hover:border-border-button-hover hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{onHand && <Check className="size-3.5 shrink-0" aria-hidden />}I have this
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +197,7 @@ export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps)
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||||
aria-labelledby="grocery-drawer-title"
|
aria-labelledby="grocery-drawer-title"
|
||||||
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
||||||
>
|
>
|
||||||
<div className="p-4 pb-8 sm:px-6">
|
<div className="p-4 pb-8 sm:px-6">
|
||||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,10 @@
|
||||||
import type { LucideProps } from "lucide-react";
|
import { RiHeart3Line as BookHeart, RiRestaurant2Line as CookingPot, RiCupLine as CupSoda, RiSailboatLine as Fish, RiFireLine as Flame, RiTempHotLine as Heater, RiLeafLine as Leaf, RiArchive2Line as PackageCheck, RiSeedlingLine as Salad, RiBowlLine as Soup, RiTimerLine as Timer, RiRestaurantLine as Utensils, RiWindyLine as Wind, RiFlashlightLine as Zap } from "@remixicon/react";
|
||||||
import {
|
import type { ComponentProps } from "react";
|
||||||
BookHeart,
|
|
||||||
CookingPot,
|
|
||||||
CupSoda,
|
|
||||||
Fish,
|
|
||||||
Flame,
|
|
||||||
Heater,
|
|
||||||
Leaf,
|
|
||||||
PackageCheck,
|
|
||||||
Salad,
|
|
||||||
Soup,
|
|
||||||
Timer,
|
|
||||||
Utensils,
|
|
||||||
Wind,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Appliance, CookingStyle } from "@/app/types";
|
import type { Appliance, CookingStyle } from "@/app/types";
|
||||||
|
|
||||||
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
|
type RemixiconProps = ComponentProps<typeof Flame>;
|
||||||
|
|
||||||
|
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & RemixiconProps) {
|
||||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "stovetop":
|
case "stovetop":
|
||||||
|
|
@ -37,7 +24,7 @@ export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
|
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & RemixiconProps) {
|
||||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "caydens_corner":
|
case "caydens_corner":
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, Search, X } from "lucide-react";
|
import { RiArrowDownSLine as ChevronDown, RiSearchLine as Search, RiCloseLine as X } from "@remixicon/react";
|
||||||
import { useMemo, useState, type Dispatch } from "react";
|
import { useMemo, useState, type Dispatch } from "react";
|
||||||
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
|
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
|
||||||
import { RECIPES } from "@/app/lib/recipes";
|
import { RECIPES } from "@/app/lib/recipes";
|
||||||
|
|
@ -8,6 +8,9 @@ import type { PlanAction } from "@/app/lib/usePlanState";
|
||||||
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
|
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
|
||||||
import { RadioGroup, type RadioOption } from "./RadioGroup";
|
import { RadioGroup, type RadioOption } from "./RadioGroup";
|
||||||
|
|
||||||
|
// Shown beside the staples toggle so people know exactly what it marks as $0.
|
||||||
|
const STAPLE_NAMES = PANTRY_STAPLE_IDS.map((id) => CATALOG_BY_ID.get(id)?.name).filter(Boolean).join(", ");
|
||||||
|
|
||||||
interface IngredientPanelProps {
|
interface IngredientPanelProps {
|
||||||
state: MealPlanState;
|
state: MealPlanState;
|
||||||
dispatch: Dispatch<PlanAction>;
|
dispatch: Dispatch<PlanAction>;
|
||||||
|
|
@ -63,7 +66,7 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
||||||
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
|
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
|
<section aria-labelledby="ingredients-heading" className="rounded-3xl border border-border-button-default bg-surface">
|
||||||
<details className="group">
|
<details className="group">
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -115,10 +118,11 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
||||||
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
|
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
|
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
|
||||||
|
aria-describedby="pantry-staples-list"
|
||||||
className="min-h-8 font-medium text-brand-300 hover:underline"
|
className="min-h-8 font-medium text-brand-300 hover:underline"
|
||||||
>
|
>
|
||||||
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
|
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
|
||||||
|
|
@ -132,6 +136,9 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
||||||
Reset all to “Use”
|
Reset all to “Use”
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<p id="pantry-staples-list" className="basis-full text-xs text-ink-muted">
|
||||||
|
Staples: {STAPLE_NAMES}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
|
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
|
||||||
|
|
@ -139,7 +146,8 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
||||||
|
|
||||||
{groups.map(([aisle, items]) => (
|
{groups.map(([aisle, items]) => (
|
||||||
<section key={aisle}>
|
<section key={aisle}>
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
|
{/* z-20: BoardUI segment buttons are z-10, so the sticky header must sit above them while rows scroll under it */}
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-line bg-surface py-1.5">
|
||||||
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
|
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
|
||||||
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
|
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
|
import { RiAlertLine as AlertTriangle, RiArrowLeftRightLine as ArrowLeftRight, RiAddLine as Plus, RiCloseLine as X } from "@remixicon/react";
|
||||||
import { useState, type Dispatch } from "react";
|
import { useState, type Dispatch } from "react";
|
||||||
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
|
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
|
||||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||||
|
|
@ -47,7 +47,7 @@ export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, o
|
||||||
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
|
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
|
<article key={day} className="flex flex-col overflow-hidden rounded-3xl border border-border-button-default bg-surface">
|
||||||
{/* Architectural day header */}
|
{/* Architectural day header */}
|
||||||
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
|
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
|
||||||
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
|
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react";
|
import { RiArrowDownSLine as ChevronDown, RiArrowUpSLine as ChevronUp, RiRepeat2Line as Repeat2, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal, RiCloseLine as X } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Cart } from "@/app/lib/grocery";
|
import { Cart } from "@/app/lib/grocery";
|
||||||
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
|
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
|
||||||
|
|
@ -10,6 +10,7 @@ import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
|
||||||
import { Chip } from "./Chip";
|
import { Chip } from "./Chip";
|
||||||
import { StyleIcon } from "./Icons";
|
import { StyleIcon } from "./Icons";
|
||||||
import { RecipeDetail } from "./RecipeDetail";
|
import { RecipeDetail } from "./RecipeDetail";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
interface MealSelectorModalProps {
|
interface MealSelectorModalProps {
|
||||||
slotKey: string | null;
|
slotKey: string | null;
|
||||||
|
|
@ -37,7 +38,7 @@ export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelec
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||||
aria-labelledby="selector-title"
|
aria-labelledby="selector-title"
|
||||||
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl"
|
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-3xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-3xl"
|
||||||
>
|
>
|
||||||
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
|
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -278,13 +279,9 @@ function SelectorContent({
|
||||||
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
|
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
|
||||||
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
|
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<Button className="mt-4 w-full" onClick={() => onPick(recipe.id)}>
|
||||||
type="button"
|
|
||||||
onClick={() => onPick(recipe.id)}
|
|
||||||
className="trim-cap mt-4 min-h-10 w-full rounded-lg bg-brand-600 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
|
||||||
>
|
|
||||||
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
|
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Check, Plus, Repeat2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export interface OverlapDish {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
cuisine: string;
|
|
||||||
standaloneCheckout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlapComboSnapshot {
|
|
||||||
checkoutTotal: number;
|
|
||||||
usedTotal: number;
|
|
||||||
carryoverValue: number;
|
|
||||||
sharedNames: string[];
|
|
||||||
packageCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverlapExplorerProps {
|
|
||||||
dishes: OverlapDish[];
|
|
||||||
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
|
|
||||||
combos: Record<string, OverlapComboSnapshot>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|
||||||
|
|
||||||
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
|
|
||||||
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
|
|
||||||
|
|
||||||
function toggleDish(id: string) {
|
|
||||||
setSelected((prev) => {
|
|
||||||
if (prev.includes(id)) {
|
|
||||||
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
|
|
||||||
}
|
|
||||||
return [...prev, id];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const comboKey = [...selected].sort().join(",");
|
|
||||||
const current = combos[comboKey];
|
|
||||||
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
|
|
||||||
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
|
|
||||||
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
|
|
||||||
Toggle dishes to watch shared packages drop the bill
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
|
|
||||||
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
|
|
||||||
packages slashes what each extra dinner adds at the register.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
|
|
||||||
{dishes.map((dish) => {
|
|
||||||
const active = selected.includes(dish.id);
|
|
||||||
return (
|
|
||||||
<li key={dish.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => toggleDish(dish.id)}
|
|
||||||
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
|
|
||||||
active
|
|
||||||
? "border-selected-line bg-selected text-ink"
|
|
||||||
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-3 min-w-0">
|
|
||||||
<span
|
|
||||||
className={`grid size-5 shrink-0 place-items-center rounded border ${
|
|
||||||
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
|
|
||||||
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
|
|
||||||
{fmt(dish.standaloneCheckout)} alone
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Simulated paper receipt */}
|
|
||||||
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
|
|
||||||
<span>Checkout Receipt</span>
|
|
||||||
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
|
|
||||||
<div className="flex justify-between text-ink-muted">
|
|
||||||
<dt>Buying each meal separately</dt>
|
|
||||||
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
|
|
||||||
</div>
|
|
||||||
{saved > 0.01 && (
|
|
||||||
<div className="flex justify-between font-semibold text-status-ok">
|
|
||||||
<dt>Shared-package savings</dt>
|
|
||||||
<dd className="tabular-nums">−{fmt(saved)}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
|
|
||||||
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
|
|
||||||
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
|
|
||||||
<div>
|
|
||||||
<dt className="text-ink-faint">Food eaten</dt>
|
|
||||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-ink-faint">Kept in pantry</dt>
|
|
||||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
|
|
||||||
{current && current.sharedNames.length > 0 ? (
|
|
||||||
<p className="flex items-start gap-1.5">
|
|
||||||
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
|
|
||||||
<span>
|
|
||||||
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
|
|
||||||
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>Select a second dish to see which store packages overlap in your cart.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
|
import { RiErrorWarningLine as AlertCircle, RiResetLeftLine as RotateCcw, RiShoppingBag3Line as ShoppingBag, RiCloseLine as X } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { generateMealPlanAction } from "@/app/actions";
|
import { generateMealPlanAction } from "@/app/actions";
|
||||||
import { Cart, buildGroceryList } from "@/app/lib/grocery";
|
import { Cart, buildGroceryList } from "@/app/lib/grocery";
|
||||||
|
|
@ -17,6 +17,7 @@ import { MealGrid } from "./MealGrid";
|
||||||
import { MealSelectorModal } from "./MealSelectorModal";
|
import { MealSelectorModal } from "./MealSelectorModal";
|
||||||
import { Navbar } from "./Navbar";
|
import { Navbar } from "./Navbar";
|
||||||
import { RecipeDialog } from "./RecipeDialog";
|
import { RecipeDialog } from "./RecipeDialog";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
interface UndoSnapshot {
|
interface UndoSnapshot {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -99,7 +100,7 @@ export default function PlannerDashboard() {
|
||||||
|
|
||||||
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
|
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
|
{/* Left: control bar -> pantry & exclusions -> MealGrid */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
state={state}
|
state={state}
|
||||||
|
|
@ -133,6 +134,8 @@ export default function PlannerDashboard() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<IngredientPanel state={state} dispatch={dispatch} />
|
||||||
|
|
||||||
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
|
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
|
||||||
<MealGrid
|
<MealGrid
|
||||||
state={state}
|
state={state}
|
||||||
|
|
@ -143,13 +146,11 @@ export default function PlannerDashboard() {
|
||||||
onRemoveSlot={handleRemoveSlot}
|
onRemoveSlot={handleRemoveSlot}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IngredientPanel state={state} dispatch={dispatch} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: persistent Market Receipt summary (desktop) */}
|
{/* Right: persistent Market Receipt summary (desktop) */}
|
||||||
<aside className="hidden lg:block">
|
<aside className="hidden lg:block">
|
||||||
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
|
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-3xl border border-border-button-default bg-surface p-5">
|
||||||
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
|
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
|
||||||
<GrocerySummaryPanel
|
<GrocerySummaryPanel
|
||||||
summary={summary}
|
summary={summary}
|
||||||
|
|
@ -170,14 +171,9 @@ export default function PlannerDashboard() {
|
||||||
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
|
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
|
||||||
>
|
>
|
||||||
<span className="text-ink">{undo.message}</span>
|
<span className="text-ink">{undo.message}</span>
|
||||||
<button
|
<Button size="small" leadingIcon={RotateCcw} onClick={handleUndo}>
|
||||||
type="button"
|
|
||||||
onClick={handleUndo}
|
|
||||||
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3.5" aria-hidden />
|
|
||||||
Undo
|
Undo
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setUndo(null)}
|
onClick={() => setUndo(null)}
|
||||||
|
|
@ -195,14 +191,9 @@ export default function PlannerDashboard() {
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
|
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button className="shrink-0" leadingIcon={ShoppingBag} onClick={() => setGroceriesOpen(true)}>
|
||||||
type="button"
|
|
||||||
onClick={() => setGroceriesOpen(true)}
|
|
||||||
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
|
|
||||||
>
|
|
||||||
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
|
|
||||||
Receipt ({summary.lines.length})
|
Receipt ({summary.lines.length})
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="sr-only" aria-live="polite">
|
<p className="sr-only" aria-live="polite">
|
||||||
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
|
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { Skeleton } from "@/components/base/skeleton/skeleton";
|
||||||
|
|
||||||
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
|
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -13,18 +14,18 @@ export function PlannerLoader() {
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="animate-pulse" aria-busy="true" aria-label="Loading planner">
|
<div aria-busy="true" aria-label="Loading planner">
|
||||||
<div className="h-15 border-b border-line bg-surface" />
|
<div className="h-15 border-b border-line bg-surface" />
|
||||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
|
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<div className="h-36 rounded-xl bg-raised" />
|
<Skeleton className="h-36 rounded-3xl" />
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{Array.from({ length: 4 }, (_, i) => (
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
<div key={i} className="h-44 rounded-xl bg-raised" />
|
<Skeleton key={i} className="h-44 rounded-3xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
|
<Skeleton className="hidden h-[32rem] rounded-3xl lg:block" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, type KeyboardEvent, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { SegmentedControl, SegmentedControlItem } from "@/components/base/segmented-control/segmented-control";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export interface RadioOption<T extends string | number> {
|
export interface RadioOption<T extends string | number> {
|
||||||
value: T;
|
value: T;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
|
/** Extra classes on the selected segment (e.g. a status tint). */
|
||||||
activeClass?: string;
|
activeClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,79 +20,32 @@ interface RadioGroupProps<T extends string | number> {
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Single-select segmented control: BoardUI's SegmentedControl (React Aria handles roving focus + arrow keys). */
|
||||||
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
|
export function RadioGroup<T extends string | number>({ name, options, value, onChange, size = "sm", fullWidth = false }: RadioGroupProps<T>) {
|
||||||
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
|
|
||||||
*/
|
|
||||||
export function RadioGroup<T extends string | number>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
size = "sm",
|
|
||||||
fullWidth = false,
|
|
||||||
}: RadioGroupProps<T>) {
|
|
||||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
|
||||||
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
|
|
||||||
let nextIndex = -1;
|
|
||||||
|
|
||||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
||||||
nextIndex = (currentIndex + 1) % options.length;
|
|
||||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
||||||
nextIndex = (currentIndex - 1 + options.length) % options.length;
|
|
||||||
} else if (e.key === "Home") {
|
|
||||||
nextIndex = 0;
|
|
||||||
} else if (e.key === "End") {
|
|
||||||
nextIndex = options.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextIndex !== -1) {
|
|
||||||
e.preventDefault();
|
|
||||||
onChange(options[nextIndex].value);
|
|
||||||
buttonsRef.current[nextIndex]?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pad =
|
|
||||||
size === "xs"
|
|
||||||
? fullWidth
|
|
||||||
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
|
|
||||||
: "min-h-8 px-2.5 py-1 text-xs"
|
|
||||||
: fullWidth
|
|
||||||
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
|
|
||||||
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SegmentedControl
|
||||||
role="radiogroup"
|
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
onKeyDown={handleKeyDown}
|
selectedKeys={[value]}
|
||||||
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
|
onSelectionChange={(keys) => {
|
||||||
|
const next = options.find((o) => keys.has(o.value));
|
||||||
|
if (next) onChange(next.value);
|
||||||
|
}}
|
||||||
|
className={cx("max-w-full", fullWidth ? "flex w-full" : "shrink-0")}
|
||||||
>
|
>
|
||||||
{options.map((option, i) => {
|
{options.map((option) => (
|
||||||
const checked = option.value === value;
|
<SegmentedControlItem
|
||||||
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
|
key={String(option.value)}
|
||||||
return (
|
id={option.value}
|
||||||
<button
|
className={cx(
|
||||||
key={String(option.value)}
|
"tabular-nums",
|
||||||
ref={(el) => {
|
size === "xs" && "min-h-7 px-2 text-caption-1-medium",
|
||||||
buttonsRef.current[i] = el;
|
fullWidth && "min-w-0 flex-1",
|
||||||
}}
|
option.value === value && option.activeClass,
|
||||||
type="button"
|
)}
|
||||||
role="radio"
|
>
|
||||||
aria-checked={checked}
|
{option.label}
|
||||||
tabIndex={checked ? 0 : -1}
|
</SegmentedControlItem>
|
||||||
onClick={() => onChange(option.value)}
|
))}
|
||||||
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
|
</SegmentedControl>
|
||||||
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookHeart, Search, SlidersHorizontal } from "lucide-react";
|
import { RiHeart3Line as BookHeart, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
|
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
|
||||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
|
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
|
||||||
|
|
@ -91,7 +91,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-xl border border-line bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
|
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-3xl border border-border-button-default bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
|
||||||
<FilterGroup label="Course">
|
<FilterGroup label="Course">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
|
|
@ -174,7 +174,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/recipes/${r.id}`}
|
href={`/recipes/${r.id}`}
|
||||||
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
className="group flex h-full flex-col rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
|
||||||
{r.styles.includes("caydens_corner") && (
|
{r.styles.includes("caydens_corner") && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Lightbulb } from "lucide-react";
|
import { RiCheckLine as Check, RiLightbulbLine as Lightbulb } from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ingredientLines } from "@/app/lib/display";
|
import { ingredientLines } from "@/app/lib/display";
|
||||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
|
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react";
|
import { RiAlertLine as AlertTriangle, RiArrowRightLine as ArrowRight, RiArrowLeftRightLine as ArrowRightLeft, RiDeleteBinLine as Trash2, RiCloseLine as X } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
|
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
|
||||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||||
import type { MealPlanState } from "@/app/types";
|
import type { MealPlanState } from "@/app/types";
|
||||||
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
|
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
interface RecipeDialogProps {
|
interface RecipeDialogProps {
|
||||||
slotKey: string | null;
|
slotKey: string | null;
|
||||||
|
|
@ -39,7 +40,7 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||||
aria-labelledby="recipe-dialog-title"
|
aria-labelledby="recipe-dialog-title"
|
||||||
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl"
|
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-3xl"
|
||||||
>
|
>
|
||||||
{open && recipe && slotKey && (
|
{open && recipe && slotKey && (
|
||||||
<div className="p-5 sm:p-7">
|
<div className="p-5 sm:p-7">
|
||||||
|
|
@ -79,14 +80,9 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
|
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
|
||||||
<button
|
<Button leadingIcon={ArrowRightLeft} onClick={() => onSwap(slotKey)}>
|
||||||
type="button"
|
|
||||||
onClick={() => onSwap(slotKey)}
|
|
||||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
|
||||||
>
|
|
||||||
<ArrowRightLeft className="size-4" aria-hidden />
|
|
||||||
Replace dish
|
Replace dish
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(slotKey, recipe.title)}
|
onClick={() => onRemove(slotKey, recipe.title)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Flame, Minus, Plus } from "lucide-react";
|
import { RiFireLine as Flame, RiSubtractLine as Minus, RiAddLine as Plus } from "@remixicon/react";
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import type { Recipe } from "@/app/types";
|
import type { Recipe } from "@/app/types";
|
||||||
import { RecipeDetail } from "./RecipeDetail";
|
import { RecipeDetail } from "./RecipeDetail";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
|
import { RiPauseLine as Pause, RiPlayLine as Play, RiRepeat2Line as Repeat2, RiShoppingBag3Line as ShoppingBag } from "@remixicon/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface PreviewSet {
|
export interface PreviewSet {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
|
import { RiHeart3Line as BookHeart, RiBookOpenLine as BookOpen, RiCalendarLine as CalendarRange, RiRestaurantLine as UtensilsCrossed } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { buttonStyles } from "@/components/base/buttons/button";
|
||||||
|
import { ThemeToggle } from "@/components/application/theme/theme-toggle";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
|
type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
|
||||||
|
|
||||||
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
|
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -58,12 +60,15 @@ interface SiteHeaderProps {
|
||||||
|
|
||||||
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
|
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
|
||||||
const linkClass = (isCurrent: boolean) =>
|
const linkClass = (isCurrent: boolean) =>
|
||||||
`trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${
|
cx(
|
||||||
isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink"
|
"inline-flex h-9 items-center gap-1.5 rounded-2lg px-2.5 text-body-medium transition-colors duration-150 sm:px-3",
|
||||||
}`;
|
isCurrent
|
||||||
|
? "bg-background-secondary-default text-text-primary"
|
||||||
|
: "text-text-secondary hover:bg-background-secondary-hover hover:text-text-primary",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/90 backdrop-blur-md">
|
<header className="sticky top-0 z-30 border-b border-separator-border bg-background-primary-default/90 backdrop-blur-md">
|
||||||
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
|
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
|
||||||
<Logo subtitle={subtitle} />
|
<Logo subtitle={subtitle} />
|
||||||
|
|
||||||
|
|
@ -91,9 +96,9 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
|
||||||
{active !== "plan" && (
|
{active !== "plan" && (
|
||||||
<Link
|
<Link
|
||||||
href="/plan"
|
href="/plan"
|
||||||
className="trim-cap ml-1 inline-flex min-h-10 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 sm:px-4"
|
className={cx(buttonStyles.base, buttonStyles.size.medium, buttonStyles.variant.primary, "ml-1 gap-1.5 px-3")}
|
||||||
>
|
>
|
||||||
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
<CalendarRange className={buttonStyles.icon.medium} aria-hidden />
|
||||||
<span className="sm:hidden">Planner</span>
|
<span className="sm:hidden">Planner</span>
|
||||||
<span className="hidden sm:inline">Open planner</span>
|
<span className="hidden sm:inline">Open planner</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -102,7 +107,7 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
|
||||||
|
|
||||||
{rightSlot}
|
{rightSlot}
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle appearance="segmented" size="small" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "plan-my-meals:theme";
|
|
||||||
const EVENT_NAME = "plan-my-meals:theme-change";
|
|
||||||
|
|
||||||
type ResolvedTheme = "light" | "dark";
|
|
||||||
|
|
||||||
function getSnapshot(): ResolvedTheme {
|
|
||||||
const explicit = document.documentElement.dataset.theme;
|
|
||||||
if (explicit === "light" || explicit === "dark") return explicit;
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe(callback: () => void): () => void {
|
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mq.addEventListener("change", callback);
|
|
||||||
window.addEventListener(EVENT_NAME, callback);
|
|
||||||
return () => {
|
|
||||||
mq.removeEventListener("change", callback);
|
|
||||||
window.removeEventListener(EVENT_NAME, callback);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
// Server snapshot returns null so hydration never mismatches; button renders a stable shell first.
|
|
||||||
const theme = useSyncExternalStore<ResolvedTheme | null>(subscribe, getSnapshot, () => null);
|
|
||||||
|
|
||||||
function toggleTheme() {
|
|
||||||
const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark";
|
|
||||||
document.documentElement.dataset.theme = next;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// Ignore blocked storage; dataset.theme still updates the active session.
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new Event(EVENT_NAME));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label={theme === "dark" ? "Switch to light paper theme" : "Switch to dark slate theme"}
|
|
||||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
|
||||||
className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-surface text-ink-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-ink"
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun className="size-4" strokeWidth={1.75} aria-hidden /> : <Moon className="size-4" strokeWidth={1.75} aria-hidden />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
136
app/globals.css
136
app/globals.css
|
|
@ -1,57 +1,95 @@
|
||||||
@import "tailwindcss";
|
@import "../styles/globals.css";
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
Warm Culinary Editorial Theme ("Parchment & Terracotta" / "Cast-Iron & Peach")
|
Plan my Meals BoardUI theme: cool slate neutrals with a salmon accent.
|
||||||
---------------------------------------------------------------------------
|
BoardUI's surfaces, text and borders all read the neutral ramp, and every
|
||||||
Perceptually uniform OKLCH tokens paired with CSS `light-dark()`.
|
CTA / selection / focus state reads the accent ramp, so retinting these two
|
||||||
Every foreground/background pair is verified against WCAG 2.2 AA:
|
ramps restyles the whole system (BoardUI's documented re-tint point).
|
||||||
- Body & 12px metadata (`ink`, `ink-muted`, `ink-faint`) >= 4.5:1
|
Accent 500/600 stay dark enough for white button labels (>= 4.5:1 blended).
|
||||||
- Interactive borders (`line-strong`, `selected-line`) >= 3.0:1 (SC 1.4.11)
|
|
||||||
--------------------------------------------------------------------------- */
|
--------------------------------------------------------------------------- */
|
||||||
@theme {
|
@theme {
|
||||||
/* Surfaces: warm unbleached linen in light mode, cast-iron slate in dark */
|
--color-neutral-50: oklch(98.4% 0.003 247.9);
|
||||||
--color-canvas: light-dark(oklch(97.4% 0.010 81.8), oklch(20.0% 0.021 245.9));
|
--color-neutral-100: oklch(97.4% 0.005 247.9);
|
||||||
--color-surface: light-dark(oklch(99.4% 0.006 84.6), oklch(24.5% 0.028 244.8));
|
--color-neutral-200: oklch(93.8% 0.010 252.0);
|
||||||
--color-raised: light-dark(oklch(94.3% 0.017 79.4), oklch(30.0% 0.035 242.7));
|
--color-neutral-300: oklch(86.9% 0.020 252.9);
|
||||||
--color-line: light-dark(oklch(84.6% 0.029 81.5), oklch(37.6% 0.042 242.8));
|
--color-neutral-400: oklch(70.4% 0.034 256.8);
|
||||||
--color-line-strong: light-dark(oklch(56.7% 0.032 74.0), oklch(62.5% 0.046 239.9));
|
--color-neutral-500: oklch(55.4% 0.040 257.4);
|
||||||
|
--color-neutral-600: oklch(44.6% 0.038 257.3);
|
||||||
|
--color-neutral-700: oklch(37.2% 0.036 257.3);
|
||||||
|
--color-neutral-800: oklch(26.5% 0.032 260.0);
|
||||||
|
--color-neutral-900: oklch(20.5% 0.030 262.0);
|
||||||
|
--color-neutral-925: oklch(16.5% 0.028 263.0);
|
||||||
|
--color-neutral-950: oklch(13.0% 0.026 264.7);
|
||||||
|
|
||||||
/* Typography ink hierarchy */
|
--color-accent-50: oklch(97.3% 0.013 27);
|
||||||
--color-ink: light-dark(oklch(22.8% 0.016 62.5), oklch(96.4% 0.021 79.1));
|
--color-accent-100: oklch(94.0% 0.030 27);
|
||||||
--color-ink-muted: light-dark(oklch(40.7% 0.024 64.6), oklch(84.1% 0.017 250.9));
|
--color-accent-200: oklch(88.5% 0.060 28);
|
||||||
--color-ink-faint: light-dark(oklch(48.0% 0.026 67.1), oklch(72.6% 0.028 251.7));
|
--color-accent-300: oklch(81.5% 0.100 29);
|
||||||
|
--color-accent-400: oklch(72.5% 0.140 30);
|
||||||
|
--color-accent-500: oklch(60.5% 0.155 31);
|
||||||
|
--color-accent-600: oklch(53.5% 0.150 32);
|
||||||
|
--color-accent-700: oklch(46.5% 0.130 32);
|
||||||
|
--color-accent-800: oklch(39.5% 0.105 32);
|
||||||
|
--color-accent-900: oklch(32.0% 0.080 31);
|
||||||
|
--color-accent-950: oklch(23.0% 0.055 30);
|
||||||
|
}
|
||||||
|
|
||||||
/* Primary culinary accent: Terracotta Paprika (light) / Warm Peach (dark) */
|
/* ---------------------------------------------------------------------------
|
||||||
--color-brand-300: light-dark(oklch(49.7% 0.151 37.0), oklch(82.9% 0.092 53.1));
|
App token aliases → BoardUI semantic tokens.
|
||||||
--color-brand-400: light-dark(oklch(51.8% 0.155 37.0), oklch(81.8% 0.098 52.8));
|
The app's components were written against these names; pointing them at
|
||||||
--color-brand-500: light-dark(oklch(48.1% 0.146 36.8), oklch(85.8% 0.088 52.7));
|
BoardUI's tokens gives every screen the BoardUI look and its `.dark` flip.
|
||||||
--color-brand-600: light-dark(oklch(54.0% 0.159 37.1), oklch(80.8% 0.102 52.6));
|
`inline` makes each utility read the BoardUI variable where it is used.
|
||||||
--color-brand-950: light-dark(oklch(95.4% 0.025 65.6), oklch(26.8% 0.011 36.5));
|
--------------------------------------------------------------------------- */
|
||||||
--color-brand-border: light-dark(oklch(71.0% 0.105 44.7), oklch(52.8% 0.088 49.3));
|
@theme inline {
|
||||||
--color-on-accent: light-dark(oklch(98.8% 0.009 67.7), oklch(18.8% 0.013 55.5));
|
--color-canvas: var(--color-background-full);
|
||||||
|
--color-surface: var(--color-background-primary-default);
|
||||||
|
--color-raised: var(--color-background-secondary-default);
|
||||||
|
--color-line: var(--color-separator-border);
|
||||||
|
--color-line-strong: var(--color-border-button-hover);
|
||||||
|
|
||||||
/* Selected control fill & border (>= 4.4:1 boundary contrast) */
|
--color-ink: var(--color-text-primary);
|
||||||
--color-selected: light-dark(oklch(90.8% 0.023 78.2), oklch(35.3% 0.043 243.0));
|
/* BoardUI's text-tertiary (neutral-400 light / 600 dark) is ~2.5:1, too faint for body copy,
|
||||||
--color-selected-line: light-dark(oklch(49.8% 0.034 72.0), oklch(71.7% 0.046 240.1));
|
so the app's two quiet text levels use their own steps that stay >= 4.5:1 (set below). */
|
||||||
|
--color-ink-muted: var(--pm-ink-muted);
|
||||||
|
--color-ink-faint: var(--pm-ink-faint);
|
||||||
|
|
||||||
/* Semantic status tokens (Herb Sage / Saffron Ochre / Chili) */
|
/* Accent: BoardUI's accent ramp; text-on-surface uses the ghost foreground, which flips in dark. */
|
||||||
--color-status-ok: light-dark(oklch(44.7% 0.092 154.8), oklch(84.0% 0.149 155.5));
|
--color-brand-300: var(--color-button-ghost-foreground);
|
||||||
--color-status-ok-bg: light-dark(oklch(95.5% 0.018 161.1), oklch(27.6% 0.040 162.9));
|
--color-brand-600: var(--color-accent-500);
|
||||||
--color-status-ok-line: light-dark(oklch(69.9% 0.094 157.2), oklch(52.0% 0.101 156.2));
|
--color-brand-950: var(--color-button-ghost-background);
|
||||||
|
--color-brand-border: color-mix(in oklab, var(--color-accent-500) 45%, transparent);
|
||||||
|
--color-on-accent: var(--color-text-white);
|
||||||
|
|
||||||
--color-status-warn: light-dark(oklch(47.6% 0.106 63.3), oklch(88.3% 0.120 85.9));
|
--color-selected: var(--color-background-tertiary-default);
|
||||||
--color-status-warn-bg: light-dark(oklch(96.7% 0.029 82.6), oklch(26.8% 0.034 78.8));
|
--color-selected-line: var(--color-accent-500);
|
||||||
--color-status-warn-line: light-dark(oklch(72.6% 0.128 75.7), oklch(55.4% 0.107 76.3));
|
|
||||||
|
|
||||||
--color-status-danger: light-dark(oklch(48.1% 0.167 26.2), oklch(80.8% 0.103 19.6));
|
--color-status-ok: var(--color-notification-success-foreground);
|
||||||
--color-status-danger-bg: light-dark(oklch(95.2% 0.021 17.5), oklch(24.8% 0.043 13.6));
|
--color-status-ok-bg: var(--color-notification-success-background);
|
||||||
--color-status-danger-line: light-dark(oklch(68.1% 0.127 21.0), oklch(49.1% 0.133 18.8));
|
--color-status-ok-line: color-mix(in oklab, var(--color-notification-success-foreground) 45%, transparent);
|
||||||
|
|
||||||
/* Three-voice editorial typography */
|
--color-status-warn: var(--color-status-yellow-text);
|
||||||
--font-sans: var(--font-jakarta), system-ui, sans-serif;
|
--color-status-warn-bg: var(--color-status-yellow-background);
|
||||||
--font-serif: var(--font-fraunces), Georgia, Cambria, serif;
|
--color-status-warn-line: color-mix(in oklab, var(--color-status-yellow-text) 45%, transparent);
|
||||||
--font-mono: var(--font-jb-mono), ui-monospace, SFMono-Regular, monospace;
|
|
||||||
|
|
||||||
|
--color-status-danger: var(--color-notification-error-foreground);
|
||||||
|
--color-status-danger-bg: var(--color-notification-error-background);
|
||||||
|
--color-status-danger-line: color-mix(in oklab, var(--color-notification-error-foreground) 45%, transparent);
|
||||||
|
|
||||||
|
/* BoardUI is single-family (Inter); the old serif display voice folds into it. */
|
||||||
|
--font-serif: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--pm-ink-muted: var(--color-neutral-600);
|
||||||
|
--pm-ink-faint: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--pm-ink-muted: var(--color-neutral-300);
|
||||||
|
--pm-ink-faint: var(--color-neutral-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
/* Animations */
|
/* Animations */
|
||||||
--animate-fade-in: fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
--animate-fade-in: fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--animate-preview-timer: preview-timer 1s linear forwards;
|
--animate-preview-timer: preview-timer 1s linear forwards;
|
||||||
|
|
@ -83,14 +121,10 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
color-scheme: light dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="light"] {
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] {
|
html.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +134,7 @@
|
||||||
|
|
||||||
/* Visible focus for keyboard users on every interactive element. */
|
/* Visible focus for keyboard users on every interactive element. */
|
||||||
:where(button, a, input, select, summary, [tabindex]):focus-visible {
|
:where(button, a, input, select, summary, [tabindex]):focus-visible {
|
||||||
@apply outline-2 outline-offset-2 outline-brand-600;
|
@apply outline-2 outline-offset-2 outline-border-focus-ring;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
|
|
@ -172,12 +206,6 @@
|
||||||
text-box-edge: cap alphabetic;
|
text-box-edge: cap alphabetic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle tactile paper noise texture without GPU-heavy blur blobs */
|
|
||||||
.paper-grain {
|
|
||||||
background-image: radial-gradient(color-mix(in oklab, var(--color-ink) 5%, transparent) 1px, transparent 0);
|
|
||||||
background-size: 18px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Market receipt serrated / perforated divider feel */
|
/* Market receipt serrated / perforated divider feel */
|
||||||
.receipt-rule {
|
.receipt-rule {
|
||||||
border-top: 1px dashed var(--color-line-strong);
|
border-top: 1px dashed var(--color-line-strong);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Fraunces, JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const jakarta = Plus_Jakarta_Sans({
|
// BoardUI's theme reads --font-inter (sans) and --font-mono-source (mono).
|
||||||
variable: "--font-jakarta",
|
const inter = Inter({ variable: "--font-inter", subsets: ["latin"], axes: ["opsz"] });
|
||||||
subsets: ["latin"],
|
const jetbrainsMono = JetBrains_Mono({ variable: "--font-mono-source", subsets: ["latin"] });
|
||||||
});
|
|
||||||
|
|
||||||
const fraunces = Fraunces({
|
|
||||||
variable: "--font-fraunces",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
|
||||||
variable: "--font-jb-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
// Pages set just their own name ("Recipes"); the template adds the app name.
|
// Pages set just their own name ("Recipes"); the template adds the app name.
|
||||||
|
|
@ -25,20 +14,22 @@ export const metadata: Metadata = {
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
{ media: "(prefers-color-scheme: light)", color: "#faf6ef" },
|
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||||
{ media: "(prefers-color-scheme: dark)", color: "#0e171f" },
|
{ media: "(prefers-color-scheme: dark)", color: "#0d1219" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem("plan-my-meals:theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t;}catch(e){}})();`;
|
// Runs before paint so the BoardUI `.dark` class is set without a flash. Carries over the
|
||||||
|
// pre-BoardUI "plan-my-meals:theme" choice once so returning users keep their theme.
|
||||||
|
const THEME_INIT_SCRIPT = `(function(){try{var s=localStorage,t=s.getItem("boardui:theme");if(!t){t=s.getItem("plan-my-meals:theme");if(t)s.setItem("boardui:theme",t);}if(t==="dark")document.documentElement.classList.add("dark");}catch(e){}})();`;
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning className={`${jakarta.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}>
|
<html lang="en" suppressHydrationWarning className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-dvh font-sans">{children}</body>
|
<body className="min-h-dvh bg-background-full font-sans text-text-primary">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ export const CATALOG: StoreItem[] = [
|
||||||
// --- Meat & Eggs ---------------------------------------------------------
|
// --- Meat & Eggs ---------------------------------------------------------
|
||||||
{ id: "chicken_breast", name: "Boneless Skinless Chicken Breast", aisle: "Meat & Eggs", packageSize: 2.5 * LB, baseUnit: "g", packageLabel: "2.5 lb tray", price: 10.5 },
|
{ id: "chicken_breast", name: "Boneless Skinless Chicken Breast", aisle: "Meat & Eggs", packageSize: 2.5 * LB, baseUnit: "g", packageLabel: "2.5 lb tray", price: 10.5 },
|
||||||
{ id: "chicken_thighs", name: "Boneless Skinless Chicken Thighs", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb tray", price: 9.89 },
|
{ id: "chicken_thighs", name: "Boneless Skinless Chicken Thighs", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb tray", price: 9.89 },
|
||||||
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
|
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
|
||||||
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
|
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
|
||||||
{ id: "pork_shoulder", name: "Bone-In Pork Shoulder Roast", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "~3 lb roast", price: 8.39 },
|
{ id: "pork_shoulder", name: "Bone-In Pork Shoulder Roast", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "~3 lb roast", price: 8.39 },
|
||||||
{ id: "smoked_sausage", name: "Smoked Sausage Rope", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz", price: 3.79 },
|
{ id: "smoked_sausage", name: "Smoked Sausage Rope", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz", price: 3.79 },
|
||||||
{ id: "bacon", name: "Hickory Smoked Bacon", aisle: "Meat & Eggs", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz pack", price: 5.09 },
|
{ id: "bacon", name: "Hickory Smoked Bacon", aisle: "Meat & Eggs", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz pack", price: 5.09 },
|
||||||
|
|
@ -38,7 +38,7 @@ export const CATALOG: StoreItem[] = [
|
||||||
|
|
||||||
// --- Dairy -----------------------------------------------------------------
|
// --- Dairy -----------------------------------------------------------------
|
||||||
{ id: "milk", name: "2% Reduced Fat Milk", aisle: "Dairy", packageSize: 128 * FL_OZ, baseUnit: "ml", packageLabel: "1 gallon", price: 3.59 },
|
{ id: "milk", name: "2% Reduced Fat Milk", aisle: "Dairy", packageSize: 128 * FL_OZ, baseUnit: "ml", packageLabel: "1 gallon", price: 3.59 },
|
||||||
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
|
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
|
||||||
{ id: "cheddar", name: "Shredded Mild Cheddar", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
|
{ id: "cheddar", name: "Shredded Mild Cheddar", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
|
||||||
{ id: "mozzarella", name: "Shredded Low-Moisture Mozzarella", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
|
{ id: "mozzarella", name: "Shredded Low-Moisture Mozzarella", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
|
||||||
{ id: "parmesan", name: "Grated Parmesan", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz shaker", price: 3.99, gramsPerMl: 0.42 },
|
{ id: "parmesan", name: "Grated Parmesan", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz shaker", price: 3.99, gramsPerMl: 0.42 },
|
||||||
|
|
@ -66,11 +66,11 @@ export const CATALOG: StoreItem[] = [
|
||||||
|
|
||||||
// --- Pantry: grains, cans, baking -------------------------------------------
|
// --- Pantry: grains, cans, baking -------------------------------------------
|
||||||
{ id: "white_rice", name: "Long Grain White Rice", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.24, gramsPerMl: 0.78 },
|
{ id: "white_rice", name: "Long Grain White Rice", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.24, gramsPerMl: 0.78 },
|
||||||
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
||||||
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
||||||
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
|
||||||
{ id: "oats", name: "Quick Oats", aisle: "Pantry", packageSize: 42 * OZ, baseUnit: "g", packageLabel: "42 oz canister", price: 4.19, gramsPerMl: 0.34 },
|
{ id: "oats", name: "Quick Oats", aisle: "Pantry", packageSize: 42 * OZ, baseUnit: "g", packageLabel: "42 oz canister", price: 4.19, gramsPerMl: 0.34 },
|
||||||
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
|
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
|
||||||
{ id: "flour_tortillas", name: "Flour Tortillas (Taco Size)", aisle: "Bakery", packageSize: 10, baseUnit: "each", packageLabel: "10 ct", price: 2.49 },
|
{ id: "flour_tortillas", name: "Flour Tortillas (Taco Size)", aisle: "Bakery", packageSize: 10, baseUnit: "each", packageLabel: "10 ct", price: 2.49 },
|
||||||
{ id: "sandwich_bread", name: "White Sandwich Bread", aisle: "Bakery", packageSize: 20, baseUnit: "each", packageLabel: "20 oz loaf (~20 slices)", price: 2.29 },
|
{ id: "sandwich_bread", name: "White Sandwich Bread", aisle: "Bakery", packageSize: 20, baseUnit: "each", packageLabel: "20 oz loaf (~20 slices)", price: 2.29 },
|
||||||
{ id: "breadcrumbs", name: "Italian Breadcrumbs", aisle: "Pantry", packageSize: 15 * OZ, baseUnit: "g", packageLabel: "15 oz canister", price: 1.99, gramsPerMl: 0.45 },
|
{ id: "breadcrumbs", name: "Italian Breadcrumbs", aisle: "Pantry", packageSize: 15 * OZ, baseUnit: "g", packageLabel: "15 oz canister", price: 1.99, gramsPerMl: 0.45 },
|
||||||
|
|
@ -122,12 +122,13 @@ export const CATALOG: StoreItem[] = [
|
||||||
|
|
||||||
// --- Cayden's Corner (family recipes) --------------------------------------------
|
// --- Cayden's Corner (family recipes) --------------------------------------------
|
||||||
// Meat & seafood
|
// Meat & seafood
|
||||||
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
|
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
|
||||||
{ id: "turkey_sausage", name: "Smoked Turkey Sausage", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz rope", price: 3.79 },
|
{ id: "turkey_sausage", name: "Smoked Turkey Sausage", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz rope", price: 3.79 },
|
||||||
{ id: "pork_tenderloin", name: "Pork Tenderloin", aisle: "Meat & Eggs", packageSize: 1.25 * LB, baseUnit: "g", packageLabel: "~1.25 lb", price: 6.49 },
|
{ id: "pork_tenderloin", name: "Pork Tenderloin", aisle: "Meat & Eggs", packageSize: 1.25 * LB, baseUnit: "g", packageLabel: "~1.25 lb", price: 6.49 },
|
||||||
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
|
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
|
||||||
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
|
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
|
||||||
{ id: "spiral_ham", name: "Spiral-Sliced Bone-In Half Ham", aisle: "Meat & Eggs", packageSize: 8 * LB, baseUnit: "g", packageLabel: "~8 lb", price: 21.99 },
|
{ id: "spiral_ham", name: "Spiral-Sliced Bone-In Half Ham", aisle: "Meat & Eggs", packageSize: 8 * LB, baseUnit: "g", packageLabel: "~8 lb", price: 21.99 },
|
||||||
|
{ id: "ham_steak", name: "Bone-In Ham Steak", aisle: "Meat & Eggs", packageSize: 1, baseUnit: "each", packageLabel: "~1 lb steak", price: 5.49 },
|
||||||
{ id: "deli_ham", name: "Sliced Deli Ham", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz tub", price: 4.29 },
|
{ id: "deli_ham", name: "Sliced Deli Ham", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz tub", price: 4.29 },
|
||||||
{ id: "bacon_bits", name: "Real Bacon Bits", aisle: "Pantry", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz pouch", price: 3.29, gramsPerMl: 0.3 },
|
{ id: "bacon_bits", name: "Real Bacon Bits", aisle: "Pantry", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz pouch", price: 3.29, gramsPerMl: 0.3 },
|
||||||
{ id: "canned_crab", name: "Lump Crabmeat, canned", aisle: "Seafood", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz can", price: 6.99 },
|
{ id: "canned_crab", name: "Lump Crabmeat, canned", aisle: "Seafood", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz can", price: 6.99 },
|
||||||
|
|
@ -162,9 +163,9 @@ export const CATALOG: StoreItem[] = [
|
||||||
{ id: "crackers", name: "Butter Crackers", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "13.7 oz box", price: 3.49 },
|
{ id: "crackers", name: "Butter Crackers", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "13.7 oz box", price: 3.49 },
|
||||||
{ id: "saltines", name: "Saltine Crackers", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz box", price: 2.99 },
|
{ id: "saltines", name: "Saltine Crackers", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz box", price: 2.99 },
|
||||||
// Baking
|
// Baking
|
||||||
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
|
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
|
||||||
{ id: "vanilla_extract", name: "Vanilla Extract", aisle: "Pantry", packageSize: 2 * FL_OZ, baseUnit: "ml", packageLabel: "2 oz bottle", price: 5.49 },
|
{ id: "vanilla_extract", name: "Vanilla Extract", aisle: "Pantry", packageSize: 2 * FL_OZ, baseUnit: "ml", packageLabel: "2 oz bottle", price: 5.49 },
|
||||||
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: 1 * FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
|
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
|
||||||
{ id: "powdered_sugar", name: "Powdered Sugar", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.59, gramsPerMl: 0.5 },
|
{ id: "powdered_sugar", name: "Powdered Sugar", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.59, gramsPerMl: 0.5 },
|
||||||
{ id: "shortening", name: "Vegetable Shortening", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz can", price: 3.79, gramsPerMl: 0.8 },
|
{ id: "shortening", name: "Vegetable Shortening", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz can", price: 3.79, gramsPerMl: 0.8 },
|
||||||
{ id: "molasses", name: "Molasses", aisle: "Pantry", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 4.29 },
|
{ id: "molasses", name: "Molasses", aisle: "Pantry", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 4.29 },
|
||||||
|
|
@ -191,9 +192,9 @@ export const CATALOG: StoreItem[] = [
|
||||||
// Meat & deli
|
// Meat & deli
|
||||||
{ id: "rotisserie_chicken", name: "Rotisserie Chicken (meat only)", aisle: "Meat & Eggs", packageSize: 1.5 * LB, baseUnit: "g", packageLabel: "1 whole bird (~1.5 lb meat)", price: 6.99, gramsPerMl: 0.59 },
|
{ id: "rotisserie_chicken", name: "Rotisserie Chicken (meat only)", aisle: "Meat & Eggs", packageSize: 1.5 * LB, baseUnit: "g", packageLabel: "1 whole bird (~1.5 lb meat)", price: 6.99, gramsPerMl: 0.59 },
|
||||||
{ id: "chicken_drumsticks", name: "Chicken Drumsticks", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb family pack", price: 5.22 },
|
{ id: "chicken_drumsticks", name: "Chicken Drumsticks", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb family pack", price: 5.22 },
|
||||||
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
|
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
|
||||||
{ id: "pork_chops", name: "Boneless Pork Chops", aisle: "Meat & Eggs", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb pack", price: 9.1 },
|
{ id: "pork_chops", name: "Boneless Pork Chops", aisle: "Meat & Eggs", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb pack", price: 9.1 },
|
||||||
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
|
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
|
||||||
{ id: "diced_ham", name: "Diced Ham", aisle: "Meat & Eggs", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz pack", price: 3.29, gramsPerMl: 0.6 },
|
{ id: "diced_ham", name: "Diced Ham", aisle: "Meat & Eggs", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz pack", price: 3.29, gramsPerMl: 0.6 },
|
||||||
{ id: "hot_dogs", name: "Beef Franks", aisle: "Meat & Eggs", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 4.49 },
|
{ id: "hot_dogs", name: "Beef Franks", aisle: "Meat & Eggs", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 4.49 },
|
||||||
{ id: "frozen_meatballs", name: "Frozen Homestyle Meatballs", aisle: "Frozen", packageSize: 26 * OZ, baseUnit: "g", packageLabel: "26 oz bag", price: 6.49 },
|
{ id: "frozen_meatballs", name: "Frozen Homestyle Meatballs", aisle: "Frozen", packageSize: 26 * OZ, baseUnit: "g", packageLabel: "26 oz bag", price: 6.49 },
|
||||||
|
|
@ -236,7 +237,7 @@ export const CATALOG: StoreItem[] = [
|
||||||
{ id: "naan", name: "Garlic Naan", aisle: "Bakery", packageSize: 4, baseUnit: "each", packageLabel: "4 ct", price: 4.29 },
|
{ id: "naan", name: "Garlic Naan", aisle: "Bakery", packageSize: 4, baseUnit: "each", packageLabel: "4 ct", price: 4.29 },
|
||||||
// Pantry: grains & pasta
|
// Pantry: grains & pasta
|
||||||
{ id: "egg_noodles", name: "Wide Egg Noodles", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 2.19 },
|
{ id: "egg_noodles", name: "Wide Egg Noodles", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 2.19 },
|
||||||
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
|
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
|
||||||
{ id: "rice_noodles", name: "Thin Rice Noodles", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz pack", price: 2.99 },
|
{ id: "rice_noodles", name: "Thin Rice Noodles", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz pack", price: 2.99 },
|
||||||
{ id: "quinoa", name: "Quinoa", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.99, gramsPerMl: 0.72 },
|
{ id: "quinoa", name: "Quinoa", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.99, gramsPerMl: 0.72 },
|
||||||
{ id: "couscous", name: "Couscous", aisle: "Pantry", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz box", price: 2.79, gramsPerMl: 0.73 },
|
{ id: "couscous", name: "Couscous", aisle: "Pantry", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz box", price: 2.79, gramsPerMl: 0.73 },
|
||||||
|
|
@ -275,9 +276,9 @@ export const CATALOG: StoreItem[] = [
|
||||||
{ id: "red_pepper_flakes", name: "Crushed Red Pepper", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.89, gramsPerMl: 0.4 },
|
{ id: "red_pepper_flakes", name: "Crushed Red Pepper", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.89, gramsPerMl: 0.4 },
|
||||||
|
|
||||||
// --- Added for the expanded cuisines ------------------------------------------
|
// --- Added for the expanded cuisines ------------------------------------------
|
||||||
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
|
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
|
||||||
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
|
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
|
||||||
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
|
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
|
||||||
{ id: "feta", name: "Crumbled Feta", aisle: "Dairy", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz tub", price: 3.29, gramsPerMl: 0.53 },
|
{ id: "feta", name: "Crumbled Feta", aisle: "Dairy", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz tub", price: 3.29, gramsPerMl: 0.53 },
|
||||||
{ id: "cucumber", name: "Cucumber", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.89 },
|
{ id: "cucumber", name: "Cucumber", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.89 },
|
||||||
{ id: "roma_tomatoes", name: "Roma Tomato", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.48 },
|
{ id: "roma_tomatoes", name: "Roma Tomato", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.48 },
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const FRACTIONS: [number, string][] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
|
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
|
||||||
export function formatQuantity(value: number): string {
|
function formatQuantity(value: number): string {
|
||||||
if (value >= 10) return String(Math.round(value));
|
if (value >= 10) return String(Math.round(value));
|
||||||
const whole = Math.floor(value);
|
const whole = Math.floor(value);
|
||||||
const fraction = value - whole;
|
const fraction = value - whole;
|
||||||
|
|
@ -30,7 +30,7 @@ export function formatQuantity(value: number): string {
|
||||||
return `${total > 0 ? total : ""}${glyph}` || "0";
|
return `${total > 0 ? total : ""}${glyph}` || "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IngredientLine {
|
interface IngredientLine {
|
||||||
quantity: string;
|
quantity: string;
|
||||||
/** e.g. "cup", "lb", or "×" for counted items. */
|
/** e.g. "cup", "lb", or "×" for counted items. */
|
||||||
unit: string;
|
unit: string;
|
||||||
|
|
@ -39,7 +39,7 @@ export interface IngredientLine {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
|
function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
|
||||||
const item = CATALOG_BY_ID.get(ingredient.itemId);
|
const item = CATALOG_BY_ID.get(ingredient.itemId);
|
||||||
const name = item?.name ?? ingredient.itemId;
|
const name = item?.name ?? ingredient.itemId;
|
||||||
const quantity = formatQuantity(ingredient.amount * scale);
|
const quantity = formatQuantity(ingredient.amount * scale);
|
||||||
|
|
|
||||||
50
app/lib/exportPlan.test.ts
Normal file
50
app/lib/exportPlan.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { jsPDF } from "jspdf";
|
||||||
|
import { buildPlanModel, pdfSafe, renderPlanPdf } from "./exportPlan";
|
||||||
|
import { RECIPES } from "./recipes";
|
||||||
|
|
||||||
|
const breakfast = RECIPES.find((r) => r.kind === "breakfast")!;
|
||||||
|
const dinner = RECIPES.find((r) => r.kind === "main")!;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
daysCount: 2,
|
||||||
|
mealsPerDay: 3,
|
||||||
|
householdSize: 4,
|
||||||
|
// Monday breakfast + dinner, Tuesday dinner; "6-3" is outside daysCount and must not export.
|
||||||
|
slots: { "0-1": breakfast.id, "0-3": dinner.id, "1-3": dinner.id, "6-3": dinner.id },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("plan export", () => {
|
||||||
|
const model = buildPlanModel(state);
|
||||||
|
|
||||||
|
test("organizes meals under each planned day, in meal order", () => {
|
||||||
|
expect(model.days.map((d) => d.name)).toEqual(["Monday", "Tuesday"]);
|
||||||
|
const [monday, tuesday] = model.days;
|
||||||
|
expect(monday.meals.map((m) => ("empty" in m ? `${m.label}: -` : `${m.label}: ${m.title}`))).toEqual([
|
||||||
|
`Breakfast: ${breakfast.title}`,
|
||||||
|
"Lunch: -",
|
||||||
|
`Dinner: ${dinner.title}`,
|
||||||
|
]);
|
||||||
|
expect(tuesday.meals.filter((m) => !("empty" in m))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each meal carries its full recipe, scaled to the household", () => {
|
||||||
|
const meal = model.days[0].meals[0];
|
||||||
|
if ("empty" in meal) throw new Error("expected a planned meal");
|
||||||
|
expect(meal.steps).toEqual(breakfast.steps);
|
||||||
|
expect(meal.ingredients).toHaveLength(breakfast.ingredients.length);
|
||||||
|
expect(meal.meta).toContain("serves 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text is made printable in the base PDF font", () => {
|
||||||
|
expect(pdfSafe("1⅓ cup flour")).toBe("1 1/3 cup flour");
|
||||||
|
expect(pdfSafe("⅛ tsp salt")).toBe("1/8 tsp salt");
|
||||||
|
expect(pdfSafe("½ cup · 350°F — Rakı")).toBe("½ cup · 350°F — Raki");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a real multi-page PDF (one day per page)", () => {
|
||||||
|
const bytes = new Uint8Array(renderPlanPdf(model, jsPDF).output("arraybuffer"));
|
||||||
|
expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
|
||||||
|
expect(renderPlanPdf(model, jsPDF).getNumberOfPages()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
app/lib/exportPlan.ts
Normal file
170
app/lib/exportPlan.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import type { jsPDF as JsPDF } from "jspdf";
|
||||||
|
import type { MealPlanState } from "@/app/types";
|
||||||
|
import { ingredientLines } from "./display";
|
||||||
|
import { activeSlots, mealLabel } from "./plan";
|
||||||
|
import { RECIPES_BY_ID } from "./recipes";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Downloadable weekly plan as a PDF, organized by day, with the full recipe
|
||||||
|
// (scaled ingredients + steps) under every planned meal.
|
||||||
|
//
|
||||||
|
// Two layers: `buildPlanModel` (pure, tested) decides the content;
|
||||||
|
// `renderPlanPdf` lays it out with jsPDF. The planner loads jsPDF on click.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FULL_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||||
|
|
||||||
|
interface PlannedMeal {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
meta: string;
|
||||||
|
description: string;
|
||||||
|
ingredients: string[];
|
||||||
|
steps: string[];
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanModel {
|
||||||
|
intro: string;
|
||||||
|
days: { name: string; meals: (PlannedMeal | { label: string; empty: true })[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanInput = Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots" | "householdSize">;
|
||||||
|
|
||||||
|
export function buildPlanModel(state: PlanInput): PlanModel {
|
||||||
|
const servings = state.householdSize;
|
||||||
|
const slots = activeSlots(state);
|
||||||
|
return {
|
||||||
|
intro: `${state.daysCount} day${state.daysCount === 1 ? "" : "s"} · ingredients scaled for ${servings} ${
|
||||||
|
servings === 1 ? "person" : "people"
|
||||||
|
}. Step wording follows each recipe's original batch size.`,
|
||||||
|
days: FULL_DAY_NAMES.slice(0, state.daysCount).map((name, dayIndex) => ({
|
||||||
|
name,
|
||||||
|
meals: slots
|
||||||
|
.filter((s) => s.dayIndex === dayIndex)
|
||||||
|
.map((s) => {
|
||||||
|
const label = mealLabel(s.mealNumber);
|
||||||
|
const recipe = s.recipeId ? RECIPES_BY_ID.get(s.recipeId) : undefined;
|
||||||
|
if (!recipe) return { label, empty: true as const };
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
title: recipe.title,
|
||||||
|
meta: `${recipe.prepTime} min · serves ${servings}`,
|
||||||
|
description: recipe.description,
|
||||||
|
ingredients: ingredientLines(recipe, servings).map(
|
||||||
|
(l) => `${l.quantity} ${l.unit} ${l.name}${l.detail ? ` (${l.detail})` : ""}`,
|
||||||
|
),
|
||||||
|
steps: recipe.steps,
|
||||||
|
tip: recipe.tip,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsPDF's built-in Helvetica only covers WinAnsi (Latin-1 + a few symbols).
|
||||||
|
const SPELLED: Record<string, string> = { "⅓": " 1/3", "⅔": " 2/3", "⅛": " 1/8", "ı": "i" };
|
||||||
|
const WINANSI_EXTRA = new Set("€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ");
|
||||||
|
|
||||||
|
/** Make text printable in the base PDF font: spell out ⅓-style fractions, decompose or drop anything else. */
|
||||||
|
export function pdfSafe(text: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of text) {
|
||||||
|
const code = ch.codePointAt(0)!;
|
||||||
|
if (code <= 0x7e || (code >= 0xa0 && code <= 0xff) || WINANSI_EXTRA.has(ch)) out += ch;
|
||||||
|
else if (SPELLED[ch]) out += SPELLED[ch];
|
||||||
|
else out += ch.normalize("NFKD").replace(/[^\x20-\x7e]/g, "");
|
||||||
|
}
|
||||||
|
// "⅓ cup" → " 1/3 cup": drop the space the spelling adds when nothing precedes it.
|
||||||
|
return out.replace(/(^|\s) (\d\/\d)/g, "$1$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENT: [number, number, number] = [206, 87, 68]; // matches the app's salmon accent-500
|
||||||
|
const INK: [number, number, number] = [30, 37, 48];
|
||||||
|
const MUTED: [number, number, number] = [74, 85, 101];
|
||||||
|
|
||||||
|
/** Lay the plan out on US Letter pages; one day per page, recipes never split mid-line. */
|
||||||
|
export function renderPlanPdf(model: PlanModel, JsPdf: typeof JsPDF): JsPDF {
|
||||||
|
const doc = new JsPdf({ unit: "pt", format: "letter" });
|
||||||
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
|
const pageH = doc.internal.pageSize.getHeight();
|
||||||
|
const margin = 54;
|
||||||
|
const width = pageW - margin * 2;
|
||||||
|
let y = margin;
|
||||||
|
|
||||||
|
const ensure = (height: number) => {
|
||||||
|
if (y + height > pageH - margin) {
|
||||||
|
doc.addPage();
|
||||||
|
y = margin;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (text: string, opts: { size: number; bold?: boolean; color?: [number, number, number]; indent?: number; gap?: number }) => {
|
||||||
|
const indent = opts.indent ?? 0;
|
||||||
|
const lineH = opts.size * 1.4;
|
||||||
|
doc.setFont("helvetica", opts.bold ? "bold" : "normal").setFontSize(opts.size).setTextColor(...(opts.color ?? INK));
|
||||||
|
for (const line of doc.splitTextToSize(pdfSafe(text), width - indent) as string[]) {
|
||||||
|
ensure(lineH);
|
||||||
|
doc.text(line, margin + indent, y + opts.size);
|
||||||
|
y += lineH;
|
||||||
|
}
|
||||||
|
y += opts.gap ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Numbered/bulleted item with a hanging indent so wrapped lines align under the text. */
|
||||||
|
const item = (marker: string, text: string) => {
|
||||||
|
const size = 10.5;
|
||||||
|
const lineH = size * 1.4;
|
||||||
|
doc.setFont("helvetica", "normal").setFontSize(size).setTextColor(...INK);
|
||||||
|
const lines = doc.splitTextToSize(pdfSafe(text), width - 20) as string[];
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
ensure(lineH);
|
||||||
|
if (i === 0) doc.text(marker, margin + 4, y + size);
|
||||||
|
doc.text(line, margin + 20, y + size);
|
||||||
|
y += lineH;
|
||||||
|
});
|
||||||
|
y += 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
write("My meal plan", { size: 24, bold: true, gap: 4 });
|
||||||
|
write(model.intro, { size: 10.5, color: MUTED, gap: 16 });
|
||||||
|
|
||||||
|
model.days.forEach((day, dayIndex) => {
|
||||||
|
if (dayIndex > 0) {
|
||||||
|
doc.addPage();
|
||||||
|
y = margin;
|
||||||
|
}
|
||||||
|
write(day.name, { size: 18, bold: true, gap: 2 });
|
||||||
|
doc.setDrawColor(...ACCENT).setLineWidth(1.5).line(margin, y, margin + width, y);
|
||||||
|
y += 14;
|
||||||
|
|
||||||
|
for (const meal of day.meals) {
|
||||||
|
ensure(80); // keep a meal's heading with the start of its recipe
|
||||||
|
write(meal.label.toUpperCase(), { size: 9, bold: true, color: ACCENT, gap: 2 });
|
||||||
|
if ("empty" in meal) {
|
||||||
|
write("Nothing planned", { size: 10.5, color: MUTED, gap: 16 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
write(meal.title, { size: 14, bold: true });
|
||||||
|
write(meal.meta, { size: 9.5, color: MUTED, gap: 4 });
|
||||||
|
write(meal.description, { size: 10.5, gap: 8 });
|
||||||
|
write("Ingredients", { size: 11, bold: true, gap: 2 });
|
||||||
|
meal.ingredients.forEach((line) => item("•", line));
|
||||||
|
y += 6;
|
||||||
|
write("Steps", { size: 11, bold: true, gap: 2 });
|
||||||
|
meal.steps.forEach((step, i) => item(`${i + 1}.`, step));
|
||||||
|
if (meal.tip) {
|
||||||
|
y += 4;
|
||||||
|
write(`Tip: ${meal.tip}`, { size: 10, color: MUTED });
|
||||||
|
}
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = doc.getNumberOfPages();
|
||||||
|
for (let page = 1; page <= pages; page++) {
|
||||||
|
doc.setPage(page).setFont("helvetica", "normal").setFontSize(8.5).setTextColor(...MUTED);
|
||||||
|
doc.text(`Plan my Meals · page ${page} of ${pages}`, pageW / 2, pageH - 28, { align: "center" });
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ import { toBaseUnit } from "./units";
|
||||||
// the recipe's own ingredients instead of rebuilding the list.
|
// the recipe's own ingredients instead of rebuilding the list.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface GroceryOptions {
|
interface GroceryOptions {
|
||||||
householdSize: number;
|
householdSize: number;
|
||||||
/** Items already in the kitchen: listed, but cost $0. */
|
/** Items already in the kitchen: listed, but cost $0. */
|
||||||
onHandItems: ItemId[];
|
onHandItems: ItemId[];
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export const slotKey = (dayIndex: number, mealNumber: number) => `${dayIndex}-${
|
||||||
* So changing the count never moves a meal to a different slot, and meals
|
* So changing the count never moves a meal to a different slot, and meals
|
||||||
* hidden by a lower count come back when you raise it again (like days do).
|
* hidden by a lower count come back when you raise it again (like days do).
|
||||||
*/
|
*/
|
||||||
export const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
|
const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
|
||||||
|
|
||||||
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
|
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
|
||||||
export function visibleMeals(mealsPerDay: number): number[] {
|
export function visibleMeals(mealsPerDay: number): number[] {
|
||||||
|
|
@ -107,7 +107,7 @@ export function activeRecipeIds(state: Pick<MealPlanState, "daysCount" | "mealsP
|
||||||
|
|
||||||
// --- Can this recipe be planned? ---------------------------------------------
|
// --- Can this recipe be planned? ---------------------------------------------
|
||||||
|
|
||||||
export interface RecipeBlockers {
|
interface RecipeBlockers {
|
||||||
/** Appliance labels the user doesn't have, e.g. ["Oven"]. */
|
/** Appliance labels the user doesn't have, e.g. ["Oven"]. */
|
||||||
missingAppliances: string[];
|
missingAppliances: string[];
|
||||||
/** Store-item names the user chose to avoid, e.g. ["80/20 Ground Beef"]. */
|
/** Store-item names the user chose to avoid, e.g. ["80/20 Ground Beef"]. */
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const CAYDENS_CORNER_MAINS = dishes({ cuisine: "american" }, [
|
||||||
{ id: "family-crispy-pork-cutlets", title: "Crispy Pork Cutlets with Lemon Butter Sauce", mins: 45, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Panko-crusted pork tenderloin with a silky lemon butter sauce.", items: "pork_tenderloin 2.5 lb; flour 2 tbsp; eggs 2 each; panko 3 cup; white_wine 0.5 cup; lemon 1 each; heavy_cream 2 tbsp; shallots 1 each; minced_garlic 0.5 tsp; butter 0.5 cup; vegetable_oil 0.5 cup" },
|
{ id: "family-crispy-pork-cutlets", title: "Crispy Pork Cutlets with Lemon Butter Sauce", mins: 45, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Panko-crusted pork tenderloin with a silky lemon butter sauce.", items: "pork_tenderloin 2.5 lb; flour 2 tbsp; eggs 2 each; panko 3 cup; white_wine 0.5 cup; lemon 1 each; heavy_cream 2 tbsp; shallots 1 each; minced_garlic 0.5 tsp; butter 0.5 cup; vegetable_oil 0.5 cup" },
|
||||||
{ id: "family-curried-chicken-broccoli-rice-casserole", title: "Curried Chicken Broccoli Rice Casserole", mins: 60, serves: 8, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Curry-mayo chicken and broccoli under cheddar and French-fried onions, over rice.", tip: "This reheats like a dream and the flavors are even better after they've had time to sit, so consider making a large batch so you'll have leftovers.", items: "chicken_breast 1.5 lb; frozen_broccoli 16 oz; mayo 1.5 cup; curry_powder 2 tbsp; cayenne 1.5 tsp; garlic_powder 1 tsp; cheddar 2.5 cup; cream_of_chicken 2 each; cream_of_mushroom 1 each; french_fried_onions 6 oz; white_rice 0.75 cup" },
|
{ id: "family-curried-chicken-broccoli-rice-casserole", title: "Curried Chicken Broccoli Rice Casserole", mins: 60, serves: 8, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Curry-mayo chicken and broccoli under cheddar and French-fried onions, over rice.", tip: "This reheats like a dream and the flavors are even better after they've had time to sit, so consider making a large batch so you'll have leftovers.", items: "chicken_breast 1.5 lb; frozen_broccoli 16 oz; mayo 1.5 cup; curry_powder 2 tbsp; cayenne 1.5 tsp; garlic_powder 1 tsp; cheddar 2.5 cup; cream_of_chicken 2 each; cream_of_mushroom 1 each; french_fried_onions 6 oz; white_rice 0.75 cup" },
|
||||||
{ id: "family-green-chicken-chili-soup", title: "Green Chicken Chili Soup", cuisine: "mexican", mins: 20, serves: 6, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "healthy", "meal_prep"], about: "Salsa verde, white beans and green chiles, slow-cooked with chicken.", items: "olive_oil 2 tbsp; onion 1 each; bell_pepper 1 each; jalapeno 1 each; minced_garlic 2 tsp; chicken_broth 3 cup; white_beans 1 each; salsa_verde 2 cup; green_chiles 1.5 each; chicken_breast 1.5 lb; cumin 1 tsp; chili_powder 1 tsp; salt 1 tsp; lime 1 each; avocado 1 each; cilantro 1 each" },
|
{ id: "family-green-chicken-chili-soup", title: "Green Chicken Chili Soup", cuisine: "mexican", mins: 20, serves: 6, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "healthy", "meal_prep"], about: "Salsa verde, white beans and green chiles, slow-cooked with chicken.", items: "olive_oil 2 tbsp; onion 1 each; bell_pepper 1 each; jalapeno 1 each; minced_garlic 2 tsp; chicken_broth 3 cup; white_beans 1 each; salsa_verde 2 cup; green_chiles 1.5 each; chicken_breast 1.5 lb; cumin 1 tsp; chili_powder 1 tsp; salt 1 tsp; lime 1 each; avocado 1 each; cilantro 1 each" },
|
||||||
{ id: "family-ham-cabbage-potato-soup", title: "Ham, Cabbage, and Potato Soup", mins: 35, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Creamy potato and ham soup thickened with a butter-flour roux.", items: "potatoes 1.25 lb; diced_ham 0.75 cup; celery 1 each; onion 0.5 each; chicken_bouillon 2 tbsp; black_pepper 1 tsp; salt 0.5 tsp; butter 5 tbsp; flour 5 tbsp; milk 2 cup" },
|
{ id: "family-ham-cabbage-potato-soup", title: "Cade's Cabbage Soup", mins: 60, serves: 8, tools: ["stovetop"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "A whole cabbage, chunky skin-on potatoes and bone-in ham, slow-boiled in chicken broth.", tip: "You'll need one large pot with a lid. Buy three 32 oz cartons of broth; you'll probably use about two and a half.", items: "cabbage 1 each; potatoes 3.5 lb; ham_steak 1 each; chicken_broth 10 cup; salt 1 tsp; black_pepper 1 tsp" },
|
||||||
{ id: "family-indian-chicken-curry", title: "Indian Chicken Curry", cuisine: "indian", mins: 40, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Warm-spiced curry with yogurt and coconut milk.", items: "olive_oil 3 tbsp; onion 1 each; minced_garlic 1 tsp; curry_powder 3 tbsp; cinnamon 1 tsp; paprika 1 tsp; ginger_paste 0.5 tsp; sugar 0.5 tsp; chicken_breast 1 lb; tomato_paste 0.1 each; greek_yogurt 1 cup; coconut_milk 0.75 cup; lemon 0.5 each; cayenne 0.5 tsp" },
|
{ id: "family-indian-chicken-curry", title: "Indian Chicken Curry", cuisine: "indian", mins: 40, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Warm-spiced curry with yogurt and coconut milk.", items: "olive_oil 3 tbsp; onion 1 each; minced_garlic 1 tsp; curry_powder 3 tbsp; cinnamon 1 tsp; paprika 1 tsp; ginger_paste 0.5 tsp; sugar 0.5 tsp; chicken_breast 1 lb; tomato_paste 0.1 each; greek_yogurt 1 cup; coconut_milk 0.75 cup; lemon 0.5 each; cayenne 0.5 tsp" },
|
||||||
{ id: "family-japanese-glazed-chicken", title: "Japanese Glazed Chicken", cuisine: "japanese", mins: 15, serves: 4, tools: ["slow_cooker"], styles: ["caydens_corner", "meal_prep"], about: "Drumsticks slow-cooked in a sweet balsamic-soy glaze, over rice.", items: "chicken_drumsticks 2.5 lb; balsamic_vinegar 1 cup; soy_sauce 0.66 cup; sugar 5 tbsp; minced_garlic 1 tsp; jalapeno 2 each; white_rice 1.5 cup" },
|
{ id: "family-japanese-glazed-chicken", title: "Japanese Glazed Chicken", cuisine: "japanese", mins: 15, serves: 4, tools: ["slow_cooker"], styles: ["caydens_corner", "meal_prep"], about: "Drumsticks slow-cooked in a sweet balsamic-soy glaze, over rice.", items: "chicken_drumsticks 2.5 lb; balsamic_vinegar 1 cup; soy_sauce 0.66 cup; sugar 5 tbsp; minced_garlic 1 tsp; jalapeno 2 each; white_rice 1.5 cup" },
|
||||||
{ id: "family-loaded-baked-potato-soup", title: "Loaded Baked Potato Soup", mins: 15, serves: 8, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Dump-and-go crockpot soup with cream cheese, bacon and cheddar.", tip: "Make a large batch of this soup – it reheats extremely well!", items: "frozen_hash_browns 64 oz; cream_cheese 8 oz; cream_of_chicken 2 each; bacon_bits 1 cup; cheddar 1.5 cup; green_onions 1 each; garlic_powder 1 tbsp; chicken_broth 4 cup" },
|
{ id: "family-loaded-baked-potato-soup", title: "Loaded Baked Potato Soup", mins: 15, serves: 8, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Dump-and-go crockpot soup with cream cheese, bacon and cheddar.", tip: "Make a large batch of this soup – it reheats extremely well!", items: "frozen_hash_browns 64 oz; cream_cheese 8 oz; cream_of_chicken 2 each; bacon_bits 1 cup; cheddar 1.5 cup; green_onions 1 each; garlic_powder 1 tbsp; chicken_broth 4 cup" },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngr
|
||||||
// module load, so typos fail loudly in dev instead of producing $0 groceries.
|
// module load, so typos fail loudly in dev instead of producing $0 groceries.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface DishInput {
|
interface DishInput {
|
||||||
title: string;
|
title: string;
|
||||||
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
|
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -35,7 +35,7 @@ export interface DishInput {
|
||||||
|
|
||||||
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
|
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
|
||||||
|
|
||||||
export const slugify = (text: string) =>
|
const slugify = (text: string) =>
|
||||||
text
|
text
|
||||||
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
|
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
|
||||||
.replace(/[\u0300-\u036f]/g, "") // …which this strips
|
.replace(/[\u0300-\u036f]/g, "") // …which this strips
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,6 @@ export const EXTRA_SOUTHERN = dishes({ cuisine: "southern" }, [
|
||||||
{ title: "Cajun Shrimp Pasta", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort", "quick"], about: "Spicy cream sauce, plump shrimp.", items: "spaghetti 1 lb; shrimp 1 lb; heavy_cream 1 cup; cajun_seasoning 1 tbsp; parmesan 0.5 cup; minced_garlic 1 tbsp; butter 2 tbsp" },
|
{ title: "Cajun Shrimp Pasta", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort", "quick"], about: "Spicy cream sauce, plump shrimp.", items: "spaghetti 1 lb; shrimp 1 lb; heavy_cream 1 cup; cajun_seasoning 1 tbsp; parmesan 0.5 cup; minced_garlic 1 tbsp; butter 2 tbsp" },
|
||||||
{ title: "Fish Stick Po' Boys", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Crispy fish, slaw and spicy mayo on hoagies.", items: "fish_sticks 16 oz; hoagie_rolls 4 each; coleslaw_mix 6 oz; mayo 0.25 cup; hot_sauce 1 tbsp; lemon 1 each" },
|
{ title: "Fish Stick Po' Boys", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Crispy fish, slaw and spicy mayo on hoagies.", items: "fish_sticks 16 oz; hoagie_rolls 4 each; coleslaw_mix 6 oz; mayo 0.25 cup; hot_sauce 1 tbsp; lemon 1 each" },
|
||||||
{ title: "Smothered Pork Chops", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Seared chops buried in onion gravy, over rice.", items: "pork_chops 1.5 lb; onion 2 each; flour 3 tbsp; chicken_broth 2 cup; garlic_powder 1 tsp; white_rice 1.5 cup" },
|
{ title: "Smothered Pork Chops", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Seared chops buried in onion gravy, over rice.", items: "pork_chops 1.5 lb; onion 2 each; flour 3 tbsp; chicken_broth 2 cup; garlic_powder 1 tsp; white_rice 1.5 cup" },
|
||||||
{ title: "Ham, Bean & Cabbage Soup", mins: 35, tools: ["stovetop"], styles: ["healthy", "comfort"], about: "Smoky beans, ham and cabbage greens.", items: "white_beans 2 each; diced_ham 8 oz; cabbage 0.5 each; onion 1 each; chicken_broth 4 cup; apple_cider_vinegar 1 tbsp" },
|
|
||||||
{ title: "Shrimp Étouffée", mins: 40, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Buttery blond roux, shrimp, rice.", items: "shrimp 1 lb; butter 4 tbsp; flour 0.25 cup; onion 1 each; bell_pepper 1 each; celery 1 each; chicken_broth 2 cup; cajun_seasoning 2 tsp; white_rice 1.5 cup" },
|
{ title: "Shrimp Étouffée", mins: 40, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Buttery blond roux, shrimp, rice.", items: "shrimp 1 lb; butter 4 tbsp; flour 0.25 cup; onion 1 each; bell_pepper 1 each; celery 1 each; chicken_broth 2 cup; cajun_seasoning 2 tsp; white_rice 1.5 cup" },
|
||||||
{ title: "Low Country Sausage & Cabbage", mins: 30, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Fried cabbage with smoked sausage.", items: "smoked_sausage 13 oz; cabbage 1 each; onion 1 each; butter 2 tbsp; cajun_seasoning 1 tsp" },
|
{ title: "Low Country Sausage & Cabbage", mins: 30, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Fried cabbage with smoked sausage.", items: "smoked_sausage 13 oz; cabbage 1 each; onion 1 each; butter 2 tbsp; cajun_seasoning 1 tsp" },
|
||||||
{ title: "Slow Cooker Beans, Greens & Cornbread", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Set-and-forget beans and greens.", items: "pinto_beans 3 each; diced_ham 8 oz; cabbage 1 each; onion 1 each; chicken_broth 4 cup; hot_sauce 1 tbsp; cornbread_mix 1 each; eggs 1 each; milk 0.33 cup" },
|
{ title: "Slow Cooker Beans, Greens & Cornbread", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Set-and-forget beans and greens.", items: "pinto_beans 3 each; diced_ham 8 oz; cabbage 1 each; onion 1 each; chicken_broth 4 cup; hot_sauce 1 tbsp; cornbread_mix 1 each; eggs 1 each; milk 0.33 cup" },
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,15 @@ export const CAYDENS_CORNER_STEPS: Record<string, string[]> = {
|
||||||
"Stir in lime juice. Serve topped with avocado and cilantro.",
|
"Stir in lime juice. Serve topped with avocado and cilantro.",
|
||||||
],
|
],
|
||||||
"family-ham-cabbage-potato-soup": [
|
"family-ham-cabbage-potato-soup": [
|
||||||
"Combine potatoes, 3¼ cups water, ham, celery, and onion in a stockpot. Bring to a boil.",
|
"Wash the cabbage and remove any dirt, and peel back any dead / dying leaves.",
|
||||||
"Simmer over medium heat until potatoes are tender, 10 to 15 minutes. Stir in chicken bouillon, pepper, and salt.",
|
"Chop the cabbage into large leaf chunks and put them in a large pot.",
|
||||||
"Melt butter in a separate saucepan over medium-low heat. Add flour; cook and stir until thick, about 1 minute.",
|
"Wash 5 whole potatoes and chop them into large cubes. They do not need to be peeled.",
|
||||||
"Whisk in milk; cook and stir until thick, 4 to 5 minutes.",
|
"Put the potatoes in the pot on top of the cabbage.",
|
||||||
"Pour the milk mixture into the stockpot; cook and stir until warmed through.",
|
"Put in the chicken broth until it is filled to a high level.",
|
||||||
|
"Boil down the cabbage and potatoes for 20-30 minutes, or until the cabbage is a soft texture.",
|
||||||
|
"Cut up your ham steak into medium-sized cubes and put in the pot. Add the ham bone as well for extra flavor.",
|
||||||
|
"Continue the slow boil for an additional 15 minutes before taking off heat.",
|
||||||
|
"Put in a generous amount of pepper and salt to taste.",
|
||||||
],
|
],
|
||||||
"family-indian-chicken-curry": [
|
"family-indian-chicken-curry": [
|
||||||
"Heat olive oil in a skillet over medium heat. Sauté onion until lightly browned.",
|
"Heat olive oil in a skillet over medium heat. Sauté onion until lightly browned.",
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export const EXTRA_AMERICAS_STEPS: Record<string, string[]> = {
|
||||||
"cajun-shrimp-pasta": ["Boil spaghetti.", "Toss shrimp with Cajun seasoning; cook in butter 2 min per side; remove.", "Add garlic and cream; simmer 3 min; stir in parmesan.", "Toss pasta and shrimp in the sauce."],
|
"cajun-shrimp-pasta": ["Boil spaghetti.", "Toss shrimp with Cajun seasoning; cook in butter 2 min per side; remove.", "Add garlic and cream; simmer 3 min; stir in parmesan.", "Toss pasta and shrimp in the sauce."],
|
||||||
"fish-stick-po-boys": ["Air fry fish sticks at 400°F 10–12 min.", "Mix mayo with hot sauce and lemon juice.", "Fill rolls with slaw, fish sticks and spicy mayo."],
|
"fish-stick-po-boys": ["Air fry fish sticks at 400°F 10–12 min.", "Mix mayo with hot sauce and lemon juice.", "Fill rolls with slaw, fish sticks and spicy mayo."],
|
||||||
"smothered-pork-chops": ["Cook rice.", "Season chops; dredge in flour; brown in oil and remove.", "Cook sliced onions in the pan 8 min; sprinkle leftover flour and garlic powder, stir 1 min.", "Whisk in broth; return chops, cover and simmer 15 min. Serve over rice."],
|
"smothered-pork-chops": ["Cook rice.", "Season chops; dredge in flour; brown in oil and remove.", "Cook sliced onions in the pan 8 min; sprinkle leftover flour and garlic powder, stir 1 min.", "Whisk in broth; return chops, cover and simmer 15 min. Serve over rice."],
|
||||||
"ham-bean-and-cabbage-soup": ["Brown ham with diced onion.", "Add chopped cabbage, beans and broth; simmer 20 min.", "Stir in vinegar; season."],
|
|
||||||
"shrimp-etouffee": ["Cook rice.", "Melt butter; whisk in flour; stir 8–10 min until peanut-butter colored.", "Add diced onion, pepper and celery; cook 5 min; whisk in broth and Cajun seasoning; simmer 10 min.", "Add shrimp; cook 3–4 min. Serve over rice."],
|
"shrimp-etouffee": ["Cook rice.", "Melt butter; whisk in flour; stir 8–10 min until peanut-butter colored.", "Add diced onion, pepper and celery; cook 5 min; whisk in broth and Cajun seasoning; simmer 10 min.", "Add shrimp; cook 3–4 min. Serve over rice."],
|
||||||
"low-country-sausage-and-cabbage": ["Brown sliced sausage in butter; remove.", "Add sliced onion and chopped cabbage; cook 10–12 min until tender and browned in spots.", "Return sausage; season with Cajun seasoning."],
|
"low-country-sausage-and-cabbage": ["Brown sliced sausage in butter; remove.", "Add sliced onion and chopped cabbage; cook 10–12 min until tender and browned in spots.", "Return sausage; season with Cajun seasoning."],
|
||||||
"slow-cooker-beans-greens-and-cornbread": ["Add beans (with liquid), ham, chopped cabbage, onion, broth and hot sauce to the slow cooker.", "Cook on low 6–8 hours.", "Bake cornbread: mix with egg and milk, bake at 400°F 15–20 min.", "Serve beans and greens with cornbread."],
|
"slow-cooker-beans-greens-and-cornbread": ["Add beans (with liquid), ham, chopped cabbage, onion, broth and hot sauce to the slow cooker.", "Cook on low 6–8 hours.", "Bake cornbread: mix with egg and milk, bake at 400°F 15–20 min.", "Serve beans and greens with cornbread."],
|
||||||
|
|
|
||||||
292
app/page.tsx
292
app/page.tsx
|
|
@ -1,53 +1,17 @@
|
||||||
import { ArrowRight, BookHeart, Flame, ReceiptText, SlidersHorizontal } from "lucide-react";
|
import { RiArrowRightLine, RiHeart3Line, RiRestaurantLine, RiShoppingBasket2Line, RiWallet3Line } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { OverlapExplorer, type OverlapComboSnapshot, type OverlapDish } from "./components/OverlapExplorer";
|
|
||||||
import { RotatingPreview, type PreviewSet } from "./components/RotatingPreview";
|
import { RotatingPreview, type PreviewSet } from "./components/RotatingPreview";
|
||||||
import { Logo, SiteHeader } from "./components/SiteHeader";
|
import { Logo, SiteHeader } from "./components/SiteHeader";
|
||||||
import { CATALOG, PANTRY_STAPLE_IDS } from "./lib/catalog";
|
import { PANTRY_STAPLE_IDS } from "./lib/catalog";
|
||||||
import { buildGroceryList } from "./lib/grocery";
|
import { buildGroceryList } from "./lib/grocery";
|
||||||
import { APPLIANCES, CUISINES } from "./lib/plan";
|
import { CUISINES } from "./lib/plan";
|
||||||
import { RECIPES } from "./lib/recipes";
|
import { RECIPES } from "./lib/recipes";
|
||||||
import { formatUsd } from "./lib/units";
|
import { formatUsd } from "./lib/units";
|
||||||
|
import { buttonStyles } from "@/components/base/buttons/button";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Landing page (server component, fully static). Preview menus are priced from the
|
||||||
// Landing page (server component, fully static).
|
// real recipe & store catalog at build time, so no catalog ships to the client.
|
||||||
//
|
|
||||||
// Every statistic, menu preview, and interactive overlap combination is
|
|
||||||
// derived from the real recipe & store catalog at build time so zero catalog
|
|
||||||
// payload needs to ship to the landing-page client bundle.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const DEMO_RECIPE_IDS = ["chicken-curry", "red-lentil-dal", "chana-masala", "garlic-butter-shrimp-and-rice"] as const;
|
|
||||||
|
|
||||||
const OVERLAP_DATA = (() => {
|
|
||||||
const options = { householdSize: 2, onHandItems: PANTRY_STAPLE_IDS };
|
|
||||||
const dishes: OverlapDish[] = DEMO_RECIPE_IDS.map((id) => {
|
|
||||||
const r = RECIPES.find((x) => x.id === id)!;
|
|
||||||
const cuisineLabel = CUISINES.find((c) => c.id === r.cuisine)?.label ?? r.cuisine;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: r.title,
|
|
||||||
cuisine: `${cuisineLabel} · ${r.prepTime} min`,
|
|
||||||
standaloneCheckout: buildGroceryList([id], options).checkoutTotal,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const combos: Record<string, OverlapComboSnapshot> = {};
|
|
||||||
const totalMasks = 1 << DEMO_RECIPE_IDS.length;
|
|
||||||
for (let mask = 1; mask < totalMasks; mask++) {
|
|
||||||
const picked = DEMO_RECIPE_IDS.filter((_, idx) => (mask & (1 << idx)) !== 0);
|
|
||||||
const key = [...picked].sort().join(",");
|
|
||||||
const summary = buildGroceryList(picked, options);
|
|
||||||
combos[key] = {
|
|
||||||
checkoutTotal: summary.checkoutTotal,
|
|
||||||
usedTotal: summary.usedTotal,
|
|
||||||
carryoverValue: summary.carryoverValue,
|
|
||||||
sharedNames: summary.lines.filter((l) => l.usedBy.length > 1 && !l.onHand).map((l) => l.item.name),
|
|
||||||
packageCount: summary.lines.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { dishes, combos };
|
|
||||||
})();
|
|
||||||
|
|
||||||
const PREVIEW_THEMES: { theme: string; blurb: string; meals: [slot: string, recipeId: string][] }[] = [
|
const PREVIEW_THEMES: { theme: string; blurb: string; meals: [slot: string, recipeId: string][] }[] = [
|
||||||
{ theme: "Comfort classics", blurb: "Cozy & thrifty", meals: [["Mon · Dinner", "chicken-pot-pie"], ["Tue · Lunch", "chicken-noodle-soup"], ["Tue · Dinner", "beef-stroganoff"]] },
|
{ theme: "Comfort classics", blurb: "Cozy & thrifty", meals: [["Mon · Dinner", "chicken-pot-pie"], ["Tue · Lunch", "chicken-noodle-soup"], ["Tue · Dinner", "beef-stroganoff"]] },
|
||||||
|
|
@ -79,208 +43,110 @@ const PREVIEW_SETS: PreviewSet[] = PREVIEW_THEMES.map(({ theme, blurb, meals })
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{
|
{
|
||||||
num: "01",
|
icon: RiWallet3Line,
|
||||||
title: "Set your register target",
|
title: "Tell us your budget",
|
||||||
body: "Choose a weekly grocery ceiling, 1–7 days, meals per day, and household size. Ingredient quantities scale automatically.",
|
body: "How much you want to spend, how many people you're feeding, and which days you're cooking. We'll do the crunching for you.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "02",
|
icon: RiRestaurantLine,
|
||||||
title: "Match your real kitchen",
|
title: "Pick meals you'll love",
|
||||||
body: "Toggle only the appliances you own—stovetop, air fryer, rice cooker, microwave. Exclude allergens or mark items already on your shelf.",
|
body: "Fill the week in one tap or choose dish by dish. We only suggest recipes your kitchen can actually make.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: "03",
|
icon: RiShoppingBasket2Line,
|
||||||
title: "Pack the week with overlap",
|
title: "Shop with one list",
|
||||||
body: "Auto-fill prioritizes dishes that share open packages of produce, dairy, and aromatics—or pick slot-by-slot sorted by marginal cart cost.",
|
body: "Get a single grocery list sorted by aisle, priced the way the store really rings it up (based on averages from October 2026, Central USA).",
|
||||||
},
|
|
||||||
{
|
|
||||||
num: "04",
|
|
||||||
title: "Walk the aisles once",
|
|
||||||
body: "Export one consolidated list grouped by store aisle, priced in whole packages the way checkout actually charges.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const FAMILY_COUNT = RECIPES.filter((r) => r.styles.includes("caydens_corner")).length;
|
const FAMILY_COUNT = RECIPES.filter((r) => r.styles.includes("caydens_corner")).length;
|
||||||
|
|
||||||
|
const ctaClass = "h-11 gap-1.5 rounded-xl px-5 text-body-medium";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
<SiteHeader active="home" />
|
<SiteHeader active="home" />
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* --- Editorial Hero ------------------------------------------------ */}
|
{/* Hero */}
|
||||||
<section className="paper-grain border-b border-line">
|
<section className="mx-auto grid max-w-6xl items-center gap-12 px-4 py-16 sm:px-6 lg:grid-cols-2 lg:py-24">
|
||||||
<div className="mx-auto grid max-w-7xl items-center gap-12 px-4 py-14 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:py-22">
|
<div>
|
||||||
<div>
|
<h1 className="text-display-4-semibold text-balance text-text-primary sm:text-display-2-semibold">
|
||||||
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">
|
Good food all week, <span className="text-brand-300">tailored to your budget.</span>
|
||||||
Weekly Meal & Grocery Ledger · Saved Locally
|
</h1>
|
||||||
</p>
|
<p className="mt-5 max-w-lg text-headline-regular text-pretty text-ink-muted">
|
||||||
<h1 className="mt-3 font-serif text-4xl leading-[1.08] font-semibold tracking-tight text-balance sm:text-5xl lg:text-[3.4rem]">
|
Plan My Meals takes the guesswork out of dinner. Build an entire week of meals around what’s already in your pantry, instantly price out missing ingredients, and explore over 400 high-quality recipes—100% free.
|
||||||
Cook a full week around{" "}
|
</p>
|
||||||
<span className="italic text-brand-300">what groceries actually cost at checkout.</span>
|
|
||||||
</h1>
|
|
||||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-pretty text-ink-muted sm:text-lg">
|
|
||||||
Most meal planners divide a pinch of cumin into imaginary pennies. Plan my Meals prices whole store packages,
|
|
||||||
pairs recipes that share ingredients, and filters strictly to the appliances on your counter.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
<Link
|
<Link href="/plan" className={cx(buttonStyles.base, buttonStyles.variant.primary, ctaClass)}>
|
||||||
href="/plan"
|
Plan my week
|
||||||
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
|
<RiArrowRightLine className="size-5" aria-hidden />
|
||||||
>
|
</Link>
|
||||||
Start this week's plan
|
<Link href="/recipes" className={cx(buttonStyles.base, buttonStyles.variant.secondary, ctaClass)}>
|
||||||
<ArrowRight className="size-4" strokeWidth={2} aria-hidden />
|
Browse recipes
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/recipes"
|
|
||||||
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg border border-line-strong bg-surface px-5 py-3 font-medium text-ink transition-colors hover:bg-raised"
|
|
||||||
>
|
|
||||||
Browse {RECIPES.length} recipes
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Architectural Ledger Metrics */}
|
|
||||||
<dl className="mt-10 grid max-w-xl grid-cols-2 gap-x-6 gap-y-4 border-t border-line pt-6 sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-ink-faint">Tested recipes</dt>
|
|
||||||
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{RECIPES.length}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-ink-faint">Global cuisines</dt>
|
|
||||||
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CUISINES.length}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-ink-faint">Appliances</dt>
|
|
||||||
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{APPLIANCES.length}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs text-ink-faint">Priced items</dt>
|
|
||||||
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CATALOG.length}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RotatingPreview sets={PREVIEW_SETS} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RotatingPreview sets={PREVIEW_SETS} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Interactive Package Overlap Proof ---------------------------- */}
|
{/* How it works */}
|
||||||
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
|
<section id="how-it-works" className="bg-background-secondary-default">
|
||||||
<div className="mb-8 max-w-2xl">
|
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:py-20">
|
||||||
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">Why the math matters</p>
|
<h2 className="text-display-4-semibold text-balance text-text-primary">Three easy steps</h2>
|
||||||
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
<ol className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
Built for the checkout lane, not fictional per-gram math
|
{STEPS.map(({ icon: Icon, title, body }) => (
|
||||||
</h2>
|
<li key={title} className="rounded-3xl border border-border-button-default bg-background-primary-default p-6">
|
||||||
</div>
|
<span className="grid size-10 place-items-center rounded-full bg-button-ghost-background text-button-ghost-foreground">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
<OverlapExplorer dishes={OVERLAP_DATA.dishes} combos={OVERLAP_DATA.combos} />
|
</span>
|
||||||
|
<h3 className="mt-4 text-title-3-semibold text-text-primary">{title}</h3>
|
||||||
{/* Three concise architectural pillars */}
|
<p className="mt-1.5 text-body-regular text-ink-muted">{body}</p>
|
||||||
<div className="mt-8 grid gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-3">
|
|
||||||
<div className="bg-surface p-6">
|
|
||||||
<ReceiptText className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
|
|
||||||
<h3 className="mt-3 font-serif text-lg font-semibold">Whole-package register totals</h3>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
|
|
||||||
You can't buy 0.3 lb of rice. Every ingredient rounds up to real store packages, showing both what you pay
|
|
||||||
today and what carries over into next week's pantry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface p-6">
|
|
||||||
<Flame className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
|
|
||||||
<h3 className="mt-3 font-serif text-lg font-semibold">Appliance-honest filtering</h3>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
|
|
||||||
Cooking in a dorm or studio without an oven? Uncheck it once and every suggested recipe is guaranteed to work on
|
|
||||||
the gear you actually own.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface p-6">
|
|
||||||
<SlidersHorizontal className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
|
|
||||||
<h3 className="mt-3 font-serif text-lg font-semibold">Three-way pantry control</h3>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
|
|
||||||
Mark any store item as <strong className="font-medium text-ink">Have it</strong> ($0 at checkout),{" "}
|
|
||||||
<strong className="font-medium text-ink">Use</strong>, or <strong className="font-medium text-ink">Avoid</strong>{" "}
|
|
||||||
across an entire aisle like Seafood or Dairy.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* --- Exposed-Border Workflow Ledger ------------------------------- */}
|
|
||||||
<section id="how-it-works" className="border-y border-line bg-surface">
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">How it works</p>
|
|
||||||
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
|
|
||||||
Four steps from weekly budget to aisle-sorted list
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="mt-10 grid gap-8 border-t border-line pt-8 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{STEPS.map((step) => (
|
|
||||||
<li key={step.num} className="flex flex-col">
|
|
||||||
<span className="font-mono text-xs font-semibold tracking-widest text-brand-300">{step.num}</span>
|
|
||||||
<h3 className="mt-2 font-serif text-xl font-semibold">{step.title}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-ink-muted">{step.body}</p>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* --- Editorial Cuisines & Cayden's Corner Spotlight --------------- */}
|
{/* What's cooking */}
|
||||||
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
|
<section className="mx-auto grid max-w-6xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center lg:py-20">
|
||||||
<div className="grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
|
<div>
|
||||||
<div>
|
<h2 className="text-display-4-semibold text-balance text-text-primary">
|
||||||
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">On the menu</p>
|
{RECIPES.length} recipes from {CUISINES.length} cuisines
|
||||||
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
|
</h2>
|
||||||
{CUISINES.length} cuisines, from quick weeknight bowls to slow Sunday braises
|
<p className="mt-3 max-w-lg text-headline-regular text-ink-muted">
|
||||||
</h2>
|
Quick weeknight bowls, cozy weekend braises, and everything in between.
|
||||||
<ul className="mt-6 flex flex-wrap gap-2">
|
</p>
|
||||||
{CUISINES.map((c) => (
|
<ul className="mt-6 flex flex-wrap gap-2">
|
||||||
<li key={c.id} className="rounded-full border border-line-strong bg-surface px-3.5 py-1.5 text-sm text-ink">
|
{CUISINES.map((c) => (
|
||||||
{c.label}
|
<li key={c.id} className="rounded-full bg-background-secondary-default px-3.5 py-1.5 text-body-medium text-text-primary">
|
||||||
</li>
|
{c.label}
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
<div className="mt-6">
|
</ul>
|
||||||
<Link
|
</div>
|
||||||
href="/recipes"
|
|
||||||
className="inline-flex items-center gap-1.5 font-medium text-brand-300 hover:underline"
|
|
||||||
>
|
|
||||||
Explore the full {RECIPES.length}-recipe index
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Heirloom Collection Card */}
|
<div className="rounded-3xl border border-brand-border bg-brand-950 p-6 sm:p-8">
|
||||||
<div className="paper-grain rounded-xl border border-brand-border bg-brand-950 p-6 sm:p-8">
|
<RiHeart3Line className="size-6 text-brand-300" aria-hidden />
|
||||||
<div className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-wider text-brand-300 uppercase">
|
<h3 className="mt-3 text-title-1-semibold text-text-primary">Cayden's Corner</h3>
|
||||||
<BookHeart className="size-4" strokeWidth={1.75} aria-hidden />
|
<p className="mt-2 text-body-regular text-ink-muted">
|
||||||
Heirloom Collection
|
{FAMILY_COUNT} favorite family recipes, from holiday sides to Sunday bakes, ready to drop into your week.
|
||||||
</div>
|
</p>
|
||||||
<h3 className="mt-2 font-serif text-2xl font-semibold tracking-tight">Cayden's Corner</h3>
|
<Link href="/caydens-corner" className={cx(buttonStyles.base, buttonStyles.variant.secondary, "mt-5", ctaClass)}>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
|
Open the family cookbook
|
||||||
{FAMILY_COUNT} tried-and-true homemade family recipes—holiday sides, comfort mains, and classic bakes—scaled
|
<RiArrowRightLine className="size-5" aria-hidden />
|
||||||
automatically to your table and integrated right into the weekly planner.
|
</Link>
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/caydens-corner"
|
|
||||||
className="trim-cap mt-5 inline-flex min-h-10 items-center gap-2 rounded-lg border border-line-strong bg-surface px-4 py-2 text-sm font-semibold text-ink transition-colors hover:bg-raised"
|
|
||||||
>
|
|
||||||
Open family cookbook
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-line bg-surface">
|
<footer className="border-t border-separator-border">
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-xs text-ink-faint sm:px-6">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-caption-1-regular text-ink-muted sm:px-6">
|
||||||
<Logo small />
|
<Logo small />
|
||||||
<p>Estimates reflect average grocery prices as of October 2026 and vary by store and region.</p>
|
<p>Prices are estimates from October 2026 and vary by store.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArrowLeft, BookHeart } from "lucide-react";
|
import { RiArrowLeftLine as ArrowLeft, RiHeart3Line as BookHeart } from "@remixicon/react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
@ -101,7 +101,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost ledger + Add to Plan action bar */}
|
{/* Cost ledger + Add to Plan action bar */}
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-line bg-surface p-4 sm:px-5">
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 rounded-3xl border border-border-button-default bg-surface p-4 sm:px-5">
|
||||||
<dl className="flex flex-wrap items-center gap-6 sm:gap-10">
|
<dl className="flex flex-wrap items-center gap-6 sm:gap-10">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-ink-faint">Cost eaten / serving</dt>
|
<dt className="text-xs text-ink-faint">Cost eaten / serving</dt>
|
||||||
|
|
@ -120,7 +120,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
|
||||||
<AddToPlanControl recipeId={recipe.id} recipeTitle={recipe.title} />
|
<AddToPlanControl recipeId={recipe.id} recipeTitle={recipe.title} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 rounded-xl border border-line bg-surface p-5 sm:p-8">
|
<div className="mt-8 rounded-3xl border border-border-button-default bg-surface p-5 sm:p-8">
|
||||||
<RecipeServings recipe={recipe} />
|
<RecipeServings recipe={recipe} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/recipes/${r.id}`}
|
href={`/recipes/${r.id}`}
|
||||||
className="group block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
className="group block rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||||
>
|
>
|
||||||
<span className="font-serif text-lg font-semibold text-ink group-hover:text-brand-300">{r.title}</span>
|
<span className="font-serif text-lg font-semibold text-ink group-hover:text-brand-300">{r.title}</span>
|
||||||
<span className="mt-1 block text-sm text-ink-muted">{r.description}</span>
|
<span className="mt-1 block text-sm text-ink-muted">{r.description}</span>
|
||||||
|
|
|
||||||
77
bun.lock
77
bun.lock
|
|
@ -5,10 +5,13 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "plan-my-meals",
|
"name": "plan-my-meals",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.66.0",
|
"@remixicon/react": "^4.9.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
|
"react-aria-components": "^1.22.1",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
|
"tailwind-merge": "^3.7.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/turbopack": "^4",
|
"@tailwindcss/turbopack": "^4",
|
||||||
|
|
@ -52,6 +55,8 @@
|
||||||
|
|
||||||
"@babel/parser": ["@babel/parser@7.29.12", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg=="],
|
"@babel/parser": ["@babel/parser@7.29.12", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg=="],
|
||||||
|
|
||||||
|
"@babel/runtime": ["@babel/runtime@7.29.13", "", {}, "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ=="],
|
||||||
|
|
||||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||||
|
|
||||||
"@babel/traverse": ["@babel/traverse@7.29.12", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" } }, "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew=="],
|
"@babel/traverse": ["@babel/traverse@7.29.12", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" } }, "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew=="],
|
||||||
|
|
@ -146,6 +151,12 @@
|
||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
|
||||||
|
|
||||||
|
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
|
||||||
|
|
||||||
|
"@internationalized/number": ["@internationalized/number@3.6.9", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g=="],
|
||||||
|
|
||||||
|
"@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
@ -186,6 +197,10 @@
|
||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
|
"@react-types/shared": ["@react-types/shared@3.37.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg=="],
|
||||||
|
|
||||||
|
"@remixicon/react": ["@remixicon/react@4.9.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||||
|
|
@ -232,10 +247,16 @@
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||||
|
|
||||||
|
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||||
|
|
||||||
|
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/type-utils": "8.72.1", "@typescript-eslint/utils": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/type-utils": "8.72.1", "@typescript-eslint/utils": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ=="],
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/types": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g=="],
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/types": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g=="],
|
||||||
|
|
@ -310,6 +331,8 @@
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||||
|
|
@ -340,6 +363,8 @@
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": "dist/cli.cjs" }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": "dist/cli.cjs" }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="],
|
"brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="],
|
||||||
|
|
@ -360,10 +385,14 @@
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001820", "", {}, "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001820", "", {}, "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w=="],
|
||||||
|
|
||||||
|
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
@ -372,8 +401,12 @@
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"core-js": ["core-js@3.51.0", "", { "hasInstallScript": true }, "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||||
|
|
@ -396,6 +429,8 @@
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
|
"dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="],
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.472", "", {}, "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.472", "", {}, "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw=="],
|
||||||
|
|
@ -466,10 +501,14 @@
|
||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||||
|
|
||||||
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
@ -528,6 +567,8 @@
|
||||||
|
|
||||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
|
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
@ -536,6 +577,8 @@
|
||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
|
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
|
|
||||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||||
|
|
@ -612,6 +655,8 @@
|
||||||
|
|
||||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||||
|
|
||||||
|
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
|
||||||
|
|
||||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
@ -654,8 +699,6 @@
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@1.66.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw=="],
|
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
@ -706,6 +749,8 @@
|
||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
@ -714,6 +759,8 @@
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
@ -730,14 +777,24 @@
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||||
|
|
||||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||||
|
|
||||||
|
"react-aria": ["react-aria@3.53.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg=="],
|
||||||
|
|
||||||
|
"react-aria-components": ["react-aria-components@1.22.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.53.1", "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"react-stately": ["react-stately@3.51.0", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
|
|
||||||
|
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||||
|
|
||||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||||
|
|
||||||
"resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
|
"resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
|
||||||
|
|
@ -748,6 +805,8 @@
|
||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||||
|
|
@ -784,6 +843,8 @@
|
||||||
|
|
||||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||||
|
|
||||||
|
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
|
|
||||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||||
|
|
@ -808,10 +869,16 @@
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
@ -846,6 +913,10 @@
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
|
||||||
|
|
||||||
|
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||||
|
|
|
||||||
410
components/application/theme/theme-toggle.tsx
Normal file
410
components/application/theme/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTemplateCopy } from "@/components/foundations/template-copy/template-copy";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { RiMoonLine, RiSunLine } from "@remixicon/react";
|
||||||
|
import { Switch as AriaSwitch } from "react-aria-components";
|
||||||
|
import { SwitchTrack } from "@/components/base/switch/switch";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = "boardui:theme";
|
||||||
|
export const THEME_CHANGE_EVENT = "boardui:theme-change";
|
||||||
|
|
||||||
|
const THEME_TRANSITION_DURATION = 820;
|
||||||
|
const THEME_TRANSITION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
|
||||||
|
const THEME_TRANSITION_STYLE_ID = "boardui-theme-transition-style";
|
||||||
|
|
||||||
|
type ThemeTransitionOrigin = { x: number; y: number };
|
||||||
|
|
||||||
|
type ViewTransition = {
|
||||||
|
ready: Promise<void>;
|
||||||
|
finished: Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewTransitionDocument = Document & {
|
||||||
|
startViewTransition?: (update: () => void) => ViewTransition;
|
||||||
|
};
|
||||||
|
|
||||||
|
let themeTransitionRunning = false;
|
||||||
|
|
||||||
|
function createBlurCircleMask() {
|
||||||
|
const svg = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
|
||||||
|
'<defs><filter id="blur" x="-50%" y="-50%" width="200%" height="200%">',
|
||||||
|
'<feGaussianBlur stdDeviation="2" /></filter></defs>',
|
||||||
|
'<circle cx="50" cy="50" r="42" fill="white" filter="url(#blur)" />',
|
||||||
|
"</svg>",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installThemeTransitionStyle(
|
||||||
|
{ x, y }: ThemeTransitionOrigin,
|
||||||
|
radius: number,
|
||||||
|
duration: number,
|
||||||
|
) {
|
||||||
|
document.getElementById(THEME_TRANSITION_STYLE_ID)?.remove();
|
||||||
|
|
||||||
|
const mask = createBlurCircleMask();
|
||||||
|
const finalMaskSize = radius * 2.5;
|
||||||
|
const finalMaskX = x - finalMaskSize / 2;
|
||||||
|
const finalMaskY = y - finalMaskSize / 2;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = THEME_TRANSITION_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
::view-transition-new(root) {
|
||||||
|
-webkit-mask-image: ${mask};
|
||||||
|
mask-image: ${mask};
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
animation: boardui-theme-mask-reveal ${duration}ms ${THEME_TRANSITION_EASING} both;
|
||||||
|
transform-origin: ${x}px ${y}px;
|
||||||
|
will-change: -webkit-mask-size, -webkit-mask-position, mask-size, mask-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boardui-theme-mask-reveal {
|
||||||
|
from {
|
||||||
|
-webkit-mask-position: ${x}px ${y}px;
|
||||||
|
mask-position: ${x}px ${y}px;
|
||||||
|
-webkit-mask-size: 0px 0px;
|
||||||
|
mask-size: 0px 0px;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
-webkit-mask-position: ${finalMaskX}px ${finalMaskY}px;
|
||||||
|
mask-position: ${finalMaskX}px ${finalMaskY}px;
|
||||||
|
-webkit-mask-size: ${finalMaskSize}px ${finalMaskSize}px;
|
||||||
|
mask-size: ${finalMaskSize}px ${finalMaskSize}px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedTheme(): ThemeMode {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
|
||||||
|
} catch {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: ThemeMode, { persist = true }: { persist?: boolean } = {}) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
if (persist) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
// The control remains usable when storage is blocked or unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: theme }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementCenter(element: HTMLElement | null): ThemeTransitionOrigin {
|
||||||
|
if (!element) {
|
||||||
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
|
}
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedOrigin(
|
||||||
|
origin: ThemeTransitionOrigin | null,
|
||||||
|
element: HTMLElement | null,
|
||||||
|
): ThemeTransitionOrigin {
|
||||||
|
const fallback = elementCenter(element);
|
||||||
|
const x = origin?.x ?? fallback.x;
|
||||||
|
const y = origin?.y ?? fallback.y;
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(x, 0), window.innerWidth),
|
||||||
|
y: Math.min(Math.max(y, 0), window.innerHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveals the next theme from the interaction point using the View Transition
|
||||||
|
* API. Keyboard activation starts at the control's center. Unsupported
|
||||||
|
* browsers and reduced-motion users receive the same immediate theme change.
|
||||||
|
*/
|
||||||
|
export async function applyThemeWithTransition(
|
||||||
|
theme: ThemeMode,
|
||||||
|
{
|
||||||
|
origin = null,
|
||||||
|
element = null,
|
||||||
|
duration = THEME_TRANSITION_DURATION,
|
||||||
|
}: {
|
||||||
|
origin?: ThemeTransitionOrigin | null;
|
||||||
|
element?: HTMLElement | null;
|
||||||
|
duration?: number;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (typeof document === "undefined" || currentTheme() === theme) return;
|
||||||
|
|
||||||
|
const transitionDocument = document as ViewTransitionDocument;
|
||||||
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (!transitionDocument.startViewTransition || reduceMotion) {
|
||||||
|
applyTheme(theme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (themeTransitionRunning) return;
|
||||||
|
|
||||||
|
const { x, y } = normalizedOrigin(origin, element);
|
||||||
|
const radius = Math.hypot(
|
||||||
|
Math.max(x, window.innerWidth - x),
|
||||||
|
Math.max(y, window.innerHeight - y),
|
||||||
|
);
|
||||||
|
|
||||||
|
themeTransitionRunning = true;
|
||||||
|
document.documentElement.classList.add("theme-transitioning");
|
||||||
|
const transitionStyle = installThemeTransitionStyle({ x, y }, radius, duration);
|
||||||
|
try {
|
||||||
|
const transition = transitionDocument.startViewTransition(() => {
|
||||||
|
flushSync(() => applyTheme(theme));
|
||||||
|
});
|
||||||
|
|
||||||
|
await transition.ready;
|
||||||
|
await transition.finished;
|
||||||
|
} catch {
|
||||||
|
// If the browser aborts a transition mid-flight, the theme still changes.
|
||||||
|
if (currentTheme() !== theme) applyTheme(theme);
|
||||||
|
} finally {
|
||||||
|
transitionStyle.remove();
|
||||||
|
document.documentElement.classList.remove("theme-transitioning");
|
||||||
|
themeTransitionRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTheme(): ThemeMode {
|
||||||
|
if (typeof document === "undefined") return "light";
|
||||||
|
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(onStoreChange: () => void) {
|
||||||
|
const onThemeChange = () => onStoreChange();
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== THEME_STORAGE_KEY) return;
|
||||||
|
applyTheme(event.newValue === "dark" ? "dark" : "light", { persist: false });
|
||||||
|
};
|
||||||
|
window.addEventListener(THEME_CHANGE_EVENT, onThemeChange);
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(THEME_CHANGE_EVENT, onThemeChange);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeMode(): ThemeMode {
|
||||||
|
return useSyncExternalStore(subscribe, currentTheme, () => "light");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
/** Compact icon-only treatment for a collapsed sidebar rail. */
|
||||||
|
collapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* Visual treatment for the control. `glass-segmented` is the landing nav's
|
||||||
|
* skin: literal black/white rather than theme tokens, because it sits on the
|
||||||
|
* hero shader's near-white band in both themes and a token that follows the
|
||||||
|
* page would invert out of sight in dark mode.
|
||||||
|
*/
|
||||||
|
appearance?: "sidebar" | "segmented" | "sidebar-segmented" | "glass-segmented";
|
||||||
|
className?: string;
|
||||||
|
/** Size of segmented controls; small fits inside dropdown rows. */
|
||||||
|
size?: "small" | "medium";
|
||||||
|
/** Circular reveal duration in milliseconds. */
|
||||||
|
transitionDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual light/dark control. It never reads the operating-system theme. */
|
||||||
|
export function ThemeToggle({
|
||||||
|
collapsed = false,
|
||||||
|
appearance = "sidebar",
|
||||||
|
className,
|
||||||
|
size = "medium",
|
||||||
|
transitionDuration = THEME_TRANSITION_DURATION,
|
||||||
|
}: ThemeToggleProps) {
|
||||||
|
const localize = useTemplateCopy();
|
||||||
|
const theme = useThemeMode();
|
||||||
|
const dark = theme === "dark";
|
||||||
|
const switchRef = useRef<HTMLLabelElement | null>(null);
|
||||||
|
const pointerOriginRef = useRef<ThemeTransitionOrigin | null>(null);
|
||||||
|
|
||||||
|
// Makes the component self-contained in customer projects. BoardUI itself
|
||||||
|
// also runs the same lookup before hydration to avoid a first-paint flash.
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(storedTheme(), { persist: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (
|
||||||
|
appearance === "segmented" ||
|
||||||
|
appearance === "sidebar-segmented" ||
|
||||||
|
appearance === "glass-segmented"
|
||||||
|
) {
|
||||||
|
const glass = appearance === "glass-segmented";
|
||||||
|
const small = size === "small" && !glass;
|
||||||
|
const sidebarSurface = appearance === "sidebar-segmented";
|
||||||
|
const options = [
|
||||||
|
{ mode: "light" as const, label: "Use light mode", Icon: RiSunLine },
|
||||||
|
{ mode: "dark" as const, label: "Use dark mode", Icon: RiMoonLine },
|
||||||
|
];
|
||||||
|
|
||||||
|
return localize((
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Theme"
|
||||||
|
className={cx(
|
||||||
|
"relative inline-flex w-fit items-center gap-1 rounded-full p-1",
|
||||||
|
// Mobile: 2px padding + 28px segments = 32px tall, matching the
|
||||||
|
// small icon buttons beside it. Desktop keeps the roomier 40px.
|
||||||
|
glass && "p-0.5 sm:p-1",
|
||||||
|
small && "gap-0.5 p-0.5",
|
||||||
|
glass
|
||||||
|
? // No track of its own: the host supplies the surface. A fill
|
||||||
|
// here would paint over it. Kept registry-safe — this component
|
||||||
|
// never imports the landing page's glass.
|
||||||
|
"z-10 bg-transparent"
|
||||||
|
: sidebarSurface
|
||||||
|
? "bg-theme-toggle-sidebar-background"
|
||||||
|
: "bg-background-secondary-default",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute top-1 start-1 size-8 rounded-full",
|
||||||
|
"shadow-xs transition-transform duration-200 ease",
|
||||||
|
glass && "top-0.5 start-0.5 size-7 sm:top-1 sm:start-1 sm:size-8",
|
||||||
|
small && "top-0.5 start-0.5 size-6",
|
||||||
|
glass
|
||||||
|
? "bg-white dark:bg-[#2e2e33]"
|
||||||
|
: sidebarSurface
|
||||||
|
? "bg-theme-toggle-sidebar-selected-background"
|
||||||
|
: "bg-background-primary-default",
|
||||||
|
// Segment width + the 4px gap: 28+4 on mobile, 32+4 above it.
|
||||||
|
dark && (small ? "translate-x-6.5 rtl:-translate-x-6.5" : glass ? "translate-x-8 rtl:-translate-x-8 sm:translate-x-9 sm:rtl:-translate-x-9" : "translate-x-9 rtl:-translate-x-9"),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{options.map(({ mode, label, Icon }) => {
|
||||||
|
const selected = theme === mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={mode === "light" ? "Light mode" : "Dark mode"}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (selected) return;
|
||||||
|
const pointerOrigin =
|
||||||
|
event.clientX === 0 && event.clientY === 0
|
||||||
|
? null
|
||||||
|
: { x: event.clientX, y: event.clientY };
|
||||||
|
void applyThemeWithTransition(mode, {
|
||||||
|
origin: pointerOrigin,
|
||||||
|
element: event.currentTarget,
|
||||||
|
duration: transitionDuration,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"relative z-10 grid size-8 cursor-pointer place-items-center rounded-full outline-none",
|
||||||
|
glass && "size-7 sm:size-8",
|
||||||
|
small && "size-6",
|
||||||
|
"transition-colors duration-150 ease",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||||
|
glass
|
||||||
|
? selected
|
||||||
|
? // Reads against the selected pill, which is white in
|
||||||
|
// light and dark grey in dark — so it flips with it.
|
||||||
|
"text-black dark:text-white"
|
||||||
|
: "text-black/45 hover:text-black/70 dark:text-white/45 dark:hover:text-white/70"
|
||||||
|
: selected
|
||||||
|
? "text-foreground-icon-primary"
|
||||||
|
: "text-foreground-icon-secondary hover:text-foreground-icon-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={small ? "size-3.5" : "size-4"} aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const Icon = dark ? RiSunLine : RiMoonLine;
|
||||||
|
return localize((
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={dark ? "Use light mode" : "Use dark mode"}
|
||||||
|
aria-pressed={dark}
|
||||||
|
title={dark ? "Light mode" : "Dark mode"}
|
||||||
|
onClick={(event) => {
|
||||||
|
const pointerOrigin =
|
||||||
|
event.clientX === 0 && event.clientY === 0
|
||||||
|
? null
|
||||||
|
: { x: event.clientX, y: event.clientY };
|
||||||
|
void applyThemeWithTransition(dark ? "light" : "dark", {
|
||||||
|
origin: pointerOrigin,
|
||||||
|
element: event.currentTarget,
|
||||||
|
duration: transitionDuration,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"flex size-9 cursor-pointer items-center justify-center rounded-2lg",
|
||||||
|
"text-foreground-icon-secondary transition-colors duration-150 ease",
|
||||||
|
"hover:bg-background-secondary-hover hover:text-foreground-icon-primary",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return localize((
|
||||||
|
<AriaSwitch
|
||||||
|
ref={switchRef}
|
||||||
|
isSelected={dark}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
pointerOriginRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
}}
|
||||||
|
onChange={(selected) => {
|
||||||
|
const origin = pointerOriginRef.current;
|
||||||
|
pointerOriginRef.current = null;
|
||||||
|
void applyThemeWithTransition(selected ? "dark" : "light", {
|
||||||
|
origin,
|
||||||
|
element: switchRef.current,
|
||||||
|
duration: transitionDuration,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label="Dark mode"
|
||||||
|
className={({ isFocusVisible }) =>
|
||||||
|
cx(
|
||||||
|
"flex w-full cursor-pointer items-center justify-between rounded-2lg p-2",
|
||||||
|
"transition-colors duration-150 ease hover:bg-background-secondary-hover",
|
||||||
|
isFocusVisible && "ring-2 ring-inset ring-border-focus-ring",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<RiMoonLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
|
||||||
|
<span className="text-body-medium text-text-secondary">Dark mode</span>
|
||||||
|
</span>
|
||||||
|
<SwitchTrack state={state} size="sm" shape="pill" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaSwitch>
|
||||||
|
));
|
||||||
|
}
|
||||||
217
components/base/buttons/button.tsx
Normal file
217
components/base/buttons/button.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import type {
|
||||||
|
AnchorHTMLAttributes,
|
||||||
|
ButtonHTMLAttributes,
|
||||||
|
ComponentType,
|
||||||
|
ReactNode,
|
||||||
|
Ref,
|
||||||
|
} from "react";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { directionalIconClass } from "@/utils/directional-icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma source: Board UI → Buttons (node 3656:13819).
|
||||||
|
*
|
||||||
|
* Variant matrix from Figma:
|
||||||
|
* Type = Primary | Secondary | Ghost | Danger
|
||||||
|
* Size = Medium | Small | Xs
|
||||||
|
* State = Default | Hover | Active | Disabled (CSS pseudo)
|
||||||
|
* OnlyIcon = false | true
|
||||||
|
*
|
||||||
|
* Sizing (1:1 with Figma):
|
||||||
|
*
|
||||||
|
* Medium Small Xs
|
||||||
|
* container h=36, p=8, r=10 h=32, px=8 py=6, r=8 h=24, px=8, r=4
|
||||||
|
* gap 2px 2px 1.33px→1
|
||||||
|
* icon 20×20 18×18 14×14
|
||||||
|
* label wrapper px=4 px=2 px=2
|
||||||
|
* text style Body 1/Medium Body 1/Medium Caption 1/Semibold
|
||||||
|
* icon-only square 36×36 (content-derived) 32×32 (forced size) 24×24 (forced size)
|
||||||
|
*
|
||||||
|
* `xs` is the smallest tier — first needed for the calendar template's
|
||||||
|
* event-details modal ("Join" / edit-icon buttons, node 3920:10954), which
|
||||||
|
* scales every dimension down by the same ~0.667 factor from Figma; the
|
||||||
|
* table above rounds those to clean pixel values rather than reproducing
|
||||||
|
* the fractional source numbers.
|
||||||
|
*
|
||||||
|
* Icons are rendered by the component itself via the `leadingIcon` /
|
||||||
|
* `trailingIcon` props so the consumer can't pass the wrong size. Pass
|
||||||
|
* a Remix Icon component reference (`RiAddLine`, not `<RiAddLine />`).
|
||||||
|
*
|
||||||
|
* For icon-only buttons:
|
||||||
|
* <Button iconOnly leadingIcon={RiAddLine} aria-label="Add" />
|
||||||
|
*
|
||||||
|
* The HTML `type` prop is preserved; Figma's "Type" enum is renamed to
|
||||||
|
* `variant` to avoid the clash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
type ButtonSize = "medium" | "small" | "xs";
|
||||||
|
|
||||||
|
type IconComponent = ComponentType<{
|
||||||
|
className?: string;
|
||||||
|
"aria-hidden"?: boolean | "true" | "false";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
leadingIcon?: IconComponent;
|
||||||
|
trailingIcon?: IconComponent;
|
||||||
|
children?: ReactNode;
|
||||||
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonLinkProps
|
||||||
|
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "children"> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
leadingIcon?: IconComponent;
|
||||||
|
trailingIcon?: IconComponent;
|
||||||
|
children?: ReactNode;
|
||||||
|
ref?: Ref<HTMLAnchorElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = sortCx({
|
||||||
|
base: [
|
||||||
|
"inline-flex items-center justify-center gap-0.5 whitespace-nowrap overflow-hidden",
|
||||||
|
"font-sans select-none cursor-pointer",
|
||||||
|
"button-press-motion",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring",
|
||||||
|
"disabled:cursor-not-allowed aria-disabled:cursor-not-allowed",
|
||||||
|
].join(" "),
|
||||||
|
|
||||||
|
// Base shape per size (used when label is present OR medium icon-only).
|
||||||
|
size: {
|
||||||
|
medium: "h-9 rounded-2lg p-2 text-body-medium",
|
||||||
|
small: "h-8 rounded-lg px-2 py-1.5 text-body-medium",
|
||||||
|
xs: "h-6 rounded-sm px-2 text-caption-1-semibold",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Icon-only override:
|
||||||
|
// Medium → keep p-2; w expands from content (8+20+8 = 36) → square.
|
||||||
|
// Small → Figma forces 32×32 even though 8+18+8=34, so we hard-set size-8
|
||||||
|
// and zero the padding; the inner flex centers the 18px icon.
|
||||||
|
// Xs → forces 24×24, content-centered — used for the calendar
|
||||||
|
// template's edit-icon buttons (timezone/participants/reminder).
|
||||||
|
iconOnlySize: {
|
||||||
|
medium: "", // base size already produces 36×36 with a 20px icon
|
||||||
|
small: "size-8 p-0", // hard 32×32, content-centered
|
||||||
|
xs: "size-6 p-0", // hard 24×24, content-centered
|
||||||
|
},
|
||||||
|
|
||||||
|
icon: {
|
||||||
|
medium: "size-5 shrink-0", // 20px
|
||||||
|
small: "size-[18px] shrink-0", // 18px
|
||||||
|
xs: "size-3.5 shrink-0", // 14px
|
||||||
|
},
|
||||||
|
|
||||||
|
label: {
|
||||||
|
medium: "inline-flex items-center justify-center px-1 shrink-0", // px=4
|
||||||
|
small: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
|
||||||
|
xs: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
|
||||||
|
},
|
||||||
|
|
||||||
|
variant: {
|
||||||
|
primary: [
|
||||||
|
"bg-button-primary text-text-white shadow-xs",
|
||||||
|
"disabled:text-button-primary-disabled-foreground disabled:shadow-none",
|
||||||
|
"aria-disabled:text-button-primary-disabled-foreground aria-disabled:shadow-none",
|
||||||
|
].join(" "),
|
||||||
|
danger: [
|
||||||
|
"bg-button-danger text-text-white shadow-xs",
|
||||||
|
"disabled:text-foreground-disabled-danger disabled:shadow-none",
|
||||||
|
"aria-disabled:text-foreground-disabled-danger aria-disabled:shadow-none",
|
||||||
|
].join(" "),
|
||||||
|
secondary: [
|
||||||
|
"bg-background-primary-default text-text-primary",
|
||||||
|
"border border-border-button-default shadow-xs",
|
||||||
|
"hover:bg-background-primary-hover hover:border-border-button-hover",
|
||||||
|
"active:bg-background-primary-active active:border-border-button-active",
|
||||||
|
"disabled:bg-background-primary-disabled disabled:border-border-button-default disabled:text-text-tertiary disabled:shadow-none",
|
||||||
|
"aria-disabled:bg-background-primary-disabled aria-disabled:border-border-button-default aria-disabled:text-text-tertiary aria-disabled:shadow-none",
|
||||||
|
].join(" "),
|
||||||
|
ghost: [
|
||||||
|
"bg-button-ghost-background text-button-ghost-foreground",
|
||||||
|
"hover:bg-button-ghost-hover active:bg-button-ghost-active",
|
||||||
|
"disabled:bg-button-ghost-disabled disabled:text-button-ghost-disabled-foreground disabled:shadow-none",
|
||||||
|
"aria-disabled:bg-button-ghost-disabled aria-disabled:text-button-ghost-disabled-foreground aria-disabled:shadow-none",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size = "medium",
|
||||||
|
iconOnly = false,
|
||||||
|
leadingIcon: Leading,
|
||||||
|
trailingIcon: Trailing,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cx(
|
||||||
|
styles.base,
|
||||||
|
styles.size[size],
|
||||||
|
styles.variant[variant],
|
||||||
|
iconOnly && styles.iconOnlySize[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
|
||||||
|
{!iconOnly && children !== undefined && children !== null && (
|
||||||
|
<span className={styles.label[size]}>{children}</span>
|
||||||
|
)}
|
||||||
|
{!iconOnly && Trailing ? (
|
||||||
|
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchor counterpart to Button for navigational actions. */
|
||||||
|
export function ButtonLink({
|
||||||
|
variant = "primary",
|
||||||
|
size = "medium",
|
||||||
|
iconOnly = false,
|
||||||
|
leadingIcon: Leading,
|
||||||
|
trailingIcon: Trailing,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: ButtonLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
ref={ref}
|
||||||
|
className={cx(
|
||||||
|
styles.base,
|
||||||
|
styles.size[size],
|
||||||
|
styles.variant[variant],
|
||||||
|
iconOnly && styles.iconOnlySize[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
|
||||||
|
{!iconOnly && children !== undefined && children !== null && (
|
||||||
|
<span className={styles.label[size]}>{children}</span>
|
||||||
|
)}
|
||||||
|
{!iconOnly && Trailing ? (
|
||||||
|
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style maps, exported for advanced composition and the dev Design Tuner. */
|
||||||
|
export const buttonStyles = styles;
|
||||||
70
components/base/input/label.tsx
Normal file
70
components/base/input/label.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import { RiInformationFill } from "@remixicon/react";
|
||||||
|
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
||||||
|
import { Label as AriaLabel } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field label. Wraps `react-aria-components`' Label so the parent `TextField`
|
||||||
|
* automatically wires `htmlFor` / `aria-labelledby`.
|
||||||
|
*
|
||||||
|
* Visuals come from Figma:
|
||||||
|
* - Inter Medium 14/20, color text/primary
|
||||||
|
* - 2px gap between label + required asterisk + info icon
|
||||||
|
* - asterisk is text/error-primary (red/500)
|
||||||
|
* - info icon is 16×16 in foreground/icon/quaternary
|
||||||
|
*
|
||||||
|
* Pass `isRequired` to show the asterisk. Pass `tooltip` to show the info
|
||||||
|
* icon (currently a static glyph — when the Tooltip component lands it'll
|
||||||
|
* wire up here).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LabelProps extends AriaLabelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
isRequired?: boolean;
|
||||||
|
/** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/** Show the info icon next to the label. */
|
||||||
|
tooltip?: boolean | string;
|
||||||
|
ref?: Ref<HTMLLabelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({
|
||||||
|
isRequired = false,
|
||||||
|
isInvalid: _isInvalid,
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: LabelProps) {
|
||||||
|
void _isInvalid;
|
||||||
|
return (
|
||||||
|
<AriaLabel
|
||||||
|
data-label="true"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-default items-center gap-0.5",
|
||||||
|
"text-body-medium text-text-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isRequired && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-body-medium text-text-error-primary"
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tooltip && (
|
||||||
|
<RiInformationFill
|
||||||
|
className="size-4 shrink-0 text-foreground-icon-quaternary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AriaLabel>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/base/meter/meter.tsx
Normal file
74
components/base/meter/meter.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Meter as AriaMeter } from "react-aria-components";
|
||||||
|
import type { MeterProps as AriaMeterProps } from "react-aria-components";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { ProgressTrack, type ProgressSize } from "@/components/base/progress/progress";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meter — a quantity within a known range: storage used, seats taken, a
|
||||||
|
* password's strength. Unlike ProgressBar it describes a level rather than
|
||||||
|
* a task moving towards done, and React Aria's Meter announces it that way
|
||||||
|
* (role="meter").
|
||||||
|
*
|
||||||
|
* <Meter label="Storage" value={72} valueLabel="7.2 of 10 GB" />
|
||||||
|
* <Meter label="Seats" value={19} maxValue={20} tone="warning" />
|
||||||
|
*
|
||||||
|
* It is drawn on ProgressBar's track: the same rail, fill, and eased
|
||||||
|
* translate when the value changes. `tone` colours the fill from semantic
|
||||||
|
* tokens, so a meter can turn amber or red as it fills up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MeterTone = "accent" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
const tones = sortCx({
|
||||||
|
accent: "text-accent-500",
|
||||||
|
success: "text-notification-success-foreground",
|
||||||
|
warning: "text-chart-8-active",
|
||||||
|
error: "text-notification-error-foreground",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface MeterProps extends Omit<AriaMeterProps, "children" | "className"> {
|
||||||
|
label?: ReactNode;
|
||||||
|
/** Show the formatted value at the end of the label row. Default: shown with a label. */
|
||||||
|
showValueLabel?: boolean;
|
||||||
|
/** Helper text under the bar. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Colour of the fill. Default `accent`. */
|
||||||
|
tone?: MeterTone;
|
||||||
|
/** Track thickness: 4, 6 (default), or 8px. */
|
||||||
|
size?: ProgressSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Meter({
|
||||||
|
label,
|
||||||
|
showValueLabel,
|
||||||
|
description,
|
||||||
|
tone = "accent",
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MeterProps) {
|
||||||
|
const showValue = showValueLabel ?? label != null;
|
||||||
|
return (
|
||||||
|
<AriaMeter {...props} className={cx("flex w-full flex-col gap-1.5", className)}>
|
||||||
|
{({ percentage, valueText }) => (
|
||||||
|
<>
|
||||||
|
{label != null || showValue ? (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
|
||||||
|
{showValue ? (
|
||||||
|
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ProgressTrack percentage={percentage} size={size} className={tones[tone]} />
|
||||||
|
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaMeter>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
components/base/progress/progress.tsx
Normal file
197
components/base/progress/progress.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ProgressBar as AriaProgressBar } from "react-aria-components";
|
||||||
|
import type { ProgressBarProps as AriaProgressBarProps } from "react-aria-components";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { SpinnerGlyph } from "@/components/base/spinner/spinner";
|
||||||
|
import { useDirection } from "@/components/foundations/direction/direction";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress — how far along a task is: an upload, an import, an onboarding
|
||||||
|
* checklist. Built on React Aria's ProgressBar, so it is announced with its
|
||||||
|
* label and value, and formats the value for the locale.
|
||||||
|
*
|
||||||
|
* <ProgressBar label="Uploading Q3-report.pdf" value={64} />
|
||||||
|
* <ProgressBar label="Preparing export" isIndeterminate />
|
||||||
|
* <ProgressCircle value={64} aria-label="Upload" />
|
||||||
|
*
|
||||||
|
* The bar is the agent limits card's rounded rail with an accent fill, on
|
||||||
|
* File Upload's track colour. The fill moves by translate rather than width, so it eases
|
||||||
|
* to each new value on the compositor (500ms, the card's ease-out).
|
||||||
|
*
|
||||||
|
* Indeterminate, a band of light sweeps the track, drawn like Spinner and
|
||||||
|
* Composer Loader: a crisp core with a rounded head, a tight glow and a wide
|
||||||
|
* bloom that bleed a few pixels past the rail, the soft layers pulled back
|
||||||
|
* so the sharp head leads. It runs right to left in RTL. The ring's
|
||||||
|
* indeterminate state is Spinner's orbiting light. Reduced motion slows the
|
||||||
|
* sweep rather than stopping it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProgressSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]";
|
||||||
|
|
||||||
|
const styles = sortCx({
|
||||||
|
track: {
|
||||||
|
sm: "h-1",
|
||||||
|
md: "h-1.5",
|
||||||
|
lg: "h-2",
|
||||||
|
},
|
||||||
|
ring: {
|
||||||
|
sm: { box: 16, line: 2, spinner: "sm" },
|
||||||
|
md: { box: 24, line: 2.5, spinner: "lg" },
|
||||||
|
lg: { box: 32, line: 3, spinner: "xl" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- bar */
|
||||||
|
|
||||||
|
export interface ProgressBarProps extends Omit<AriaProgressBarProps, "children" | "className"> {
|
||||||
|
label?: ReactNode;
|
||||||
|
/** Show the formatted value at the end of the label row. Default: shown with a label. */
|
||||||
|
showValueLabel?: boolean;
|
||||||
|
/** Helper text under the bar. */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Track thickness: 4, 6 (default), or 8px. */
|
||||||
|
size?: ProgressSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sweeping band of light for an indeterminate bar. */
|
||||||
|
function Sweep() {
|
||||||
|
return (
|
||||||
|
// The clip keeps the light inside the rail's length but lets the glow
|
||||||
|
// and bloom bleed past its thickness.
|
||||||
|
<span aria-hidden className="absolute inset-0 [clip-path:inset(-8px_0_round_9999px)]">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"absolute inset-y-0 left-0 w-2/5",
|
||||||
|
"animate-[bui-progress-sweep_1.6s_cubic-bezier(0.65,0,0.35,1)_infinite]",
|
||||||
|
"rtl:[animation-direction:reverse] motion-reduce:[animation-duration:4s]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 rtl:-scale-x-100">
|
||||||
|
<span className="absolute -inset-y-[5px] right-[10px] left-0 rounded-full bg-linear-to-r from-transparent to-current opacity-30 blur-[5px]" />
|
||||||
|
<span className="absolute -inset-y-[1.5px] right-[4px] left-[25%] rounded-full bg-linear-to-r from-transparent to-current opacity-60 blur-[2px]" />
|
||||||
|
<span className="absolute inset-y-0 right-0 left-[35%] rounded-full bg-linear-to-r from-transparent to-current" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressTrackProps {
|
||||||
|
/** 0 to 100. */
|
||||||
|
percentage: number;
|
||||||
|
size?: ProgressSize;
|
||||||
|
/** Draw the indeterminate sweep instead of a fill. */
|
||||||
|
isIndeterminate?: boolean;
|
||||||
|
/** Colour of the fill, from the text colour. Default accent. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rail and its fill, shared by ProgressBar and Meter. */
|
||||||
|
export function ProgressTrack({ percentage, size = "md", isIndeterminate = false, className }: ProgressTrackProps) {
|
||||||
|
const direction = useDirection();
|
||||||
|
return (
|
||||||
|
// The rail is File Upload's ring track colour, which keeps an edge on
|
||||||
|
// every surface in both themes (chart-track matches dark cards).
|
||||||
|
<div className={cx("relative w-full rounded-full bg-border-button-default text-accent-500", styles.track[size], className)}>
|
||||||
|
{isIndeterminate ? (
|
||||||
|
<Sweep />
|
||||||
|
) : (
|
||||||
|
<span className="absolute inset-0 overflow-hidden rounded-full">
|
||||||
|
<span
|
||||||
|
className={cx("absolute inset-0 rounded-full bg-current transition-[translate] duration-500 motion-reduce:transition-none", EASE)}
|
||||||
|
style={{ translate: `${(direction === "rtl" ? 1 : -1) * (100 - Math.min(100, Math.max(0, percentage)))}% 0` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({
|
||||||
|
label,
|
||||||
|
showValueLabel,
|
||||||
|
description,
|
||||||
|
size = "md",
|
||||||
|
isIndeterminate,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const showValue = showValueLabel ?? (label != null && !isIndeterminate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className={cx("flex w-full flex-col gap-1.5", className)}>
|
||||||
|
{({ percentage = 0, valueText }) => (
|
||||||
|
<>
|
||||||
|
{label != null || showValue ? (
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
|
||||||
|
{showValue ? (
|
||||||
|
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ProgressTrack percentage={percentage} size={size} isIndeterminate={isIndeterminate} />
|
||||||
|
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaProgressBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- ring */
|
||||||
|
|
||||||
|
export interface ProgressCircleProps extends Omit<AriaProgressBarProps, "children" | "className"> {
|
||||||
|
/** 16, 24 (default), or 32px. */
|
||||||
|
size?: ProgressSize;
|
||||||
|
/** Colour of the arc, from the text colour. Default accent. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ring for tight spots: a file tile, a table cell, a checklist header. Name
|
||||||
|
* it with `aria-label`. The arc draws by stroke-dashoffset, a paint-only
|
||||||
|
* change on a few pixels of SVG, eased like the bar.
|
||||||
|
*/
|
||||||
|
export function ProgressCircle({ size = "md", isIndeterminate, className, ...props }: ProgressCircleProps) {
|
||||||
|
const { box, line, spinner } = styles.ring[size];
|
||||||
|
const radius = (box - line) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className="inline-flex shrink-0">
|
||||||
|
{({ percentage = 0 }) =>
|
||||||
|
isIndeterminate ? (
|
||||||
|
<SpinnerGlyph size={spinner as "sm" | "lg" | "xl"} className={className} />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
width={box}
|
||||||
|
height={box}
|
||||||
|
viewBox={`0 0 ${box} ${box}`}
|
||||||
|
className={cx("-rotate-90 text-accent-500", className)}
|
||||||
|
>
|
||||||
|
<circle cx={box / 2} cy={box / 2} r={radius} fill="none" strokeWidth={line} className="stroke-border-button-default" />
|
||||||
|
<circle
|
||||||
|
cx={box / 2}
|
||||||
|
cy={box / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={line}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={circumference * (1 - percentage / 100)}
|
||||||
|
className={cx("transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none", EASE)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</AriaProgressBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
components/base/segmented-control/segmented-control.tsx
Normal file
148
components/base/segmented-control/segmented-control.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDirection } from "@/components/foundations/direction/direction";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import {
|
||||||
|
ToggleButton as AriaToggleButton,
|
||||||
|
ToggleButtonGroup as AriaToggleButtonGroup,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import type {
|
||||||
|
ToggleButtonGroupProps as AriaToggleButtonGroupProps,
|
||||||
|
ToggleButtonProps as AriaToggleButtonProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma source: Board UI → dashboard 1 Weekly/Monthly/Yearly switch
|
||||||
|
* (node 3731:3096).
|
||||||
|
*
|
||||||
|
* Single-select segmented control.
|
||||||
|
* track bg background/tertiary/default, p 4, radius 10 (radius/2lg), gap 2
|
||||||
|
* segment px 10, py 4, radius/md (6px)
|
||||||
|
* selected bg segmented-control/selected (white in light), shadow/2xs, Body 1/Medium,
|
||||||
|
* text/primary
|
||||||
|
* unselected transparent, Body 1/Regular, text/secondary
|
||||||
|
*
|
||||||
|
* Built on react-aria ToggleButtonGroup with single selection that can't be
|
||||||
|
* emptied — arrow keys move focus, Space/Enter selects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `solid` (default) is the Figma design — tertiary track, white thumb behind
|
||||||
|
* the selected segment. `plain` drops the track background and the thumb; the
|
||||||
|
* selected segment reads only through its text weight/color.
|
||||||
|
*/
|
||||||
|
export type SegmentedControlVariant = "solid" | "plain";
|
||||||
|
|
||||||
|
export interface SegmentedControlProps
|
||||||
|
extends Omit<AriaToggleButtonGroupProps, "selectionMode" | "disallowEmptySelection"> {
|
||||||
|
children?: ReactNode;
|
||||||
|
variant?: SegmentedControlVariant;
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Thumb = { left: number; top: number; width: number; height: number };
|
||||||
|
|
||||||
|
export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) {
|
||||||
|
const direction = useDirection();
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [thumb, setThumb] = useState<Thumb | null>(null);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
const el = innerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const selected = el.querySelector<HTMLElement>("[data-selected]");
|
||||||
|
if (selected) {
|
||||||
|
setThumb({
|
||||||
|
left: selected.offsetLeft,
|
||||||
|
top: selected.offsetTop,
|
||||||
|
width: selected.offsetWidth,
|
||||||
|
height: selected.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
const observeItems = () => el.querySelectorAll("button").forEach(item => ro.observe(item));
|
||||||
|
observeItems();
|
||||||
|
const mo = new MutationObserver(() => { observeItems(); measure(); });
|
||||||
|
mo.observe(el, { attributes: true, childList: true, characterData: true, subtree: true, attributeFilter: ["data-selected"] });
|
||||||
|
return () => {
|
||||||
|
mo.disconnect();
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [direction]);
|
||||||
|
|
||||||
|
const setRefs = (node: HTMLDivElement | null) => {
|
||||||
|
innerRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaToggleButtonGroup
|
||||||
|
ref={setRefs}
|
||||||
|
selectionMode="single"
|
||||||
|
disallowEmptySelection
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative inline-flex items-start gap-0.5 rounded-2lg",
|
||||||
|
variant === "solid" && "bg-segmented-control-background p-1",
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{variant === "solid" && thumb && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute left-0 top-0 rounded-md bg-segmented-control-selected-background shadow-2xs transition-[transform,width,height] duration-200 ease"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${thumb.left}px, ${thumb.top}px)`,
|
||||||
|
width: thumb.width,
|
||||||
|
height: thumb.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</AriaToggleButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentedControlItemProps extends Omit<AriaToggleButtonProps, "children"> {
|
||||||
|
children?: ReactNode;
|
||||||
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControlItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: SegmentedControlItemProps) {
|
||||||
|
return (
|
||||||
|
<AriaToggleButton
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap",
|
||||||
|
"transition-colors duration-200 ease",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||||
|
state.isSelected
|
||||||
|
? "text-body-medium text-text-primary"
|
||||||
|
: "text-body-regular text-text-secondary hover:text-text-primary",
|
||||||
|
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaToggleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
components/base/skeleton/skeleton.tsx
Normal file
95
components/base/skeleton/skeleton.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton — placeholders in the shape of the content that is on its way.
|
||||||
|
*
|
||||||
|
* <Skeleton shape="circle" className="size-10" />
|
||||||
|
* <SkeletonText lines={3} />
|
||||||
|
* <Skeleton isLoaded={user != null}><Avatar … /></Skeleton>
|
||||||
|
*
|
||||||
|
* Blocks are tinted from the text colour at 8%, so they show on any surface
|
||||||
|
* in both themes (a fixed grey disappears on dark cards). A sheen of light
|
||||||
|
* sweeps each one, drawn in layers like Spinner and Progress: a crisp 2px
|
||||||
|
* line leads, a tight glow and a wide bloom trail it. It mirrors in RTL and
|
||||||
|
* gives way to a gentle opacity pulse under reduced motion. The sheen lives
|
||||||
|
* in globals.css (`.bui-skeleton`).
|
||||||
|
*
|
||||||
|
* With children, a Skeleton sizes itself to them: it lays the real content
|
||||||
|
* out invisibly, then, once `isLoaded`, cross-fades the block away while the
|
||||||
|
* content sharpens in from a 4px blur.
|
||||||
|
*
|
||||||
|
* Skeletons are hidden from assistive technology; mark the region that is
|
||||||
|
* loading with `aria-busy`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SkeletonShape = "rect" | "text" | "circle";
|
||||||
|
|
||||||
|
const styles = sortCx({
|
||||||
|
block: "bui-skeleton block bg-text-primary/8",
|
||||||
|
shape: {
|
||||||
|
rect: "rounded-xl",
|
||||||
|
text: "rounded-md",
|
||||||
|
circle: "rounded-full",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const FADE = "transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none";
|
||||||
|
|
||||||
|
export interface SkeletonProps {
|
||||||
|
/** `rect` (radius 12, default), `text` (radius 6), or `circle`. */
|
||||||
|
shape?: SkeletonShape;
|
||||||
|
/** With children: show them once true, blurring them in. */
|
||||||
|
isLoaded?: boolean;
|
||||||
|
/** Size and placement, e.g. `h-4 w-40` or `size-10`. */
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Real content to measure the placeholder from and reveal when loaded. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ shape = "rect", isLoaded = false, className, style, children }: SkeletonProps) {
|
||||||
|
if (children === undefined) {
|
||||||
|
return <span aria-hidden className={cx(styles.block, "relative", styles.shape[shape], className)} style={style} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx("relative block", className)} style={style}>
|
||||||
|
<span
|
||||||
|
aria-hidden={!isLoaded || undefined}
|
||||||
|
inert={!isLoaded}
|
||||||
|
className={cx("block", FADE, isLoaded ? "opacity-100 blur-[0px]" : "opacity-0 motion-safe:blur-[4px]")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx(styles.block, styles.shape[shape], "pointer-events-none absolute inset-0", FADE, isLoaded && "opacity-0")}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkeletonTextProps {
|
||||||
|
/** Number of lines. Default 3; the last one runs 60% wide. */
|
||||||
|
lines?: number;
|
||||||
|
/** Line height, matched to the text it stands in for. Default `h-3.5` (Body 1). */
|
||||||
|
lineClassName?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({ lines = 3, lineClassName = "h-3.5", className }: SkeletonTextProps) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className={cx("flex w-full flex-col gap-2.5", className)}>
|
||||||
|
{Array.from({ length: lines }, (_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
shape="text"
|
||||||
|
className={cx(lineClassName, index === lines - 1 && lines > 1 ? "w-3/5" : "w-full")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
components/base/spinner/spinner.tsx
Normal file
129
components/base/spinner/spinner.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { ProgressBar as AriaProgressBar } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spinner — an orbiting light for work of unknown length.
|
||||||
|
*
|
||||||
|
* <Spinner /> // 20px, accent
|
||||||
|
* <Spinner size="sm" className="text-white" /> // inside a primary button
|
||||||
|
*
|
||||||
|
* The light is drawn the way Composer Loader draws its band: the same arc
|
||||||
|
* at three scales, a crisp line with a rounded head, a tight glow and a wide
|
||||||
|
* bloom, each on its own blur. The soft layers carry shorter arcs and turn a
|
||||||
|
* few degrees behind, so the sharp head always leads and the glow gathers
|
||||||
|
* just behind it like the tail of a comet. A faint track completes the
|
||||||
|
* circle. Everything is drawn in `currentColor`, so a text colour sets it.
|
||||||
|
*
|
||||||
|
* Announced as an indeterminate progress bar labelled "Loading" (override
|
||||||
|
* with `aria-label`). Pass `aria-hidden` where the surrounding control
|
||||||
|
* already says it is busy, e.g. a pending button. Reduced motion slows the
|
||||||
|
* orbit rather than stopping it: the motion is the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
/** Box size and line width in px. */
|
||||||
|
const SIZES: Record<SpinnerSize, { box: number; line: number }> = {
|
||||||
|
xs: { box: 12, line: 1.5 },
|
||||||
|
sm: { box: 16, line: 2 },
|
||||||
|
md: { box: 20, line: 2 },
|
||||||
|
lg: { box: 24, line: 2.5 },
|
||||||
|
xl: { box: 32, line: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One scale of the light: a conic arc masked to a ring, blurred by its wrapper. */
|
||||||
|
function Arc({
|
||||||
|
box,
|
||||||
|
line,
|
||||||
|
width,
|
||||||
|
blur,
|
||||||
|
opacity,
|
||||||
|
arc,
|
||||||
|
lag,
|
||||||
|
}: {
|
||||||
|
box: number;
|
||||||
|
line: number;
|
||||||
|
/** Ring thickness, px. */
|
||||||
|
width: number;
|
||||||
|
blur: number;
|
||||||
|
opacity: number;
|
||||||
|
/** Arc length in degrees; the head sits at 12 o'clock before the turn. */
|
||||||
|
arc: number;
|
||||||
|
/** Degrees this layer trails the crisp head. */
|
||||||
|
lag: number;
|
||||||
|
}) {
|
||||||
|
const center = box / 2 - line / 2;
|
||||||
|
const inner = center - width / 2;
|
||||||
|
const outer = center + width / 2;
|
||||||
|
// The wrapper is grown to hold the ring plus its blur, so nothing clips.
|
||||||
|
const bleed = Math.max(0, outer + blur * 2 - box / 2);
|
||||||
|
const ring = `radial-gradient(circle, transparent ${inner}px, #000 ${inner + 0.5}px, #000 ${outer - 0.5}px, transparent ${outer}px)`;
|
||||||
|
const style: CSSProperties = {
|
||||||
|
inset: -bleed,
|
||||||
|
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
||||||
|
opacity,
|
||||||
|
rotate: `${-lag}deg`,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span aria-hidden className="absolute" style={style}>
|
||||||
|
<span
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(transparent ${360 - arc}deg, currentColor 360deg)`,
|
||||||
|
maskImage: ring,
|
||||||
|
WebkitMaskImage: ring,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpinnerProps {
|
||||||
|
/** 12, 16, 20 (default), 24, or 32px. */
|
||||||
|
size?: SpinnerSize;
|
||||||
|
/** Draw the faint full circle under the light. Default true. */
|
||||||
|
track?: boolean;
|
||||||
|
/** Accessible name. Default "Loading". */
|
||||||
|
"aria-label"?: string;
|
||||||
|
/** Hide it from assistive technology when a parent already announces the wait. */
|
||||||
|
"aria-hidden"?: boolean;
|
||||||
|
/** Colour comes from the text colour, e.g. `text-white`. Default accent. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpinnerGlyph({ size = "md", track = true, className }: Omit<SpinnerProps, "aria-label" | "aria-hidden">) {
|
||||||
|
const { box, line } = SIZES[size];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx("relative inline-flex shrink-0 text-accent-500", className)}
|
||||||
|
style={{ width: box, height: box }}
|
||||||
|
>
|
||||||
|
{track ? (
|
||||||
|
<span className="absolute inset-0 rounded-full border-current opacity-15" style={{ borderWidth: line }} />
|
||||||
|
) : null}
|
||||||
|
<span className="absolute inset-0 animate-spin [animation-duration:900ms] motion-reduce:[animation-duration:2400ms]">
|
||||||
|
<Arc box={box} line={line} width={line * 4} blur={line * 1.6} opacity={0.32} arc={110} lag={22} />
|
||||||
|
<Arc box={box} line={line} width={line * 2} blur={line * 0.6} opacity={0.6} arc={200} lag={9} />
|
||||||
|
<Arc box={box} line={line} width={line} blur={0} opacity={1} arc={290} lag={0} />
|
||||||
|
{/* The crisp line's rounded head, at the arc's leading end. */}
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-0 rounded-full bg-current"
|
||||||
|
style={{ width: line, height: line, translate: "-50% 0" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ size = "md", track = true, "aria-label": ariaLabel = "Loading", "aria-hidden": ariaHidden, className }: SpinnerProps) {
|
||||||
|
if (ariaHidden) return <SpinnerGlyph size={size} track={track} className={className} />;
|
||||||
|
return (
|
||||||
|
<AriaProgressBar isIndeterminate aria-label={ariaLabel} className="inline-flex shrink-0">
|
||||||
|
<SpinnerGlyph size={size} track={track} className={className} />
|
||||||
|
</AriaProgressBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
components/base/switch/switch.tsx
Normal file
172
components/base/switch/switch.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import { Switch as AriaSwitch } from "react-aria-components";
|
||||||
|
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma source: Board UI → "Toogles" (node 3856:3807).
|
||||||
|
*
|
||||||
|
* Two shapes × three sizes, plus off / on:
|
||||||
|
* shape pill (Figma "fully rounded") — track & thumb rounded-full
|
||||||
|
* rectangle (Figma "rectangle") — track radius/md-scale, thumb radius/xs-scale
|
||||||
|
* size sm 28×16 · md 42×24 · lg 56×32 (track w×h; thumb = h − 2·pad)
|
||||||
|
*
|
||||||
|
* Anatomy (all values 1:1 with Figma):
|
||||||
|
* track off → bg background/tertiary/default (#ebebeb, no border)
|
||||||
|
* on → Button/Primary gradient (blue/500 → blue/600) + an inset
|
||||||
|
* ring + top highlight that scales with size. lg is exactly
|
||||||
|
* the shared `--shadow-checkbox-selected` token; md/sm scale
|
||||||
|
* the ring (0.75px / 0.5px) and highlight (1.5px / 1px).
|
||||||
|
* thumb theme-stable white gradient + contact shadow,
|
||||||
|
* slides by (w − thumb − 2·pad) between states.
|
||||||
|
* chip small embossed inset in the thumb centre:
|
||||||
|
* off → white→neutral-100 in light mode; in dark mode it matches
|
||||||
|
* the track so the mark reads like a hole through the thumb;
|
||||||
|
* on → switch-on-chip-start→end (accent mixes matching Figma's
|
||||||
|
* raw #2473fe→#0450e2 for blue), accent/600 border.
|
||||||
|
*
|
||||||
|
* The track visual lives in `SwitchTrack` so `Switch` and `SwitchCard` share it
|
||||||
|
* (mirrors how Checkbox / CheckboxCard share `CheckboxGlyph`). Built on
|
||||||
|
* react-aria Switch: native toggle semantics, keyboard support, focus-visible
|
||||||
|
* ring, optional label via `children`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SwitchSize = "sm" | "md" | "lg";
|
||||||
|
export type SwitchShape = "pill" | "rectangle";
|
||||||
|
|
||||||
|
export const switchSizes = sortCx({
|
||||||
|
sm: {
|
||||||
|
track: "h-4 w-7",
|
||||||
|
trackRadius: { pill: "rounded-full", rectangle: "rounded-[3px]" },
|
||||||
|
onShadow: "shadow-[inset_0_1px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.5px_var(--color-accent-500)]",
|
||||||
|
thumb: "size-3",
|
||||||
|
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
|
||||||
|
offset: "start-0.5 top-0.5",
|
||||||
|
travel: "translate-x-3 rtl:-translate-x-3",
|
||||||
|
chip: "size-[5px] border-[0.25px] shadow-[0_2px_2px_0_rgb(0_0_0/0.03)]",
|
||||||
|
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.5px]" },
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
track: "h-6 w-[42px]",
|
||||||
|
trackRadius: { pill: "rounded-full", rectangle: "rounded-[4.5px]" },
|
||||||
|
onShadow: "shadow-[inset_0_1.5px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.75px_var(--color-accent-500)]",
|
||||||
|
thumb: "size-[18px]",
|
||||||
|
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1.5px]" },
|
||||||
|
offset: "start-[3px] top-[3px]",
|
||||||
|
travel: "translate-x-[18px] rtl:-translate-x-[18px]",
|
||||||
|
chip: "size-[7.5px] border-[0.375px] shadow-[0_3px_3px_0_rgb(0_0_0/0.03)]",
|
||||||
|
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.75px]" },
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
track: "h-8 w-14",
|
||||||
|
trackRadius: { pill: "rounded-full", rectangle: "rounded-md" },
|
||||||
|
onShadow: "shadow-checkbox-selected",
|
||||||
|
thumb: "size-6",
|
||||||
|
thumbRadius: { pill: "rounded-full", rectangle: "rounded-xs" },
|
||||||
|
offset: "start-1 top-1",
|
||||||
|
travel: "translate-x-6 rtl:-translate-x-6",
|
||||||
|
chip: "size-[10px] border-[0.5px] shadow-[0_4px_4px_0_rgb(0_0_0/0.03)]",
|
||||||
|
chipRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SwitchVisualState {
|
||||||
|
isSelected: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
isFocusVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The track + thumb + chip visual. Pure presentation — the react-aria Switch
|
||||||
|
* (in `Switch` or `SwitchCard`) owns the input and passes its render state in.
|
||||||
|
*/
|
||||||
|
export function SwitchTrack({
|
||||||
|
state,
|
||||||
|
size = "md",
|
||||||
|
shape = "pill",
|
||||||
|
}: {
|
||||||
|
state: SwitchVisualState;
|
||||||
|
size?: SwitchSize;
|
||||||
|
shape?: SwitchShape;
|
||||||
|
}) {
|
||||||
|
const s = switchSizes[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx(
|
||||||
|
"relative shrink-0 transition-colors duration-200 ease",
|
||||||
|
s.track,
|
||||||
|
s.trackRadius[shape],
|
||||||
|
state.isSelected
|
||||||
|
? cx("bg-linear-to-b from-accent-500 to-accent-600", s.onShadow)
|
||||||
|
: "bg-background-tertiary-default",
|
||||||
|
state.isDisabled && "opacity-50",
|
||||||
|
state.isFocusVisible && "ring-2 ring-border-focus-ring ring-offset-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Thumb */}
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"absolute flex items-center justify-center",
|
||||||
|
"bg-linear-to-b from-control-indicator-background from-[43.837%] to-control-indicator-background-subtle",
|
||||||
|
"shadow-[0_3px_3px_0_rgb(0_0_0/0.03),0_0.75px_0_0_rgb(0_0_0/0.05)]",
|
||||||
|
"transition-transform duration-200 ease",
|
||||||
|
s.thumb,
|
||||||
|
s.thumbRadius[shape],
|
||||||
|
s.offset,
|
||||||
|
state.isSelected && s.travel,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Embossed inner chip. Gradient runs bottom→top (`to-t`) — equivalent
|
||||||
|
to the Figma layer's vertical flip — but WITHOUT a transform, so the
|
||||||
|
chip stays pixel-snapped/centred (no blurry 1px shift at sub-pixel
|
||||||
|
sizes) and its drop shadow points down instead of up. */}
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"border-solid bg-linear-to-t from-[43.837%]",
|
||||||
|
s.chip,
|
||||||
|
s.chipRadius[shape],
|
||||||
|
state.isSelected
|
||||||
|
? "border-accent-600 from-switch-on-chip-start to-switch-on-chip-end"
|
||||||
|
: "border-border-button-default/50 from-switch-off-chip-start to-switch-off-chip-end",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchProps extends Omit<AriaSwitchProps, "children"> {
|
||||||
|
children?: ReactNode;
|
||||||
|
size?: SwitchSize;
|
||||||
|
shape?: SwitchShape;
|
||||||
|
ref?: Ref<HTMLLabelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Switch({ className, children, size = "md", shape = "pill", ref, ...props }: SwitchProps) {
|
||||||
|
return (
|
||||||
|
<AriaSwitch
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"group inline-flex items-center gap-2 select-none",
|
||||||
|
state.isDisabled ? "cursor-not-allowed" : "cursor-pointer",
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
<SwitchTrack state={state} size={size} shape={shape} />
|
||||||
|
{children != null && children !== false && (
|
||||||
|
<span className="text-body-medium text-text-primary">{children}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaSwitch>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/foundations/direction/direction.tsx
Normal file
35
components/foundations/direction/direction.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { I18nProvider, useLocale } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export type Direction = "ltr" | "rtl";
|
||||||
|
|
||||||
|
export interface DirectionProviderProps extends Omit<HTMLAttributes<HTMLDivElement>, "dir" | "lang" | "children"> {
|
||||||
|
/** BCP 47 language tag. Arabic and Hebrew resolve to RTL; Chinese to LTR. */
|
||||||
|
locale: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps CSS direction, React Aria keyboard behavior and portalled menus in sync.
|
||||||
|
* Use at the app root, or nest providers for regions in another language.
|
||||||
|
* The default display: contents boundary preserves the surrounding layout.
|
||||||
|
*/
|
||||||
|
export function DirectionProvider({ locale, ...props }: DirectionProviderProps) {
|
||||||
|
return (
|
||||||
|
<I18nProvider locale={locale}>
|
||||||
|
<DirectionBoundary {...props} />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectionBoundary({ children, className, ...props }: Omit<DirectionProviderProps, "locale">) {
|
||||||
|
const { locale, direction } = useLocale();
|
||||||
|
return <div {...props} lang={locale} dir={direction} className={cx("contents", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Also works inside a React Aria I18nProvider. */
|
||||||
|
export function useDirection(): Direction {
|
||||||
|
return useLocale().direction;
|
||||||
|
}
|
||||||
72
components/foundations/template-copy/template-copy.tsx
Normal file
72
components/foundations/template-copy/template-copy.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cloneElement, createContext, isValidElement, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export type TemplateCopyTranslator = (text: string) => string;
|
||||||
|
type CopySettings = { translate: TemplateCopyTranslator; render?: <T>(node: T) => T };
|
||||||
|
const CopyContext = createContext<CopySettings | null>(null);
|
||||||
|
const MenuContext = createContext<ReactNode>(null);
|
||||||
|
|
||||||
|
/** Optional application language control shared by template account menus. */
|
||||||
|
export function TemplateMenuProvider({ languageSelector, children }: { languageSelector?: ReactNode; children: ReactNode }) {
|
||||||
|
return <MenuContext.Provider value={languageSelector ?? null}>{children}</MenuContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTemplateLanguageSelector() {
|
||||||
|
return useContext(MenuContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional application copy for templates. Without a provider, source copy is unchanged. */
|
||||||
|
export function TemplateCopyProvider({ translate, render, children }: CopySettings & { children: ReactNode }) {
|
||||||
|
const settings = useMemo(() => ({ translate, render }), [translate, render]);
|
||||||
|
return <CopyContext.Provider value={settings}>{children}</CopyContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only display copy is translated. IDs, form values, data keys, URLs, styles,
|
||||||
|
// event handlers and application state retain their original values.
|
||||||
|
const textProps = new Set([
|
||||||
|
"children", "title", "subtitle", "description", "label", "textValue", "placeholder", "alt",
|
||||||
|
"aria-label", "aria-description", "metricLabel", "emptyMessage", "caption", "heading", "text", "content", "renderValue",
|
||||||
|
]);
|
||||||
|
const dataProps = new Set(["items", "options", "tabs", "columns", "series", "ranges", "stats", "data", "nodes", "bar", "line"]);
|
||||||
|
const dataText = new Set(["label", "title", "description", "subtitle", "header", "role", "joined"]);
|
||||||
|
const renderProps = new Set(["children", "cell", "header", "formatter", "tickFormatter", "labelFormatter", "renderItem", "node", "link", "tick", "label", "content", "renderValue"]);
|
||||||
|
|
||||||
|
/** Translate rendered copy while preserving React nodes, collection IDs and callbacks. */
|
||||||
|
export function translateTemplateCopy<T>(value: T, translate: TemplateCopyTranslator): T {
|
||||||
|
function visit(value: unknown, data = false): unknown {
|
||||||
|
if (typeof value === "string") return data ? value : translate(value);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const result = value.map(item => visit(item, data));
|
||||||
|
return result.every((item, index) => item === value[index]) ? value : result;
|
||||||
|
}
|
||||||
|
if (isValidElement<Record<string, unknown>>(value)) {
|
||||||
|
if (typeof value.type === "string" && ["pre", "code", "kbd", "script", "style"].includes(value.type)) return value;
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
for (const [key, original] of Object.entries(value.props)) {
|
||||||
|
if (textProps.has(key)) updates[key] = renderProps.has(key) && typeof original === "function"
|
||||||
|
? (...args: unknown[]) => visit(original(...args)) : visit(original);
|
||||||
|
else if (dataProps.has(key)) updates[key] = visit(original, true);
|
||||||
|
else if (renderProps.has(key) && typeof original === "function") updates[key] = (...args: unknown[]) => visit(original(...args));
|
||||||
|
}
|
||||||
|
return Object.entries(updates).some(([key, result]) => result !== value.props[key]) ? cloneElement(value, updates) : value;
|
||||||
|
}
|
||||||
|
if (data && value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
||||||
|
const result: Record<string, unknown> = { ...value };
|
||||||
|
for (const [key, original] of Object.entries(result)) {
|
||||||
|
if (renderProps.has(key) && typeof original === "function") result[key] = (...args: unknown[]) => visit(original(...args));
|
||||||
|
else if (dataText.has(key)) result[key] = visit(original);
|
||||||
|
else if (original && typeof original === "object") result[key] = visit(original, true);
|
||||||
|
}
|
||||||
|
return Object.entries(result).some(([key, item]) => item !== (value as Record<string, unknown>)[key]) ? result : value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return visit(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply to a template component's returned JSX; copy updates through React, including portals. */
|
||||||
|
export function useTemplateCopy() {
|
||||||
|
const settings = useContext(CopyContext);
|
||||||
|
return useMemo(() => <T,>(node: T): T => settings?.render ? settings.render(node) : settings ? translateTemplateCopy(node, settings.translate) : node, [settings]);
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,13 @@
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.66.0",
|
"@remixicon/react": "^4.9.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0"
|
"react-aria-components": "^1.22.1",
|
||||||
|
"react-dom": "19.3.0",
|
||||||
|
"tailwind-merge": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/turbopack": "^4",
|
"@tailwindcss/turbopack": "^4",
|
||||||
|
|
|
||||||
1025
styles/globals.css
Normal file
1025
styles/globals.css
Normal file
File diff suppressed because it is too large
Load diff
955
styles/theme.css
Normal file
955
styles/theme.css
Normal file
|
|
@ -0,0 +1,955 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* BoardUI theme tokens
|
||||||
|
*
|
||||||
|
* Layered as Tailwind v4 expects:
|
||||||
|
* @theme { ... } primitives + ramp overrides → Tailwind utilities
|
||||||
|
* :root { ... } semantic tokens (light mode) — reference primitives
|
||||||
|
* .dark { ... } semantic overrides (dark mode) — landed when designed
|
||||||
|
* @theme inline {..} re-export of semantic tokens so they become utilities
|
||||||
|
*
|
||||||
|
* See docs/FIGMA_WORKFLOW.md for how tokens get added (Figma is source of truth).
|
||||||
|
* --------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ----- Color primitives ----------------------------------------------------
|
||||||
|
* Almost the entire palette matches Tailwind v4's OKLCH defaults. The four
|
||||||
|
* overrides below are the only intentional deviations (see Figma → variables
|
||||||
|
* → color/...).
|
||||||
|
* --------------------------------------------------------------------------*/
|
||||||
|
--color-slate-200: #ebeff5;
|
||||||
|
--color-neutral-100: #f7f7f7;
|
||||||
|
--color-neutral-200: #ebebeb;
|
||||||
|
--color-neutral-925: #121212;
|
||||||
|
--color-blue-400: #3392ff;
|
||||||
|
|
||||||
|
/* ----- Accent ramp ---------------------------------------------------------
|
||||||
|
* The CTA hue. Interactive/selection surfaces (primary button gradient,
|
||||||
|
* ghost + link buttons, checkbox, radio, switch, slider, tabs, sidebar
|
||||||
|
* selected, focus ring, date-range selection) reference accent-* instead of
|
||||||
|
* a raw hue, so the whole system re-tints by overriding these eleven
|
||||||
|
* variables at runtime (see components/application/theme/accent.ts) or in
|
||||||
|
* a customer project after importing this file. Defaults to the blue
|
||||||
|
* primitives above. Content/data blues (charts, status chips, calendar
|
||||||
|
* events, info notifications) deliberately keep referencing blue-*.
|
||||||
|
* --------------------------------------------------------------------------*/
|
||||||
|
--color-accent-50: var(--color-blue-50);
|
||||||
|
--color-accent-100: var(--color-blue-100);
|
||||||
|
--color-accent-200: var(--color-blue-200);
|
||||||
|
--color-accent-300: var(--color-blue-300);
|
||||||
|
--color-accent-400: var(--color-blue-400);
|
||||||
|
--color-accent-500: var(--color-blue-500);
|
||||||
|
--color-accent-600: var(--color-blue-600);
|
||||||
|
--color-accent-700: var(--color-blue-700);
|
||||||
|
--color-accent-800: var(--color-blue-800);
|
||||||
|
--color-accent-900: var(--color-blue-900);
|
||||||
|
--color-accent-950: var(--color-blue-950);
|
||||||
|
|
||||||
|
/* ----- Typography ----------------------------------------------------------
|
||||||
|
* Font sizes, weights, line-heights, and tracking all match Tailwind v4
|
||||||
|
* defaults. The only override is the family: Inter, loaded via next/font
|
||||||
|
* in app/layout.tsx, which exposes --font-inter to the document.
|
||||||
|
* --------------------------------------------------------------------------*/
|
||||||
|
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
|
"Noto Color Emoji";
|
||||||
|
|
||||||
|
/* Monospace ramp for code blocks. Loaded via next/font in app/layout.tsx
|
||||||
|
* (--font-mono-source → JetBrains Mono). Figma's spec uses IBM Plex Mono;
|
||||||
|
* JetBrains Mono is the closest already-bundled face. */
|
||||||
|
--font-mono: var(--font-mono-source), ui-monospace, "SFMono-Regular", Menlo,
|
||||||
|
Consolas, "Liberation Mono", monospace;
|
||||||
|
|
||||||
|
/* ----- Radii ---------------------------------------------------------------
|
||||||
|
* Extra step between lg (8) and xl (12). Used by medium-size buttons.
|
||||||
|
* Figma name: radius/2lg.
|
||||||
|
* --------------------------------------------------------------------------*/
|
||||||
|
--radius-2lg: 10px;
|
||||||
|
/* Figma `radius/2,5xl`: kanban columns and other large inset panels. */
|
||||||
|
--radius-2-5xl: 20px;
|
||||||
|
--radius-notification-card: 10px;
|
||||||
|
|
||||||
|
/* ----- Shadows -------------------------------------------------------------
|
||||||
|
* 2xs, xs, 2xl and the inner shadows already match Tailwind v4. Sizes sm, md,
|
||||||
|
* lg, xl follow the Figma 6%/10% recipe (slightly softer than Tailwind v4's
|
||||||
|
* 10%/10% default).
|
||||||
|
* --------------------------------------------------------------------------*/
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.06), 0 1px 3px 0 rgb(0 0 0 / 0.10);
|
||||||
|
--shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.06), 0 4px 6px -1px rgb(0 0 0 / 0.10);
|
||||||
|
--shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.05), 0 10px 15px -3px rgb(0 0 0 / 0.10);
|
||||||
|
--shadow-xl: 0 10px 10px -5px rgb(0 0 0 / 0.04), 0 20px 25px -5px rgb(0 0 0 / 0.10);
|
||||||
|
|
||||||
|
/* Figma effect style "Background/Sidebar Elevation" — copied 1:1:
|
||||||
|
* DROP_SHADOW #00000005 offset(0,1) radius 0 spread 0 → 0 1px 0px 0 / 0.0196
|
||||||
|
* DROP_SHADOW #0000000F offset(0,1) radius 12 spread 0 → 0 1px 12px 0 / 0.0588
|
||||||
|
* DROP_SHADOW #00000052 offset(0,0) radius 1 spread 0 → 0 0 1px 0 / 0.3216
|
||||||
|
* (alpha = hex byte / 255: 05→0.0196, 0F→0.0588, 52→0.3216). */
|
||||||
|
--shadow-sidebar:
|
||||||
|
0 1px 0 0 rgb(0 0 0 / 0.0196),
|
||||||
|
0 1px 12px 0 rgb(0 0 0 / 0.0588),
|
||||||
|
0 0 1px 0 rgb(0 0 0 / 0.3216);
|
||||||
|
|
||||||
|
/* Figma effect style "Sidebar selected" — copied 1:1:
|
||||||
|
* DROP_SHADOW color/blue/500 offset(0,0) radius 0 spread 1 → 0 0 0 1px blue-500
|
||||||
|
* INNER_SHADOW #FFFFFF40 offset(0,1) radius 0 spread 0 → inset 0 1px 0 0 / 0.251
|
||||||
|
* (#FFFFFF40 alpha = 0x40/255 = 0.251). */
|
||||||
|
--shadow-nav-selected:
|
||||||
|
0 0 0 1px var(--color-accent-500),
|
||||||
|
inset 0 1px 0 0 rgb(255 255 255 / 0.251);
|
||||||
|
|
||||||
|
/* Checkbox checked/indeterminate surface (from the Figma Checkbox component):
|
||||||
|
* inner top highlight + 1px inner blue edge over the primary gradient. */
|
||||||
|
--shadow-checkbox-selected:
|
||||||
|
inset 0 2px 0 0 rgb(255 255 255 / 0.25),
|
||||||
|
inset 0 0 0 1px var(--color-accent-500);
|
||||||
|
|
||||||
|
/* 1px contact shadow used on white cards/dropdown triggers sitting on the
|
||||||
|
* secondary background (Figma drop shadow 0 1 1 5%). */
|
||||||
|
--shadow-card: 0 1px 1px 0 rgb(0 0 0 / 0.05);
|
||||||
|
|
||||||
|
/* Floating menu/dropdown panel (Figma → sidebar profile menu node 3823:3699):
|
||||||
|
* DROP_SHADOW #0000000A offset(0,1) radius 1 → 0 1px 1px 0 / 0.04
|
||||||
|
* DROP_SHADOW #00000005 offset(0,4) radius 4 → 0 4px 4px 0 / 0.02 */
|
||||||
|
--shadow-dropdown:
|
||||||
|
0 1px 1px 0 rgb(0 0 0 / 0.04),
|
||||||
|
0 4px 4px 0 rgb(0 0 0 / 0.02);
|
||||||
|
|
||||||
|
/* Floating template waitlist (Figma node 4117:32737). This extends the
|
||||||
|
* sidebar elevation recipe with the two broad ambient layers used by the
|
||||||
|
* signup surface. */
|
||||||
|
--shadow-waitlist:
|
||||||
|
0 0 6px 0 rgb(0 0 0 / 0.06),
|
||||||
|
0 0 64px 0 rgb(0 0 0 / 0.16),
|
||||||
|
0 0 1px 0 rgb(0 0 0 / 0.32),
|
||||||
|
0 1px 12px 0 rgb(0 0 0 / 0.06),
|
||||||
|
0 1px 0 0 rgb(0 0 0 / 0.02);
|
||||||
|
|
||||||
|
/* Inner shadows (Figma names inner-shadow/*; Tailwind utility prefix is
|
||||||
|
* inset-shadow-*).
|
||||||
|
*/
|
||||||
|
--inset-shadow-2xs: inset 0 1px 0 0 rgb(0 0 0 / 0.05);
|
||||||
|
--inset-shadow-xs: inset 0 1px 1px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--inset-shadow-sm: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Semantic tokens — light mode.
|
||||||
|
*
|
||||||
|
* All values reference primitives via var(--color-{family}-{shade}). Edit the
|
||||||
|
* primitives in @theme above and these update automatically.
|
||||||
|
*
|
||||||
|
* Figma name on the left, var name on the right:
|
||||||
|
* foreground/full → --color-foreground-full
|
||||||
|
* foreground/icon/primary → --color-foreground-icon-primary
|
||||||
|
* foreground/disabled → --color-foreground-disabled
|
||||||
|
* foreground/disabled-danger → --color-foreground-disabled-danger
|
||||||
|
* text/primary → --color-text-primary
|
||||||
|
* text/tertiary → --color-text-tertiary
|
||||||
|
* text/disabled-danger → --color-text-disabled-danger
|
||||||
|
* background/primary/default → --color-background-primary-default
|
||||||
|
* background/primary/hover → --color-background-primary-hover
|
||||||
|
* background/primary/active → --color-background-primary-active
|
||||||
|
* background/primary/disabled → --color-background-primary-disabled
|
||||||
|
* background/secondary/default → --color-background-secondary-default
|
||||||
|
* border/button/default → --color-border-button-default
|
||||||
|
* border/button/hover → --color-border-button-hover
|
||||||
|
* border/button/active → --color-border-button-active
|
||||||
|
*
|
||||||
|
* Button gradients (linear, 180°, light stop → dark stop) sourced from the
|
||||||
|
* blue/red primitives that each Button/{type}/{state} style references in
|
||||||
|
* Figma. Direction inferred — confirm visually against Figma.
|
||||||
|
* --------------------------------------------------------------------------- */
|
||||||
|
:root {
|
||||||
|
/* Foreground (icons + on-fill content) */
|
||||||
|
--color-foreground-full: var(--color-white);
|
||||||
|
--color-foreground-icon-primary: var(--color-neutral-950);
|
||||||
|
--color-foreground-icon-hover: var(--color-black);
|
||||||
|
--color-foreground-icon-secondary: var(--color-neutral-500);
|
||||||
|
--color-foreground-icon-tertiary: var(--color-neutral-400);
|
||||||
|
--color-foreground-icon-quaternary: var(--color-neutral-300);
|
||||||
|
--color-foreground-icon-error: var(--color-red-600);
|
||||||
|
--color-foreground-icon-disabled: var(--color-neutral-300);
|
||||||
|
--color-foreground-disabled: var(--color-neutral-400);
|
||||||
|
--color-foreground-disabled-danger: var(--color-red-300);
|
||||||
|
--color-input-disabled-foreground: var(--color-neutral-300);
|
||||||
|
--color-icon-button-disabled-foreground: var(--color-neutral-300);
|
||||||
|
--color-button-primary-disabled-foreground: var(--color-neutral-400);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: var(--color-neutral-950);
|
||||||
|
--color-text-secondary: var(--color-neutral-500);
|
||||||
|
--color-text-tertiary: var(--color-neutral-400);
|
||||||
|
--color-text-disabled: var(--color-neutral-300);
|
||||||
|
--color-text-disabled-danger: var(--color-red-300);
|
||||||
|
--color-text-placeholder: var(--color-neutral-400);
|
||||||
|
--color-text-error-primary: var(--color-red-500);
|
||||||
|
--color-text-destructive: color-mix(in srgb, var(--color-red-700) 80%, var(--color-neutral-600));
|
||||||
|
--color-input-disabled-text: var(--color-neutral-300);
|
||||||
|
--color-text-white: var(--color-white);
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
--color-background-full: var(--color-white);
|
||||||
|
/* Ticket detail, Figma 4509:12272. The raw #fafafa surface matches neutral/50. */
|
||||||
|
--color-ticket-detail-surface: var(--color-white);
|
||||||
|
--color-ticket-detail-low-background: var(--color-indigo-200);
|
||||||
|
--color-ticket-detail-low-hover: var(--color-indigo-300);
|
||||||
|
--color-ticket-detail-medium-hover: var(--color-yellow-300);
|
||||||
|
--color-ticket-detail-high-hover: var(--color-orange-300);
|
||||||
|
--color-ticket-detail-urgent-hover: var(--color-rose-300);
|
||||||
|
--color-ticket-detail-low-text: var(--color-indigo-800);
|
||||||
|
--color-ticket-detail-resource-background: var(--color-blue-100);
|
||||||
|
--color-ticket-detail-resource-text: var(--color-blue-600);
|
||||||
|
--color-ticket-detail-favorite: #ffb83c; /* raw Figma fill, no token yet */
|
||||||
|
/* Empty-state illustration: raw Figma fills from node 4488:11848. */
|
||||||
|
--color-project-board-empty-panel: rgb(242 242 242 / 0.8);
|
||||||
|
--color-project-board-empty-front: #d9d9d9;
|
||||||
|
--color-project-board-empty-middle: #e5e5e5;
|
||||||
|
--color-project-board-empty-back: #ededed;
|
||||||
|
--color-project-board-empty-detail: var(--color-white);
|
||||||
|
--color-background-primary-default: var(--color-white);
|
||||||
|
--color-background-inner-default: var(--color-white);
|
||||||
|
--color-background-primary-hover: var(--color-neutral-100);
|
||||||
|
--color-background-primary-active: var(--color-neutral-200);
|
||||||
|
--color-background-primary-disabled: var(--color-neutral-100);
|
||||||
|
--color-input-disabled-background: var(--color-neutral-100);
|
||||||
|
--color-background-secondary-default: var(--color-neutral-100);
|
||||||
|
--color-background-secondary-hover: var(--color-neutral-200);
|
||||||
|
--color-background-tertiary-default: var(--color-neutral-200);
|
||||||
|
--color-background-tertiary-hover: var(--color-neutral-300);
|
||||||
|
--color-background-tertiary-error: var(--color-red-100);
|
||||||
|
--color-background-recent-hire-role: var(--color-neutral-100);
|
||||||
|
--color-avatar-neutral-background: var(--color-neutral-300);
|
||||||
|
/* Figma background/quaternary/default — first used by the Storage
|
||||||
|
* dropzone's upload-icon disc (node 4106:26587). */
|
||||||
|
--color-background-quaternary-default: var(--color-neutral-300);
|
||||||
|
--color-background-quaternary-hover: var(--color-neutral-400);
|
||||||
|
--color-background-quaternary-error: var(--color-red-200);
|
||||||
|
--color-file-upload-icon-background: var(--color-neutral-300);
|
||||||
|
--color-file-upload-icon-foreground: var(--color-neutral-400);
|
||||||
|
--color-file-upload-icon-foreground-hover: var(--color-neutral-500);
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
--color-border-button-default: var(--color-neutral-200);
|
||||||
|
--color-border-button-hover: var(--color-neutral-300);
|
||||||
|
--color-border-button-active: var(--color-neutral-400);
|
||||||
|
--color-border-button-white: var(--color-white);
|
||||||
|
/* The system had no red border. Matches text/error/primary so an invalid
|
||||||
|
field's edge and its message are the same red. */
|
||||||
|
--color-border-error-default: var(--color-red-500);
|
||||||
|
--color-border-checkbox-default: var(--color-neutral-300);
|
||||||
|
--color-border-checkbox-hover: var(--color-neutral-400);
|
||||||
|
--color-border-checkbox-active: var(--color-neutral-400);
|
||||||
|
--color-border-checkbox-white: var(--color-white);
|
||||||
|
--color-border-component-detail-container: var(--color-neutral-200);
|
||||||
|
--color-border-ai-profile-card: var(--color-neutral-200);
|
||||||
|
--color-border-table: var(--color-neutral-200);
|
||||||
|
--color-border-button-group: var(--color-neutral-200);
|
||||||
|
--color-border-sidebar-profile-hover: var(--color-neutral-300);
|
||||||
|
--color-separator-border: var(--color-neutral-200);
|
||||||
|
--color-separator-border-strong: var(--color-neutral-200);
|
||||||
|
--color-border-focus-ring: var(--color-accent-500);
|
||||||
|
|
||||||
|
/* State */
|
||||||
|
--color-state-success-text: var(--color-lime-800);
|
||||||
|
--color-state-success-base: var(--color-lime-200);
|
||||||
|
|
||||||
|
/* Selected control interiors remain light on colored tracks in both themes. */
|
||||||
|
--color-control-indicator-background: var(--color-white);
|
||||||
|
--color-control-indicator-background-subtle: var(--color-neutral-100);
|
||||||
|
--color-switch-off-chip-start: var(--color-white);
|
||||||
|
--color-switch-off-chip-end: var(--color-neutral-100);
|
||||||
|
/* Selected switch chip: the Figma comps use hexes a touch deeper than the
|
||||||
|
* plain 500→600 ramp (#2473fe → #0450e2 for blue). Expressed as oklab mixes
|
||||||
|
* of neighbouring accent stops so the recipe survives a re-tint. */
|
||||||
|
--color-switch-on-chip-start: color-mix(in oklab, var(--color-accent-500) 63%, var(--color-accent-600));
|
||||||
|
--color-switch-on-chip-end: color-mix(in oklab, var(--color-accent-600) 16%, var(--color-accent-700));
|
||||||
|
--color-stat-card-icon-background: var(--color-white);
|
||||||
|
--color-segmented-control-background: var(--color-neutral-100);
|
||||||
|
--color-segmented-control-selected-background: var(--color-white);
|
||||||
|
--color-theme-toggle-sidebar-background: var(--color-neutral-200);
|
||||||
|
--color-theme-toggle-sidebar-selected-background: var(--color-white);
|
||||||
|
/* Stepped against the composer: the pill is white and the add button is the
|
||||||
|
* subtle grey sitting on it, so the control separates from the surface it
|
||||||
|
* shares an edge with. Dark mode keeps its own pair, where the pill is the
|
||||||
|
* lighter of the two and the button steps the other way. */
|
||||||
|
--color-ai-chat-composer-add-background: var(--color-neutral-100);
|
||||||
|
--color-ai-chat-composer-add-hover-background: var(--color-neutral-200);
|
||||||
|
/* Composer Panel (Figma node 4430:12203): the status tab hanging off the
|
||||||
|
* card's top edge and the card's plus button are both neutral-200 in light
|
||||||
|
* mode. Dark mode steps them up instead, so they still separate from the
|
||||||
|
* neutral-800 card they touch. */
|
||||||
|
--color-composer-panel-tab-background: var(--color-neutral-200);
|
||||||
|
--color-composer-panel-add-background: var(--color-neutral-200);
|
||||||
|
--color-composer-panel-add-hover-background: var(--color-neutral-300);
|
||||||
|
/* Attachment tile edge: the Figma neutral-200 in light mode; dark mode
|
||||||
|
* uses a translucent white so the ring reads on the neutral-800 card
|
||||||
|
* (border/button/default is one neutral step away there and vanishes). */
|
||||||
|
--color-composer-panel-tile-border: var(--color-neutral-200);
|
||||||
|
/* Model picker provider rail: background/secondary in light mode, at
|
||||||
|
* half opacity in dark so it recedes on the neutral-800 panel. */
|
||||||
|
--color-composer-panel-rail-background: var(--color-neutral-100);
|
||||||
|
/* Questionnaire (Figma nodes 4435:14749 / 4435:15042): the answer rows share
|
||||||
|
* the card's background/primary surface in both modes, separated by their
|
||||||
|
* border alone, and the "Other" row uses the stock Input untouched. The
|
||||||
|
* single-select number key is background/secondary in light mode and one
|
||||||
|
* tone lighter than the row in dark mode (never darker), stepping again
|
||||||
|
* while its row is hovered so it stays legible on the lifted fill. */
|
||||||
|
--color-questionnaire-key-background: var(--color-neutral-100);
|
||||||
|
--color-questionnaire-key-hover-background: var(--color-neutral-200);
|
||||||
|
--color-agent-progress-ring: var(--color-neutral-700);
|
||||||
|
--color-button-ghost-background: var(--color-accent-100);
|
||||||
|
--color-button-ghost-hover: var(--color-accent-200);
|
||||||
|
--color-button-ghost-active: var(--color-accent-300);
|
||||||
|
--color-button-ghost-disabled: var(--color-accent-50);
|
||||||
|
--color-button-ghost-foreground: var(--color-accent-700);
|
||||||
|
--color-button-ghost-disabled-foreground: var(--color-accent-300);
|
||||||
|
--color-dropdown-item-hover-background: var(--color-neutral-100);
|
||||||
|
--color-kbd-background: var(--color-neutral-300);
|
||||||
|
--color-kbd-foreground: var(--color-neutral-500);
|
||||||
|
--color-docs-file-chip-background: var(--color-purple-50);
|
||||||
|
--color-docs-file-chip-border: var(--color-purple-100);
|
||||||
|
--color-docs-file-chip-foreground: var(--color-purple-500);
|
||||||
|
--color-docs-command-accent: var(--color-purple-400);
|
||||||
|
--color-docs-code-background: var(--color-background-primary-default);
|
||||||
|
--color-docs-install-rail: var(--color-border-button-default);
|
||||||
|
|
||||||
|
/* Recently shipped component badge. Kept semantic so its compact blue
|
||||||
|
* surface can retain the same hierarchy in both themes. */
|
||||||
|
--color-badge-neutral-background: var(--color-neutral-200);
|
||||||
|
--color-team-menu-count-background: var(--color-neutral-200);
|
||||||
|
--color-team-menu-count-foreground: var(--color-neutral-500);
|
||||||
|
--color-badge-new-background: var(--color-accent-100);
|
||||||
|
--color-badge-new-text: var(--color-accent-600);
|
||||||
|
--color-tab-count-selected-background: var(--color-accent-100);
|
||||||
|
--color-pill-tab-blue-selected-background: var(--color-accent-50);
|
||||||
|
--color-pill-tab-blue-hover-background: var(--color-neutral-100);
|
||||||
|
|
||||||
|
/* Notification status-icon surfaces. */
|
||||||
|
--color-notification-information-background: var(--color-blue-100);
|
||||||
|
--color-notification-information-foreground: var(--color-blue-600);
|
||||||
|
--color-notification-success-background: var(--color-lime-100);
|
||||||
|
--color-notification-success-foreground: var(--color-lime-600);
|
||||||
|
--color-notification-error-background: var(--color-rose-100);
|
||||||
|
--color-notification-error-foreground: var(--color-rose-600);
|
||||||
|
--color-notification-center-background: var(--color-white);
|
||||||
|
|
||||||
|
/* Table status chips. Dark mappings come from the medical dashboard table
|
||||||
|
* in Figma (4139:7202, 4139:7240, 4139:7278, 4139:7315). */
|
||||||
|
--color-status-lime-background: var(--color-lime-200);
|
||||||
|
--color-status-lime-text: var(--color-lime-800);
|
||||||
|
--color-status-yellow-background: var(--color-yellow-200);
|
||||||
|
--color-status-yellow-text: var(--color-yellow-800);
|
||||||
|
--color-status-rose-background: var(--color-rose-200);
|
||||||
|
--color-status-rose-text: var(--color-rose-800);
|
||||||
|
--color-status-cyan-background: var(--color-cyan-200);
|
||||||
|
--color-status-cyan-text: var(--color-cyan-800);
|
||||||
|
--color-status-orange-background: var(--color-orange-200);
|
||||||
|
--color-status-orange-text: var(--color-orange-800);
|
||||||
|
--color-status-blue-background: var(--color-blue-200);
|
||||||
|
--color-status-blue-text: var(--color-blue-800);
|
||||||
|
--color-status-purple-background: var(--color-purple-100);
|
||||||
|
--color-status-purple-text: var(--color-purple-600);
|
||||||
|
--color-status-dot-green-halo: var(--color-green-100);
|
||||||
|
--color-status-dot-yellow-halo: var(--color-yellow-200);
|
||||||
|
--color-status-dot-indigo-halo: var(--color-indigo-100);
|
||||||
|
|
||||||
|
/* Date range selection: the connecting band and its start/end caps. */
|
||||||
|
--color-date-range-background: var(--color-accent-100);
|
||||||
|
--color-date-range-edge-background: var(--color-accent-300);
|
||||||
|
|
||||||
|
/* Calendar event chips. Title/time stay separate so the original light
|
||||||
|
* hierarchy remains intact while dark mode can normalize both to /300. */
|
||||||
|
--color-calendar-event-blue-background: var(--color-blue-100);
|
||||||
|
--color-calendar-event-blue-title: var(--color-blue-700);
|
||||||
|
--color-calendar-event-blue-time: var(--color-blue-700);
|
||||||
|
--color-calendar-event-pink-background: var(--color-pink-100);
|
||||||
|
--color-calendar-event-pink-title: var(--color-pink-700);
|
||||||
|
--color-calendar-event-pink-time: var(--color-pink-700);
|
||||||
|
--color-calendar-event-purple-background: var(--color-purple-100);
|
||||||
|
--color-calendar-event-purple-title: var(--color-purple-700);
|
||||||
|
--color-calendar-event-purple-time: var(--color-purple-700);
|
||||||
|
--color-calendar-event-lime-background: var(--color-lime-100);
|
||||||
|
--color-calendar-event-lime-title: var(--color-lime-800);
|
||||||
|
--color-calendar-event-lime-time: var(--color-lime-700);
|
||||||
|
--color-calendar-event-emerald-background: var(--color-emerald-100);
|
||||||
|
--color-calendar-event-emerald-title: var(--color-emerald-800);
|
||||||
|
--color-calendar-event-emerald-time: var(--color-emerald-700);
|
||||||
|
|
||||||
|
/* Placeholder colors that don't map cleanly to existing text tokens */
|
||||||
|
--color-text-error-placeholder: var(--color-red-400);
|
||||||
|
|
||||||
|
/* Charts (graphs) — shared neutrals + a categorical accent ramp.
|
||||||
|
* `track` is the background bar/ring behind a series; `cursor` the hover
|
||||||
|
* outline / dashed reference line; `neutral` a data color for "other"-style
|
||||||
|
* slices. Each numbered accent pairs a base fill with a one-tone-darker
|
||||||
|
* `-active` used for hover emphasis (the 400 → 500 recipe every chart
|
||||||
|
* follows). Numbered (not hue-named) so the palette can be retuned without
|
||||||
|
* renaming, mirroring the chart-1…n convention common to chart systems. */
|
||||||
|
--color-chart-track: var(--color-neutral-200);
|
||||||
|
--color-chart-cursor: var(--color-neutral-300);
|
||||||
|
--color-chart-neutral: var(--color-neutral-300);
|
||||||
|
--color-chart-1: var(--color-teal-400);
|
||||||
|
--color-chart-1-active: var(--color-teal-500);
|
||||||
|
--color-chart-2: var(--color-lime-400);
|
||||||
|
--color-chart-2-active: var(--color-lime-500);
|
||||||
|
--color-chart-3: var(--color-pink-400);
|
||||||
|
--color-chart-3-active: var(--color-pink-500);
|
||||||
|
--color-chart-4: var(--color-sky-400);
|
||||||
|
--color-chart-4-active: var(--color-sky-500);
|
||||||
|
--color-chart-5: var(--color-purple-400);
|
||||||
|
--color-chart-5-active: var(--color-purple-500);
|
||||||
|
--color-chart-6: var(--color-blue-400);
|
||||||
|
--color-chart-6-active: var(--color-blue-500);
|
||||||
|
--color-chart-7: var(--color-emerald-400);
|
||||||
|
--color-chart-7-active: var(--color-emerald-500);
|
||||||
|
--color-chart-8: var(--color-yellow-400);
|
||||||
|
--color-chart-8-active: var(--color-yellow-500);
|
||||||
|
--color-chart-9: var(--color-indigo-400);
|
||||||
|
--color-chart-9-active: var(--color-indigo-500);
|
||||||
|
--color-chart-agents-bar: var(--color-purple-300);
|
||||||
|
--color-chart-agents-bar-active: var(--color-purple-400);
|
||||||
|
|
||||||
|
/* Button gradients */
|
||||||
|
--gradient-button-primary-default: linear-gradient(180deg, var(--color-accent-500) 0%, var(--color-accent-600) 100%);
|
||||||
|
--gradient-button-primary-hover: linear-gradient(180deg, var(--color-accent-400) 0%, var(--color-accent-500) 100%);
|
||||||
|
--gradient-button-primary-active: linear-gradient(180deg, var(--color-accent-600) 0%, var(--color-accent-700) 100%);
|
||||||
|
--gradient-button-primary-disabled: linear-gradient(180deg, var(--color-neutral-200) 0%, var(--color-neutral-300) 100%);
|
||||||
|
|
||||||
|
--gradient-button-danger-default: linear-gradient(180deg, var(--color-red-500) 0%, var(--color-red-600) 100%);
|
||||||
|
--gradient-button-danger-hover: linear-gradient(180deg, var(--color-red-400) 0%, var(--color-red-500) 100%);
|
||||||
|
--gradient-button-danger-active: linear-gradient(180deg, var(--color-red-600) 0%, var(--color-red-700) 100%);
|
||||||
|
--gradient-button-danger-disabled: linear-gradient(180deg, var(--color-red-100) 0%, var(--color-red-200) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Foreground */
|
||||||
|
--color-foreground-full: var(--color-neutral-700);
|
||||||
|
--color-foreground-icon-primary: var(--color-neutral-50);
|
||||||
|
--color-foreground-icon-hover: var(--color-white);
|
||||||
|
--color-foreground-icon-error: var(--color-red-400);
|
||||||
|
--color-foreground-icon-tertiary: var(--color-neutral-600);
|
||||||
|
--color-foreground-icon-quaternary: var(--color-neutral-700);
|
||||||
|
--color-foreground-icon-disabled: var(--color-neutral-800);
|
||||||
|
--color-foreground-disabled: var(--color-neutral-700);
|
||||||
|
--color-foreground-disabled-danger: var(--color-red-400);
|
||||||
|
--color-input-disabled-foreground: var(--color-neutral-600);
|
||||||
|
--color-icon-button-disabled-foreground: var(--color-neutral-500);
|
||||||
|
--color-button-primary-disabled-foreground: var(--color-neutral-500);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: var(--color-neutral-50);
|
||||||
|
--color-text-tertiary: var(--color-neutral-600);
|
||||||
|
--color-text-disabled: var(--color-neutral-800);
|
||||||
|
--color-text-error-primary: var(--color-red-400);
|
||||||
|
--color-text-destructive: color-mix(in srgb, var(--color-red-500) 80%, var(--color-neutral-500));
|
||||||
|
--color-input-disabled-text: color-mix(in srgb, var(--color-neutral-500) 40%, transparent);
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
--color-background-full: var(--color-neutral-925);
|
||||||
|
--color-ticket-detail-surface: var(--color-neutral-925);
|
||||||
|
--color-ticket-detail-low-background: color-mix(in srgb, var(--color-indigo-950) 60%, transparent);
|
||||||
|
--color-ticket-detail-low-hover: color-mix(in srgb, var(--color-indigo-950) 80%, transparent);
|
||||||
|
--color-ticket-detail-medium-hover: color-mix(in srgb, var(--color-yellow-950) 80%, transparent);
|
||||||
|
--color-ticket-detail-high-hover: color-mix(in srgb, var(--color-orange-950) 80%, transparent);
|
||||||
|
--color-ticket-detail-urgent-hover: color-mix(in srgb, var(--color-rose-950) 80%, transparent);
|
||||||
|
--color-ticket-detail-low-text: var(--color-indigo-300);
|
||||||
|
--color-ticket-detail-resource-background: color-mix(in srgb, var(--color-blue-950) 60%, transparent);
|
||||||
|
--color-ticket-detail-resource-text: var(--color-blue-300);
|
||||||
|
--color-project-board-empty-panel: color-mix(in srgb, var(--color-neutral-800) 60%, transparent);
|
||||||
|
--color-project-board-empty-front: color-mix(in srgb, var(--color-neutral-800) 75%, var(--color-neutral-700));
|
||||||
|
--color-project-board-empty-middle: var(--color-neutral-700);
|
||||||
|
--color-project-board-empty-back: color-mix(in srgb, var(--color-neutral-600) 35%, var(--color-neutral-700));
|
||||||
|
--color-project-board-empty-detail: var(--color-neutral-500);
|
||||||
|
--color-background-primary-default: var(--color-neutral-800);
|
||||||
|
--color-background-inner-default: color-mix(in srgb, var(--color-neutral-800) 60%, transparent);
|
||||||
|
--color-background-primary-hover: color-mix(in srgb, var(--color-neutral-700) 60%, transparent);
|
||||||
|
--color-background-primary-active: var(--color-neutral-800);
|
||||||
|
--color-background-primary-disabled: var(--color-neutral-800);
|
||||||
|
--color-input-disabled-background: color-mix(in srgb, var(--color-neutral-800) 30%, transparent);
|
||||||
|
--color-switch-off-chip-start: var(--color-background-tertiary-default);
|
||||||
|
--color-switch-off-chip-end: var(--color-background-tertiary-default);
|
||||||
|
--color-background-secondary-default: var(--color-neutral-900);
|
||||||
|
--color-background-secondary-hover: var(--color-neutral-800);
|
||||||
|
--color-background-tertiary-default: var(--color-neutral-800);
|
||||||
|
--color-background-tertiary-hover: var(--color-neutral-700);
|
||||||
|
--color-background-tertiary-error: color-mix(in srgb, var(--color-red-950) 60%, transparent);
|
||||||
|
--color-background-quaternary-default: var(--color-neutral-700);
|
||||||
|
--color-background-quaternary-hover: var(--color-neutral-600);
|
||||||
|
--color-background-recent-hire-role: color-mix(in srgb, var(--color-neutral-900) 60%, transparent);
|
||||||
|
--color-avatar-neutral-background: var(--color-background-primary-default);
|
||||||
|
--color-file-upload-icon-background: var(--color-neutral-600);
|
||||||
|
--color-file-upload-icon-foreground: var(--color-neutral-400);
|
||||||
|
--color-file-upload-icon-foreground-hover: var(--color-neutral-300);
|
||||||
|
--color-stat-card-icon-background: var(--color-neutral-800);
|
||||||
|
--color-segmented-control-background: var(--color-neutral-925);
|
||||||
|
--color-segmented-control-selected-background: var(--color-neutral-800);
|
||||||
|
--color-theme-toggle-sidebar-background: var(--color-neutral-800);
|
||||||
|
--color-theme-toggle-sidebar-selected-background: var(--color-neutral-700);
|
||||||
|
--color-ai-chat-composer-add-background: var(--color-neutral-700);
|
||||||
|
--color-ai-chat-composer-add-hover-background: var(--color-neutral-600);
|
||||||
|
--color-composer-panel-tab-background: color-mix(in srgb, var(--color-neutral-700) 50%, transparent);
|
||||||
|
--color-composer-panel-add-background: var(--color-neutral-700);
|
||||||
|
--color-composer-panel-add-hover-background: var(--color-neutral-600);
|
||||||
|
--color-composer-panel-tile-border: color-mix(in srgb, var(--color-white) 14%, transparent);
|
||||||
|
--color-composer-panel-rail-background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||||
|
--color-questionnaire-key-background: var(--color-neutral-700);
|
||||||
|
--color-questionnaire-key-hover-background: var(--color-neutral-600);
|
||||||
|
--color-agent-progress-ring: color-mix(in srgb, var(--color-white) 50%, transparent);
|
||||||
|
--color-button-ghost-background: var(--color-accent-900);
|
||||||
|
--color-button-ghost-hover: var(--color-accent-800);
|
||||||
|
--color-button-ghost-active: var(--color-accent-700);
|
||||||
|
--color-button-ghost-disabled: var(--color-neutral-800);
|
||||||
|
--color-button-ghost-foreground: var(--color-accent-300);
|
||||||
|
--color-button-ghost-disabled-foreground: var(--color-neutral-600);
|
||||||
|
--color-dropdown-item-hover-background: color-mix(in srgb, var(--color-neutral-700) 60%, transparent);
|
||||||
|
--color-kbd-background: var(--color-neutral-700);
|
||||||
|
--color-kbd-foreground: var(--color-neutral-400);
|
||||||
|
--color-docs-file-chip-background: color-mix(in srgb, var(--color-purple-950) 50%, transparent);
|
||||||
|
--color-docs-file-chip-border: var(--color-purple-800);
|
||||||
|
--color-docs-file-chip-foreground: var(--color-purple-400);
|
||||||
|
--color-docs-code-background: var(--color-background-full);
|
||||||
|
--color-docs-install-rail: color-mix(in srgb, var(--color-neutral-800) 60%, transparent);
|
||||||
|
|
||||||
|
/* Dark surfaces absorb low-alpha shadows more readily than white ones.
|
||||||
|
* Preserve the light-mode geometry and raise only the black alpha: tighter
|
||||||
|
* contact shadows stay crisp, while higher elevations receive a restrained
|
||||||
|
* ambient layer without introducing a grey glow. */
|
||||||
|
--shadow-2xs: 0 1px rgb(0 0 0 / 0.16);
|
||||||
|
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.18);
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.12), 0 1px 3px 0 rgb(0 0 0 / 0.16);
|
||||||
|
--shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.12), 0 4px 6px -1px rgb(0 0 0 / 0.18);
|
||||||
|
--shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.10), 0 10px 15px -3px rgb(0 0 0 / 0.18);
|
||||||
|
--shadow-xl: 0 10px 10px -5px rgb(0 0 0 / 0.08), 0 20px 25px -5px rgb(0 0 0 / 0.20);
|
||||||
|
--shadow-card: 0 1px 1px 0 rgb(0 0 0 / 0.14);
|
||||||
|
--shadow-dropdown:
|
||||||
|
0 1px 1px 0 rgb(0 0 0 / 0.14),
|
||||||
|
0 4px 4px 0 rgb(0 0 0 / 0.10);
|
||||||
|
--shadow-sidebar:
|
||||||
|
0 1px 0 0 rgb(0 0 0 / 0.10),
|
||||||
|
0 1px 12px 0 rgb(0 0 0 / 0.16),
|
||||||
|
0 0 1px 0 rgb(0 0 0 / 0.42);
|
||||||
|
--shadow-waitlist:
|
||||||
|
0 0 6px 0 rgb(0 0 0 / 0.12),
|
||||||
|
0 0 64px 0 rgb(0 0 0 / 0.22),
|
||||||
|
0 0 1px 0 rgb(0 0 0 / 0.42),
|
||||||
|
0 1px 12px 0 rgb(0 0 0 / 0.14),
|
||||||
|
0 1px 0 0 rgb(0 0 0 / 0.08);
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
--color-badge-neutral-background: var(--color-neutral-800);
|
||||||
|
--color-team-menu-count-background: var(--color-neutral-700);
|
||||||
|
--color-team-menu-count-foreground: var(--color-neutral-400);
|
||||||
|
--color-badge-new-background: var(--color-accent-950);
|
||||||
|
--color-badge-new-text: var(--color-accent-400);
|
||||||
|
--color-tab-count-selected-background: color-mix(in srgb, var(--color-accent-800) 60%, transparent);
|
||||||
|
|
||||||
|
/* Dark status discs use /800 surfaces with /300 foregrounds so the icon
|
||||||
|
* remains crisp without reverting to a bright pastel circle. */
|
||||||
|
--color-notification-information-background: color-mix(in srgb, var(--color-blue-800) 50%, transparent);
|
||||||
|
--color-notification-information-foreground: var(--color-blue-300);
|
||||||
|
--color-notification-success-background: color-mix(in srgb, var(--color-lime-800) 50%, transparent);
|
||||||
|
--color-notification-success-foreground: var(--color-lime-300);
|
||||||
|
--color-notification-error-background: color-mix(in srgb, var(--color-rose-800) 50%, transparent);
|
||||||
|
--color-notification-error-foreground: var(--color-rose-300);
|
||||||
|
--color-notification-center-background: var(--color-neutral-900);
|
||||||
|
|
||||||
|
/* Borders and separators */
|
||||||
|
--color-border-button-default: var(--color-neutral-700);
|
||||||
|
--color-border-button-hover: var(--color-neutral-500);
|
||||||
|
--color-border-button-active: var(--color-neutral-600);
|
||||||
|
--color-border-button-white: var(--color-zinc-800);
|
||||||
|
--color-border-error-default: var(--color-red-400);
|
||||||
|
--color-border-checkbox-default: var(--color-neutral-700);
|
||||||
|
--color-border-checkbox-hover: var(--color-neutral-500);
|
||||||
|
--color-border-checkbox-active: var(--color-neutral-500);
|
||||||
|
--color-border-component-detail-container: var(--color-neutral-800);
|
||||||
|
--color-border-ai-profile-card: var(--color-neutral-800);
|
||||||
|
--color-border-table: var(--color-neutral-800);
|
||||||
|
--color-border-button-group: color-mix(in srgb, var(--color-neutral-600) 60%, transparent);
|
||||||
|
--color-border-sidebar-profile-hover: var(--color-neutral-600);
|
||||||
|
--color-separator-border: var(--color-neutral-800);
|
||||||
|
--color-separator-border-strong: color-mix(in srgb, var(--color-neutral-700) 60%, transparent);
|
||||||
|
|
||||||
|
/* Figma's dark table status pairs. */
|
||||||
|
--color-status-lime-background: color-mix(in srgb, var(--color-lime-950) 60%, transparent);
|
||||||
|
--color-status-lime-text: var(--color-lime-500);
|
||||||
|
--color-status-yellow-background: color-mix(in srgb, var(--color-yellow-950) 60%, transparent);
|
||||||
|
--color-status-yellow-text: var(--color-yellow-500);
|
||||||
|
--color-status-rose-background: color-mix(in srgb, var(--color-rose-950) 60%, transparent);
|
||||||
|
--color-status-rose-text: var(--color-rose-500);
|
||||||
|
--color-status-cyan-background: color-mix(in srgb, var(--color-cyan-950) 60%, transparent);
|
||||||
|
--color-status-cyan-text: var(--color-cyan-400);
|
||||||
|
--color-status-orange-background: color-mix(in srgb, var(--color-orange-950) 60%, transparent);
|
||||||
|
--color-status-orange-text: var(--color-orange-500);
|
||||||
|
--color-status-blue-background: color-mix(in srgb, var(--color-blue-950) 60%, transparent);
|
||||||
|
--color-status-blue-text: var(--color-blue-300);
|
||||||
|
--color-status-purple-background: color-mix(in srgb, var(--color-purple-900) 50%, transparent);
|
||||||
|
--color-status-purple-text: var(--color-purple-300);
|
||||||
|
--color-status-dot-green-halo: color-mix(in srgb, var(--color-green-800) 40%, transparent);
|
||||||
|
--color-status-dot-yellow-halo: color-mix(in srgb, var(--color-yellow-800) 40%, transparent);
|
||||||
|
--color-status-dot-indigo-halo: color-mix(in srgb, var(--color-indigo-800) 40%, transparent);
|
||||||
|
|
||||||
|
--color-date-range-background: var(--color-accent-950);
|
||||||
|
--color-date-range-edge-background: var(--color-accent-800);
|
||||||
|
|
||||||
|
--color-pill-tab-blue-selected-background: color-mix(in srgb, var(--color-accent-950) 60%, transparent);
|
||||||
|
--color-pill-tab-blue-hover-background: var(--color-neutral-800);
|
||||||
|
|
||||||
|
/* Calendar event families share the same dark-mode depth/contrast recipe. */
|
||||||
|
--color-calendar-event-blue-background: var(--color-blue-950);
|
||||||
|
--color-calendar-event-blue-title: var(--color-blue-300);
|
||||||
|
--color-calendar-event-blue-time: var(--color-blue-300);
|
||||||
|
--color-calendar-event-pink-background: var(--color-pink-950);
|
||||||
|
--color-calendar-event-pink-title: var(--color-pink-300);
|
||||||
|
--color-calendar-event-pink-time: var(--color-pink-300);
|
||||||
|
--color-calendar-event-purple-background: var(--color-purple-950);
|
||||||
|
--color-calendar-event-purple-title: var(--color-purple-300);
|
||||||
|
--color-calendar-event-purple-time: var(--color-purple-300);
|
||||||
|
--color-calendar-event-lime-background: var(--color-lime-950);
|
||||||
|
--color-calendar-event-lime-title: var(--color-lime-300);
|
||||||
|
--color-calendar-event-lime-time: var(--color-lime-300);
|
||||||
|
--color-calendar-event-emerald-background: var(--color-emerald-950);
|
||||||
|
--color-calendar-event-emerald-title: var(--color-emerald-300);
|
||||||
|
--color-calendar-event-emerald-time: var(--color-emerald-300);
|
||||||
|
|
||||||
|
/* Disabled primary CTA: dark, low-emphasis fill with readable muted text. */
|
||||||
|
--gradient-button-primary-disabled: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--color-neutral-700) 0%,
|
||||||
|
var(--color-neutral-800) 100%
|
||||||
|
);
|
||||||
|
--gradient-button-danger-disabled: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--color-red-900) 0%,
|
||||||
|
var(--color-red-950) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Neutral graph structure; categorical and status colors stay unchanged. */
|
||||||
|
--color-chart-track: var(--color-neutral-800);
|
||||||
|
--color-chart-cursor: var(--color-neutral-700);
|
||||||
|
--color-chart-neutral: var(--color-neutral-800);
|
||||||
|
--color-chart-agents-bar: var(--color-purple-500);
|
||||||
|
--color-chart-agents-bar-active: var(--color-purple-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Non-default accents (data-accent set by the accent engine) can land on a
|
||||||
|
* /950 that glows too loudly against the dark sidebar; halve the NEW badge
|
||||||
|
* fill there. Light mode keeps the solid /100 surface. */
|
||||||
|
.dark[data-accent] {
|
||||||
|
--color-badge-new-background: color-mix(in srgb, var(--color-accent-950) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-export semantic tokens as Tailwind utilities. The `inline` form means
|
||||||
|
* Tailwind reads the *current* computed value of the variable, so light↔dark
|
||||||
|
* flips work without rebuild. */
|
||||||
|
@theme inline {
|
||||||
|
--color-foreground-full: var(--color-foreground-full);
|
||||||
|
--color-foreground-icon-primary: var(--color-foreground-icon-primary);
|
||||||
|
--color-foreground-icon-hover: var(--color-foreground-icon-hover);
|
||||||
|
--color-foreground-icon-secondary: var(--color-foreground-icon-secondary);
|
||||||
|
--color-foreground-icon-tertiary: var(--color-foreground-icon-tertiary);
|
||||||
|
--color-foreground-icon-quaternary: var(--color-foreground-icon-quaternary);
|
||||||
|
--color-foreground-icon-error: var(--color-foreground-icon-error);
|
||||||
|
--color-foreground-icon-disabled: var(--color-foreground-icon-disabled);
|
||||||
|
--color-foreground-disabled: var(--color-foreground-disabled);
|
||||||
|
--color-foreground-disabled-danger: var(--color-foreground-disabled-danger);
|
||||||
|
--color-input-disabled-foreground: var(--color-input-disabled-foreground);
|
||||||
|
--color-icon-button-disabled-foreground: var(--color-icon-button-disabled-foreground);
|
||||||
|
--color-button-primary-disabled-foreground: var(--color-button-primary-disabled-foreground);
|
||||||
|
|
||||||
|
--color-text-primary: var(--color-text-primary);
|
||||||
|
--color-text-secondary: var(--color-text-secondary);
|
||||||
|
--color-text-tertiary: var(--color-text-tertiary);
|
||||||
|
--color-text-disabled: var(--color-text-disabled);
|
||||||
|
--color-text-disabled-danger: var(--color-text-disabled-danger);
|
||||||
|
--color-text-placeholder: var(--color-text-placeholder);
|
||||||
|
--color-text-error-primary: var(--color-text-error-primary);
|
||||||
|
--color-text-destructive: var(--color-text-destructive);
|
||||||
|
--color-input-disabled-text: var(--color-input-disabled-text);
|
||||||
|
--color-text-white: var(--color-text-white);
|
||||||
|
|
||||||
|
--color-background-full: var(--color-background-full);
|
||||||
|
--color-ticket-detail-surface: var(--color-ticket-detail-surface);
|
||||||
|
--color-ticket-detail-low-background: var(--color-ticket-detail-low-background);
|
||||||
|
--color-ticket-detail-low-hover: var(--color-ticket-detail-low-hover);
|
||||||
|
--color-ticket-detail-medium-hover: var(--color-ticket-detail-medium-hover);
|
||||||
|
--color-ticket-detail-high-hover: var(--color-ticket-detail-high-hover);
|
||||||
|
--color-ticket-detail-urgent-hover: var(--color-ticket-detail-urgent-hover);
|
||||||
|
--color-ticket-detail-low-text: var(--color-ticket-detail-low-text);
|
||||||
|
--color-ticket-detail-resource-background: var(--color-ticket-detail-resource-background);
|
||||||
|
--color-ticket-detail-resource-text: var(--color-ticket-detail-resource-text);
|
||||||
|
--color-ticket-detail-favorite: var(--color-ticket-detail-favorite);
|
||||||
|
--color-background-primary-default: var(--color-background-primary-default);
|
||||||
|
--color-background-inner-default: var(--color-background-inner-default);
|
||||||
|
--color-background-primary-hover: var(--color-background-primary-hover);
|
||||||
|
--color-background-primary-active: var(--color-background-primary-active);
|
||||||
|
--color-background-primary-disabled: var(--color-background-primary-disabled);
|
||||||
|
--color-input-disabled-background: var(--color-input-disabled-background);
|
||||||
|
--color-background-secondary-default: var(--color-background-secondary-default);
|
||||||
|
--color-background-secondary-hover: var(--color-background-secondary-hover);
|
||||||
|
--color-background-tertiary-default: var(--color-background-tertiary-default);
|
||||||
|
--color-background-tertiary-hover: var(--color-background-tertiary-hover);
|
||||||
|
--color-background-tertiary-error: var(--color-background-tertiary-error);
|
||||||
|
--color-background-recent-hire-role: var(--color-background-recent-hire-role);
|
||||||
|
--color-avatar-neutral-background: var(--color-avatar-neutral-background);
|
||||||
|
--color-background-quaternary-default: var(--color-background-quaternary-default);
|
||||||
|
--color-background-quaternary-hover: var(--color-background-quaternary-hover);
|
||||||
|
--color-background-quaternary-error: var(--color-background-quaternary-error);
|
||||||
|
--color-file-upload-icon-background: var(--color-file-upload-icon-background);
|
||||||
|
--color-file-upload-icon-foreground: var(--color-file-upload-icon-foreground);
|
||||||
|
--color-file-upload-icon-foreground-hover: var(--color-file-upload-icon-foreground-hover);
|
||||||
|
|
||||||
|
--color-border-button-default: var(--color-border-button-default);
|
||||||
|
--color-border-button-hover: var(--color-border-button-hover);
|
||||||
|
--color-border-button-active: var(--color-border-button-active);
|
||||||
|
--color-border-button-white: var(--color-border-button-white);
|
||||||
|
--color-border-error-default: var(--color-border-error-default);
|
||||||
|
--color-border-checkbox-default: var(--color-border-checkbox-default);
|
||||||
|
--color-border-checkbox-hover: var(--color-border-checkbox-hover);
|
||||||
|
--color-border-checkbox-active: var(--color-border-checkbox-active);
|
||||||
|
--color-border-checkbox-white: var(--color-border-checkbox-white);
|
||||||
|
--color-border-component-detail-container: var(--color-border-component-detail-container);
|
||||||
|
--color-border-ai-profile-card: var(--color-border-ai-profile-card);
|
||||||
|
--color-border-table: var(--color-border-table);
|
||||||
|
--color-border-button-group: var(--color-border-button-group);
|
||||||
|
--color-border-sidebar-profile-hover: var(--color-border-sidebar-profile-hover);
|
||||||
|
--color-separator-border: var(--color-separator-border);
|
||||||
|
--color-separator-border-strong: var(--color-separator-border-strong);
|
||||||
|
--color-border-focus-ring: var(--color-border-focus-ring);
|
||||||
|
|
||||||
|
--color-state-success-text: var(--color-state-success-text);
|
||||||
|
--color-state-success-base: var(--color-state-success-base);
|
||||||
|
--color-control-indicator-background: var(--color-control-indicator-background);
|
||||||
|
--color-control-indicator-background-subtle: var(--color-control-indicator-background-subtle);
|
||||||
|
--color-switch-off-chip-start: var(--color-switch-off-chip-start);
|
||||||
|
--color-switch-off-chip-end: var(--color-switch-off-chip-end);
|
||||||
|
--color-switch-on-chip-start: var(--color-switch-on-chip-start);
|
||||||
|
--color-switch-on-chip-end: var(--color-switch-on-chip-end);
|
||||||
|
--color-stat-card-icon-background: var(--color-stat-card-icon-background);
|
||||||
|
--color-segmented-control-background: var(--color-segmented-control-background);
|
||||||
|
--color-segmented-control-selected-background: var(--color-segmented-control-selected-background);
|
||||||
|
--color-theme-toggle-sidebar-background: var(--color-theme-toggle-sidebar-background);
|
||||||
|
--color-theme-toggle-sidebar-selected-background: var(--color-theme-toggle-sidebar-selected-background);
|
||||||
|
--color-ai-chat-composer-add-background: var(--color-ai-chat-composer-add-background);
|
||||||
|
--color-ai-chat-composer-add-hover-background: var(--color-ai-chat-composer-add-hover-background);
|
||||||
|
--color-composer-panel-tab-background: var(--color-composer-panel-tab-background);
|
||||||
|
--color-composer-panel-add-background: var(--color-composer-panel-add-background);
|
||||||
|
--color-composer-panel-add-hover-background: var(--color-composer-panel-add-hover-background);
|
||||||
|
--color-composer-panel-tile-border: var(--color-composer-panel-tile-border);
|
||||||
|
--color-composer-panel-rail-background: var(--color-composer-panel-rail-background);
|
||||||
|
--color-questionnaire-key-background: var(--color-questionnaire-key-background);
|
||||||
|
--color-questionnaire-key-hover-background: var(--color-questionnaire-key-hover-background);
|
||||||
|
--color-agent-progress-ring: var(--color-agent-progress-ring);
|
||||||
|
--color-button-ghost-background: var(--color-button-ghost-background);
|
||||||
|
--color-button-ghost-hover: var(--color-button-ghost-hover);
|
||||||
|
--color-button-ghost-active: var(--color-button-ghost-active);
|
||||||
|
--color-button-ghost-disabled: var(--color-button-ghost-disabled);
|
||||||
|
--color-button-ghost-foreground: var(--color-button-ghost-foreground);
|
||||||
|
--color-button-ghost-disabled-foreground: var(--color-button-ghost-disabled-foreground);
|
||||||
|
--color-dropdown-item-hover-background: var(--color-dropdown-item-hover-background);
|
||||||
|
--color-kbd-background: var(--color-kbd-background);
|
||||||
|
--color-kbd-foreground: var(--color-kbd-foreground);
|
||||||
|
--color-docs-file-chip-background: var(--color-docs-file-chip-background);
|
||||||
|
--color-docs-file-chip-border: var(--color-docs-file-chip-border);
|
||||||
|
--color-docs-file-chip-foreground: var(--color-docs-file-chip-foreground);
|
||||||
|
--color-docs-command-accent: var(--color-docs-command-accent);
|
||||||
|
--color-docs-code-background: var(--color-docs-code-background);
|
||||||
|
--color-docs-install-rail: var(--color-docs-install-rail);
|
||||||
|
--color-badge-neutral-background: var(--color-badge-neutral-background);
|
||||||
|
--color-team-menu-count-background: var(--color-team-menu-count-background);
|
||||||
|
--color-team-menu-count-foreground: var(--color-team-menu-count-foreground);
|
||||||
|
--color-badge-new-background: var(--color-badge-new-background);
|
||||||
|
--color-badge-new-text: var(--color-badge-new-text);
|
||||||
|
--color-tab-count-selected-background: var(--color-tab-count-selected-background);
|
||||||
|
--color-pill-tab-blue-selected-background: var(--color-pill-tab-blue-selected-background);
|
||||||
|
--color-pill-tab-blue-hover-background: var(--color-pill-tab-blue-hover-background);
|
||||||
|
--color-notification-information-background: var(--color-notification-information-background);
|
||||||
|
--color-notification-information-foreground: var(--color-notification-information-foreground);
|
||||||
|
--color-notification-success-background: var(--color-notification-success-background);
|
||||||
|
--color-notification-success-foreground: var(--color-notification-success-foreground);
|
||||||
|
--color-notification-error-background: var(--color-notification-error-background);
|
||||||
|
--color-notification-error-foreground: var(--color-notification-error-foreground);
|
||||||
|
--color-notification-center-background: var(--color-notification-center-background);
|
||||||
|
--color-status-lime-background: var(--color-status-lime-background);
|
||||||
|
--color-status-lime-text: var(--color-status-lime-text);
|
||||||
|
--color-status-yellow-background: var(--color-status-yellow-background);
|
||||||
|
--color-status-yellow-text: var(--color-status-yellow-text);
|
||||||
|
--color-status-rose-background: var(--color-status-rose-background);
|
||||||
|
--color-status-rose-text: var(--color-status-rose-text);
|
||||||
|
--color-status-cyan-background: var(--color-status-cyan-background);
|
||||||
|
--color-status-cyan-text: var(--color-status-cyan-text);
|
||||||
|
--color-status-orange-background: var(--color-status-orange-background);
|
||||||
|
--color-status-orange-text: var(--color-status-orange-text);
|
||||||
|
--color-status-blue-background: var(--color-status-blue-background);
|
||||||
|
--color-status-blue-text: var(--color-status-blue-text);
|
||||||
|
--color-status-purple-background: var(--color-status-purple-background);
|
||||||
|
--color-status-purple-text: var(--color-status-purple-text);
|
||||||
|
--color-status-dot-green-halo: var(--color-status-dot-green-halo);
|
||||||
|
--color-status-dot-yellow-halo: var(--color-status-dot-yellow-halo);
|
||||||
|
--color-status-dot-indigo-halo: var(--color-status-dot-indigo-halo);
|
||||||
|
--color-date-range-background: var(--color-date-range-background);
|
||||||
|
--color-date-range-edge-background: var(--color-date-range-edge-background);
|
||||||
|
--color-calendar-event-blue-background: var(--color-calendar-event-blue-background);
|
||||||
|
--color-calendar-event-blue-title: var(--color-calendar-event-blue-title);
|
||||||
|
--color-calendar-event-blue-time: var(--color-calendar-event-blue-time);
|
||||||
|
--color-calendar-event-pink-background: var(--color-calendar-event-pink-background);
|
||||||
|
--color-calendar-event-pink-title: var(--color-calendar-event-pink-title);
|
||||||
|
--color-calendar-event-pink-time: var(--color-calendar-event-pink-time);
|
||||||
|
--color-calendar-event-purple-background: var(--color-calendar-event-purple-background);
|
||||||
|
--color-calendar-event-purple-title: var(--color-calendar-event-purple-title);
|
||||||
|
--color-calendar-event-purple-time: var(--color-calendar-event-purple-time);
|
||||||
|
--color-calendar-event-lime-background: var(--color-calendar-event-lime-background);
|
||||||
|
--color-calendar-event-lime-title: var(--color-calendar-event-lime-title);
|
||||||
|
--color-calendar-event-lime-time: var(--color-calendar-event-lime-time);
|
||||||
|
--color-calendar-event-emerald-background: var(--color-calendar-event-emerald-background);
|
||||||
|
--color-calendar-event-emerald-title: var(--color-calendar-event-emerald-title);
|
||||||
|
--color-calendar-event-emerald-time: var(--color-calendar-event-emerald-time);
|
||||||
|
|
||||||
|
--color-text-error-placeholder: var(--color-text-error-placeholder);
|
||||||
|
|
||||||
|
--color-chart-track: var(--color-chart-track);
|
||||||
|
--color-chart-cursor: var(--color-chart-cursor);
|
||||||
|
--color-chart-neutral: var(--color-chart-neutral);
|
||||||
|
--color-chart-1: var(--color-chart-1);
|
||||||
|
--color-chart-1-active: var(--color-chart-1-active);
|
||||||
|
--color-chart-2: var(--color-chart-2);
|
||||||
|
--color-chart-2-active: var(--color-chart-2-active);
|
||||||
|
--color-chart-3: var(--color-chart-3);
|
||||||
|
--color-chart-3-active: var(--color-chart-3-active);
|
||||||
|
--color-chart-4: var(--color-chart-4);
|
||||||
|
--color-chart-4-active: var(--color-chart-4-active);
|
||||||
|
--color-chart-5: var(--color-chart-5);
|
||||||
|
--color-chart-5-active: var(--color-chart-5-active);
|
||||||
|
--color-chart-6: var(--color-chart-6);
|
||||||
|
--color-chart-6-active: var(--color-chart-6-active);
|
||||||
|
--color-chart-7: var(--color-chart-7);
|
||||||
|
--color-chart-7-active: var(--color-chart-7-active);
|
||||||
|
--color-chart-8: var(--color-chart-8);
|
||||||
|
--color-chart-8-active: var(--color-chart-8-active);
|
||||||
|
--color-chart-9: var(--color-chart-9);
|
||||||
|
--color-chart-9-active: var(--color-chart-9-active);
|
||||||
|
--color-chart-agents-bar: var(--color-chart-agents-bar);
|
||||||
|
--color-chart-agents-bar-active: var(--color-chart-agents-bar-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Component utilities — Buttons
|
||||||
|
*
|
||||||
|
* Each variant collapses the four interactive states (default / hover / active
|
||||||
|
* / disabled) into a single class. Use them on the component:
|
||||||
|
*
|
||||||
|
* <button class="bg-button-primary">…</button>
|
||||||
|
* <button class="bg-button-danger">…</button>
|
||||||
|
*
|
||||||
|
* Smooth default→hover transition trick:
|
||||||
|
* CSS can't interpolate `background-image`, so swapping gradients on :hover
|
||||||
|
* would snap. Instead we keep the *default* gradient on the element and
|
||||||
|
* layer the *hover* gradient on a `::before` pseudo-element whose opacity
|
||||||
|
* fades 0→1 over `--button-transition-ms`. Active/disabled gradients still
|
||||||
|
* swap instantly (we hide the pseudo to reveal them).
|
||||||
|
*
|
||||||
|
* Stacking: `isolation: isolate` keeps the pseudo behind the button's text
|
||||||
|
* and icons (z-index -1) while staying above the element's own background.
|
||||||
|
* `border-radius: inherit` + `overflow: hidden` keeps it within the rounded
|
||||||
|
* corners.
|
||||||
|
* --------------------------------------------------------------------------- */
|
||||||
|
@theme {
|
||||||
|
--button-transition-ms: 150ms;
|
||||||
|
--input-transition-ms: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep color feedback quick while the physical press motion remains soft.
|
||||||
|
* The transform uses a shorter duration on press and the slower duration only
|
||||||
|
* when returning to rest, so hover styles never inherit the 700ms timing. */
|
||||||
|
@utility button-press-motion {
|
||||||
|
transition:
|
||||||
|
background-color var(--button-transition-ms) ease,
|
||||||
|
border-color var(--button-transition-ms) ease,
|
||||||
|
box-shadow var(--button-transition-ms) ease,
|
||||||
|
color var(--button-transition-ms) ease,
|
||||||
|
transform 420ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:active:not(:disabled):not([aria-disabled="true"]) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
transition-duration: 150ms, 150ms, 150ms, 150ms, 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-button-primary {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background-image: var(--gradient-button-primary-default);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background-image: var(--gradient-button-primary-hover);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--button-transition-ms) ease;
|
||||||
|
}
|
||||||
|
&:hover:not(:disabled):not([aria-disabled="true"])::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&:active:not(:disabled):not([aria-disabled="true"]) {
|
||||||
|
background-image: var(--gradient-button-primary-active);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled):not([aria-disabled="true"])::before {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&:disabled,
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
background-image: var(--gradient-button-primary-disabled);
|
||||||
|
}
|
||||||
|
&:disabled::before,
|
||||||
|
&[aria-disabled="true"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-button-danger {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background-image: var(--gradient-button-danger-default);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background-image: var(--gradient-button-danger-hover);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--button-transition-ms) ease;
|
||||||
|
}
|
||||||
|
&:hover:not(:disabled):not([aria-disabled="true"])::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&:active:not(:disabled):not([aria-disabled="true"]) {
|
||||||
|
background-image: var(--gradient-button-danger-active);
|
||||||
|
}
|
||||||
|
&:active:not(:disabled):not([aria-disabled="true"])::before {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&:disabled,
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
background-image: var(--gradient-button-danger-disabled);
|
||||||
|
}
|
||||||
|
&:disabled::before,
|
||||||
|
&[aria-disabled="true"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
257
styles/typography.css
Normal file
257
styles/typography.css
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* BoardUI typography
|
||||||
|
*
|
||||||
|
* Type ramp from Figma → text styles. Each style registers a Tailwind v4
|
||||||
|
* text utility — `text-{name}` applies font-size + line-height + letter-spacing
|
||||||
|
* + font-weight in one go.
|
||||||
|
*
|
||||||
|
* Family: Inter for the whole ramp. (The Figma export marks Large Title /
|
||||||
|
* Regular as "Inter Display"; we map everything to Inter for consistency since
|
||||||
|
* Inter ships a variable optical-size axis. Flag if a true Inter Display load
|
||||||
|
* is needed.)
|
||||||
|
*
|
||||||
|
* Weight mapping (mirrors Figma variables — font/weight/*):
|
||||||
|
* Regular = 400
|
||||||
|
* Medium = 500
|
||||||
|
* Semibold = 600 (Figma variable font/weight/semibold = 600)
|
||||||
|
* Bold = 700
|
||||||
|
*
|
||||||
|
* Naming: `<family>-<weight>` in lowercase kebab — e.g. `text-body-medium`,
|
||||||
|
* `text-title-1-semibold`, `text-caption-2-bold`.
|
||||||
|
* --------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ===== Large Title — 64 / 80 ============================================= */
|
||||||
|
--text-large-title-regular: 4rem;
|
||||||
|
--text-large-title-regular--line-height: 5rem;
|
||||||
|
--text-large-title-regular--letter-spacing: 0;
|
||||||
|
--text-large-title-regular--font-weight: 400;
|
||||||
|
--text-large-title-medium: 4rem;
|
||||||
|
--text-large-title-medium--line-height: 5rem;
|
||||||
|
--text-large-title-medium--letter-spacing: 0;
|
||||||
|
--text-large-title-medium--font-weight: 500;
|
||||||
|
--text-large-title-semibold: 4rem;
|
||||||
|
--text-large-title-semibold--line-height: 5rem;
|
||||||
|
--text-large-title-semibold--letter-spacing: 0;
|
||||||
|
--text-large-title-semibold--font-weight: 600;
|
||||||
|
--text-large-title-bold: 4rem;
|
||||||
|
--text-large-title-bold--line-height: 5rem;
|
||||||
|
--text-large-title-bold--letter-spacing: 0;
|
||||||
|
--text-large-title-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Display 1 — 56 / 72 =============================================== */
|
||||||
|
--text-display-1-regular: 3.5rem;
|
||||||
|
--text-display-1-regular--line-height: 4.5rem;
|
||||||
|
--text-display-1-regular--letter-spacing: 0;
|
||||||
|
--text-display-1-regular--font-weight: 400;
|
||||||
|
--text-display-1-medium: 3.5rem;
|
||||||
|
--text-display-1-medium--line-height: 4.5rem;
|
||||||
|
--text-display-1-medium--letter-spacing: 0;
|
||||||
|
--text-display-1-medium--font-weight: 500;
|
||||||
|
--text-display-1-semibold: 3.5rem;
|
||||||
|
--text-display-1-semibold--line-height: 4.5rem;
|
||||||
|
--text-display-1-semibold--letter-spacing: 0;
|
||||||
|
--text-display-1-semibold--font-weight: 600;
|
||||||
|
--text-display-1-bold: 3.5rem;
|
||||||
|
--text-display-1-bold--line-height: 4.5rem;
|
||||||
|
--text-display-1-bold--letter-spacing: 0;
|
||||||
|
--text-display-1-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Display 2 — 48 / 64 =============================================== */
|
||||||
|
--text-display-2-regular: 3rem;
|
||||||
|
--text-display-2-regular--line-height: 4rem;
|
||||||
|
--text-display-2-regular--letter-spacing: 0;
|
||||||
|
--text-display-2-regular--font-weight: 400;
|
||||||
|
--text-display-2-medium: 3rem;
|
||||||
|
--text-display-2-medium--line-height: 4rem;
|
||||||
|
--text-display-2-medium--letter-spacing: 0;
|
||||||
|
--text-display-2-medium--font-weight: 500;
|
||||||
|
--text-display-2-semibold: 3rem;
|
||||||
|
--text-display-2-semibold--line-height: 4rem;
|
||||||
|
--text-display-2-semibold--letter-spacing: 0;
|
||||||
|
--text-display-2-semibold--font-weight: 600;
|
||||||
|
--text-display-2-bold: 3rem;
|
||||||
|
--text-display-2-bold--line-height: 4rem;
|
||||||
|
--text-display-2-bold--letter-spacing: 0;
|
||||||
|
--text-display-2-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Display 3 — 40 / 54 =============================================== */
|
||||||
|
--text-display-3-regular: 2.5rem;
|
||||||
|
--text-display-3-regular--line-height: 3.375rem;
|
||||||
|
--text-display-3-regular--letter-spacing: 0;
|
||||||
|
--text-display-3-regular--font-weight: 400;
|
||||||
|
--text-display-3-medium: 2.5rem;
|
||||||
|
--text-display-3-medium--line-height: 3.375rem;
|
||||||
|
--text-display-3-medium--letter-spacing: 0;
|
||||||
|
--text-display-3-medium--font-weight: 500;
|
||||||
|
--text-display-3-semibold: 2.5rem;
|
||||||
|
--text-display-3-semibold--line-height: 3.375rem;
|
||||||
|
--text-display-3-semibold--letter-spacing: 0;
|
||||||
|
--text-display-3-semibold--font-weight: 600;
|
||||||
|
--text-display-3-bold: 2.5rem;
|
||||||
|
--text-display-3-bold--line-height: 3.375rem;
|
||||||
|
--text-display-3-bold--letter-spacing: 0;
|
||||||
|
--text-display-3-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Display 4 — 32 / 44 =============================================== */
|
||||||
|
--text-display-4-regular: 2rem;
|
||||||
|
--text-display-4-regular--line-height: 2.75rem;
|
||||||
|
--text-display-4-regular--letter-spacing: 0;
|
||||||
|
--text-display-4-regular--font-weight: 400;
|
||||||
|
--text-display-4-medium: 2rem;
|
||||||
|
--text-display-4-medium--line-height: 2.75rem;
|
||||||
|
--text-display-4-medium--letter-spacing: 0;
|
||||||
|
--text-display-4-medium--font-weight: 500;
|
||||||
|
--text-display-4-semibold: 2rem;
|
||||||
|
--text-display-4-semibold--line-height: 2.75rem;
|
||||||
|
--text-display-4-semibold--letter-spacing: 0;
|
||||||
|
--text-display-4-semibold--font-weight: 600;
|
||||||
|
--text-display-4-bold: 2rem;
|
||||||
|
--text-display-4-bold--line-height: 2.75rem;
|
||||||
|
--text-display-4-bold--letter-spacing: 0;
|
||||||
|
--text-display-4-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Title 1 — 24 / 34 ================================================= */
|
||||||
|
--text-title-1-regular: 1.5rem;
|
||||||
|
--text-title-1-regular--line-height: 2.125rem;
|
||||||
|
--text-title-1-regular--letter-spacing: 0;
|
||||||
|
--text-title-1-regular--font-weight: 400;
|
||||||
|
--text-title-1-medium: 1.5rem;
|
||||||
|
--text-title-1-medium--line-height: 2.125rem;
|
||||||
|
--text-title-1-medium--letter-spacing: 0;
|
||||||
|
--text-title-1-medium--font-weight: 500;
|
||||||
|
--text-title-1-semibold: 1.5rem;
|
||||||
|
--text-title-1-semibold--line-height: 2.125rem;
|
||||||
|
--text-title-1-semibold--letter-spacing: 0;
|
||||||
|
--text-title-1-semibold--font-weight: 600;
|
||||||
|
--text-title-1-bold: 1.5rem;
|
||||||
|
--text-title-1-bold--line-height: 2.125rem;
|
||||||
|
--text-title-1-bold--letter-spacing: 0;
|
||||||
|
--text-title-1-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Title 2 — 20 / 26 ================================================= */
|
||||||
|
--text-title-2-regular: 1.25rem;
|
||||||
|
--text-title-2-regular--line-height: 1.625rem;
|
||||||
|
--text-title-2-regular--letter-spacing: 0;
|
||||||
|
--text-title-2-regular--font-weight: 400;
|
||||||
|
--text-title-2-medium: 1.25rem;
|
||||||
|
--text-title-2-medium--line-height: 1.625rem;
|
||||||
|
--text-title-2-medium--letter-spacing: 0;
|
||||||
|
--text-title-2-medium--font-weight: 500;
|
||||||
|
--text-title-2-semibold: 1.25rem;
|
||||||
|
--text-title-2-semibold--line-height: 1.625rem;
|
||||||
|
--text-title-2-semibold--letter-spacing: 0;
|
||||||
|
--text-title-2-semibold--font-weight: 600;
|
||||||
|
--text-title-2-bold: 1.25rem;
|
||||||
|
--text-title-2-bold--line-height: 1.625rem;
|
||||||
|
--text-title-2-bold--letter-spacing: 0;
|
||||||
|
--text-title-2-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Title 3 — 18 / 24-26 ============================================== */
|
||||||
|
--text-title-3-regular: 1.125rem;
|
||||||
|
--text-title-3-regular--line-height: 1.5rem; /* 24 per Figma */
|
||||||
|
--text-title-3-regular--letter-spacing: 0;
|
||||||
|
--text-title-3-regular--font-weight: 400;
|
||||||
|
--text-title-3-medium: 1.125rem;
|
||||||
|
--text-title-3-medium--line-height: 1.625rem; /* 26 per Figma */
|
||||||
|
--text-title-3-medium--letter-spacing: 0;
|
||||||
|
--text-title-3-medium--font-weight: 500;
|
||||||
|
--text-title-3-semibold: 1.125rem;
|
||||||
|
--text-title-3-semibold--line-height: 1.625rem;
|
||||||
|
--text-title-3-semibold--letter-spacing: 0;
|
||||||
|
--text-title-3-semibold--font-weight: 600;
|
||||||
|
--text-title-3-bold: 1.125rem;
|
||||||
|
--text-title-3-bold--line-height: 1.625rem;
|
||||||
|
--text-title-3-bold--letter-spacing: 0;
|
||||||
|
--text-title-3-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Headline — 16 / 22 ================================================ */
|
||||||
|
--text-headline-regular: 1rem;
|
||||||
|
--text-headline-regular--line-height: 1.375rem;
|
||||||
|
--text-headline-regular--letter-spacing: 0;
|
||||||
|
--text-headline-regular--font-weight: 400;
|
||||||
|
--text-headline-medium: 1rem;
|
||||||
|
--text-headline-medium--line-height: 1.375rem;
|
||||||
|
--text-headline-medium--letter-spacing: 0;
|
||||||
|
--text-headline-medium--font-weight: 500;
|
||||||
|
--text-headline-semibold: 1rem;
|
||||||
|
--text-headline-semibold--line-height: 1.375rem;
|
||||||
|
--text-headline-semibold--letter-spacing: 0;
|
||||||
|
--text-headline-semibold--font-weight: 600;
|
||||||
|
--text-headline-bold: 1rem;
|
||||||
|
--text-headline-bold--line-height: 1.375rem;
|
||||||
|
--text-headline-bold--letter-spacing: 0;
|
||||||
|
--text-headline-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Body — 14 / 20 ==================================================== */
|
||||||
|
--text-body-regular: 0.875rem;
|
||||||
|
--text-body-regular--line-height: 1.25rem;
|
||||||
|
--text-body-regular--letter-spacing: 0;
|
||||||
|
--text-body-regular--font-weight: 400;
|
||||||
|
--text-body-medium: 0.875rem;
|
||||||
|
--text-body-medium--line-height: 1.25rem;
|
||||||
|
--text-body-medium--letter-spacing: 0;
|
||||||
|
--text-body-medium--font-weight: 500;
|
||||||
|
--text-body-semibold: 0.875rem;
|
||||||
|
--text-body-semibold--line-height: 1.25rem;
|
||||||
|
--text-body-semibold--letter-spacing: 0;
|
||||||
|
--text-body-semibold--font-weight: 600;
|
||||||
|
--text-body-bold: 0.875rem;
|
||||||
|
--text-body-bold--line-height: 1.25rem;
|
||||||
|
--text-body-bold--letter-spacing: 0;
|
||||||
|
--text-body-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Body 2 — 13 / 18 ================================================== */
|
||||||
|
--text-body-2-regular: 0.8125rem;
|
||||||
|
--text-body-2-regular--line-height: 1.125rem;
|
||||||
|
--text-body-2-regular--letter-spacing: 0;
|
||||||
|
--text-body-2-regular--font-weight: 400;
|
||||||
|
--text-body-2-medium: 0.8125rem;
|
||||||
|
--text-body-2-medium--line-height: 1.125rem;
|
||||||
|
--text-body-2-medium--letter-spacing: 0;
|
||||||
|
--text-body-2-medium--font-weight: 500;
|
||||||
|
--text-body-2-semibold: 0.8125rem;
|
||||||
|
--text-body-2-semibold--line-height: 1.125rem;
|
||||||
|
--text-body-2-semibold--letter-spacing: 0;
|
||||||
|
--text-body-2-semibold--font-weight: 600;
|
||||||
|
--text-body-2-bold: 0.8125rem;
|
||||||
|
--text-body-2-bold--line-height: 1.125rem;
|
||||||
|
--text-body-2-bold--letter-spacing: 0;
|
||||||
|
--text-body-2-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Caption 1 — 12 / 16 (tracking 0.15) =============================== */
|
||||||
|
--text-caption-1-regular: 0.75rem;
|
||||||
|
--text-caption-1-regular--line-height: 1rem;
|
||||||
|
--text-caption-1-regular--letter-spacing: 0.15px;
|
||||||
|
--text-caption-1-regular--font-weight: 400;
|
||||||
|
--text-caption-1-medium: 0.75rem;
|
||||||
|
--text-caption-1-medium--line-height: 1rem;
|
||||||
|
--text-caption-1-medium--letter-spacing: 0.15px;
|
||||||
|
--text-caption-1-medium--font-weight: 500;
|
||||||
|
--text-caption-1-semibold: 0.75rem;
|
||||||
|
--text-caption-1-semibold--line-height: 1rem;
|
||||||
|
--text-caption-1-semibold--letter-spacing: 0.15px;
|
||||||
|
--text-caption-1-semibold--font-weight: 600;
|
||||||
|
--text-caption-1-bold: 0.75rem;
|
||||||
|
--text-caption-1-bold--line-height: 1rem;
|
||||||
|
--text-caption-1-bold--letter-spacing: 0.15px;
|
||||||
|
--text-caption-1-bold--font-weight: 700;
|
||||||
|
|
||||||
|
/* ===== Caption 2 — 11 / 15 (tracking 0.2) ================================ */
|
||||||
|
--text-caption-2-regular: 0.6875rem;
|
||||||
|
--text-caption-2-regular--line-height: 0.9375rem;
|
||||||
|
--text-caption-2-regular--letter-spacing: 0.2px;
|
||||||
|
--text-caption-2-regular--font-weight: 400;
|
||||||
|
--text-caption-2-medium: 0.6875rem;
|
||||||
|
--text-caption-2-medium--line-height: 0.9375rem;
|
||||||
|
--text-caption-2-medium--letter-spacing: 0.2px;
|
||||||
|
--text-caption-2-medium--font-weight: 500;
|
||||||
|
--text-caption-2-semibold: 0.6875rem;
|
||||||
|
--text-caption-2-semibold--line-height: 0.9375rem;
|
||||||
|
--text-caption-2-semibold--letter-spacing: 0.2px;
|
||||||
|
--text-caption-2-semibold--font-weight: 600;
|
||||||
|
--text-caption-2-bold: 0.6875rem;
|
||||||
|
--text-caption-2-bold--line-height: 0.9375rem;
|
||||||
|
--text-caption-2-bold--letter-spacing: 0.2px;
|
||||||
|
--text-caption-2-bold--font-weight: 700;
|
||||||
|
}
|
||||||
61
utils/cx.ts
Normal file
61
utils/cx.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { extendTailwindMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text-style classes from styles/typography.css.
|
||||||
|
*
|
||||||
|
* IMPORTANT: every text-* utility we define via @theme (e.g. `text-body-medium`,
|
||||||
|
* `text-title-1-semibold`) must be listed here. Otherwise tailwind-merge — which
|
||||||
|
* has no view of our Tailwind theme — treats them as text-color utilities and
|
||||||
|
* silently drops them when they appear in the same className as a real color
|
||||||
|
* (`text-foreground-full`, `text-text-primary`, etc).
|
||||||
|
*
|
||||||
|
* If you add or rename a text style in typography.css, mirror the change here.
|
||||||
|
*/
|
||||||
|
const TEXT_FAMILIES = [
|
||||||
|
"large-title",
|
||||||
|
"display-1",
|
||||||
|
"display-2",
|
||||||
|
"display-3",
|
||||||
|
"display-4",
|
||||||
|
"title-1",
|
||||||
|
"title-2",
|
||||||
|
"title-3",
|
||||||
|
"headline",
|
||||||
|
"body",
|
||||||
|
"body-2",
|
||||||
|
"caption-1",
|
||||||
|
"caption-2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TEXT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const;
|
||||||
|
|
||||||
|
const TEXT_STYLE_SUFFIXES = TEXT_FAMILIES.flatMap((family) =>
|
||||||
|
TEXT_WEIGHTS.map((weight) => `${family}-${weight}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const twMerge = extendTailwindMerge({
|
||||||
|
extend: {
|
||||||
|
classGroups: {
|
||||||
|
"font-size": [{ text: TEXT_STYLE_SUFFIXES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind classes safely. Last-write-wins on conflicting utilities.
|
||||||
|
*/
|
||||||
|
export const cx = twMerge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity helper that gives the Tailwind IntelliSense extension a hook for
|
||||||
|
* sorting classes inside style objects (the extension doesn't sort inside
|
||||||
|
* plain object literals otherwise).
|
||||||
|
*/
|
||||||
|
export function sortCx<
|
||||||
|
T extends Record<
|
||||||
|
string,
|
||||||
|
string | number | Record<string, string | number | Record<string, string | number>>
|
||||||
|
>,
|
||||||
|
>(classes: T): T {
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
18
utils/directional-icon.ts
Normal file
18
utils/directional-icon.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import {
|
||||||
|
RiArrowLeftLine,
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiSidebarFoldLine,
|
||||||
|
RiSidebarUnfoldLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
|
||||||
|
/** Navigation glyphs mirror; logos, media controls and trend arrows do not. */
|
||||||
|
const directionalIcons: ReadonlySet<unknown> = new Set([
|
||||||
|
RiArrowLeftLine, RiArrowLeftSLine, RiArrowRightLine, RiArrowRightSLine,
|
||||||
|
RiSidebarFoldLine, RiSidebarUnfoldLine,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function directionalIconClass(icon: unknown): string | undefined {
|
||||||
|
return directionalIcons.has(icon) ? "rtl:rotate-180" : undefined;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue