Drop .cursor rules and npm lockfile; standardize on Bun; remove v1 migration code
- Delete .cursor/rules/boardui.mdc (rules live in AGENTS.md) and package-lock.json; README now uses bun install/dev/test/build - Remove migrateV1Slots, the v1 localStorage key, the legacy "asian" cuisine expansion, the ownsPantryStaples fallback, and the pre-BoardUI theme-key carryover in the layout init script; drop their tests Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
df5dd3a70e
commit
2cd683e64a
7 changed files with 14 additions and 6904 deletions
|
|
@ -1,48 +0,0 @@
|
||||||
---
|
|
||||||
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`.
|
|
||||||
12
README.md
12
README.md
|
|
@ -7,14 +7,14 @@ not a misleading "per-serving" number.
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
bun install
|
||||||
npm run dev # http://localhost:3000 (landing) → /plan (planner)
|
bun dev # http://localhost:3000 (landing) → /plan (planner)
|
||||||
npm test # money math, state validation, meal slots (needs Bun: https://bun.sh)
|
bun test # money math, state validation, meal slots
|
||||||
```
|
```
|
||||||
|
|
||||||
Production: `npm run build && npm start`. Deploys to Vercel as-is (no env vars needed).
|
Production: `bun run build && bun start`. Deploys to Vercel as-is (no env vars needed).
|
||||||
|
|
||||||
Requires Node 20.9+ (Next.js 16).
|
Requires Bun (https://bun.sh); Next.js 16 also runs on Node 20.9+ if you prefer.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ The big library in `app/lib/recipes/extra/` uses a compact one-entry-per-recipe
|
||||||
```
|
```
|
||||||
|
|
||||||
- Each ingredient is `<catalog id> <amount> <unit>`; units: g, oz, lb, ml, tsp, tbsp, cup, each.
|
- Each ingredient is `<catalog id> <amount> <unit>`; units: g, oz, lb, ml, tsp, tbsp, cup, each.
|
||||||
- These all throw when the app loads (and so fail `npm run build`): unknown catalog ids, units that
|
- These all throw when the app loads (and so fail `bun run build`): unknown catalog ids, units that
|
||||||
can't convert to how the item is sold, unknown formats, and duplicate ids.
|
can't convert to how the item is sold, unknown formats, and duplicate ids.
|
||||||
- Cups/tbsp on an item sold by weight need `gramsPerMl` on that catalog item.
|
- Cups/tbsp on an item sold by weight need `gramsPerMl` on that catalog item.
|
||||||
- Add the recipe's steps to the matching file in `recipes/steps/`, keyed by its id. The app refuses to
|
- Add the recipe's steps to the matching file in `recipes/steps/`, keyed by its id. The app refuses to
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,8 @@ export const viewport: Viewport = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Runs before paint so the BoardUI `.dark` class is set without a flash. Carries over the
|
// Runs before paint so the BoardUI `.dark` class is set without a flash.
|
||||||
// pre-BoardUI "plan-my-meals:theme" choice once so returning users keep their theme.
|
const THEME_INIT_SCRIPT = `(function(){try{if(localStorage.getItem("boardui:theme")==="dark")document.documentElement.classList.add("dark");}catch(e){}})();`;
|
||||||
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 (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { MealPlanState } from "@/app/types";
|
import type { MealPlanState } from "@/app/types";
|
||||||
import { activeRecipeIds, mealKindFor, mealLabel, visibleMeals } from "./plan";
|
import { activeRecipeIds, mealKindFor, mealLabel, visibleMeals } from "./plan";
|
||||||
import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
|
import { DEFAULT_STATE } from "./sanitize";
|
||||||
import { RECIPES } from "./recipes";
|
import { RECIPES } from "./recipes";
|
||||||
import { planReducer } from "./usePlanState";
|
import { planReducer } from "./usePlanState";
|
||||||
|
|
||||||
|
|
@ -70,24 +70,3 @@ describe("Clear", () => {
|
||||||
expect(cleared.slots).toEqual({ "0-1": PANCAKES }); // breakfast was hidden, so untouched
|
expect(cleared.slots).toEqual({ "0-1": PANCAKES }); // breakfast was hidden, so untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("migrating plans saved before meal numbers were fixed", () => {
|
|
||||||
test("2 meals/day: old 1/2 (Lunch/Dinner) become 2/3", () => {
|
|
||||||
const old = { mealsPerDay: 2, slots: { "0-1": SANDWICH, "0-2": STEW } };
|
|
||||||
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual({ "0-2": SANDWICH, "0-3": STEW });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("1 meal/day: old 1 (Dinner) becomes 3, stale hidden keys are dropped", () => {
|
|
||||||
const old = { mealsPerDay: 1, slots: { "0-1": STEW, "0-2": SANDWICH } };
|
|
||||||
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual({ "0-3": STEW });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("3 meals/day: nothing moves", () => {
|
|
||||||
const old = { mealsPerDay: 3, slots: { "0-1": PANCAKES, "0-3": STEW } };
|
|
||||||
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual(old.slots);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("migrated plans still pass sanitizeState", () => {
|
|
||||||
expect(() => sanitizeState(migrateV1Slots({ mealsPerDay: 2, slots: { "0-1": "x" } }))).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import { RECIPES_BY_ID } from "./recipes";
|
||||||
//
|
//
|
||||||
// Used by:
|
// Used by:
|
||||||
// - the Server Action (input from the network is untrusted)
|
// - the Server Action (input from the network is untrusted)
|
||||||
// - the localStorage loader (old versions of the app saved older shapes)
|
// - the localStorage loader (saved data is untrusted too)
|
||||||
//
|
|
||||||
// Old-shape migrations live here too, so components never see legacy data.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const DEFAULT_STATE: MealPlanState = {
|
export const DEFAULT_STATE: MealPlanState = {
|
||||||
|
|
@ -26,11 +24,6 @@ export const DEFAULT_STATE: MealPlanState = {
|
||||||
slots: {},
|
slots: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** v1 had a single "asian" cuisine; it was split into four. */
|
|
||||||
const LEGACY_CUISINES: Record<string, Cuisine[]> = {
|
|
||||||
asian: ["chinese", "japanese", "korean", "thai"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_APPLIANCES = new Set<string>(APPLIANCES.map((a) => a.id));
|
const VALID_APPLIANCES = new Set<string>(APPLIANCES.map((a) => a.id));
|
||||||
const VALID_CUISINES = new Set<string>(CUISINES.map((c) => c.id));
|
const VALID_CUISINES = new Set<string>(CUISINES.map((c) => c.id));
|
||||||
const VALID_STYLES = new Set<string>(STYLES.map((s) => s.id));
|
const VALID_STYLES = new Set<string>(STYLES.map((s) => s.id));
|
||||||
|
|
@ -53,17 +46,10 @@ export function sanitizeState(input: unknown): MealPlanState {
|
||||||
if (/^[0-6]-[1-3]$/.test(key) && typeof recipeId === "string" && RECIPES_BY_ID.has(recipeId)) slots[key] = recipeId;
|
if (/^[0-6]-[1-3]$/.test(key) && typeof recipeId === "string" && RECIPES_BY_ID.has(recipeId)) slots[key] = recipeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cuisines, with the legacy "asian" expanded.
|
// Items.
|
||||||
const cuisinesIn = Array.isArray(raw.preferredCuisines) ? raw.preferredCuisines.flatMap((c) => LEGACY_CUISINES[c as string] ?? [c]) : [];
|
|
||||||
|
|
||||||
// Items. v1 had `ownsPantryStaples: boolean` instead of an on-hand list.
|
|
||||||
const isItem = (id: string) => CATALOG_BY_ID.has(id);
|
const isItem = (id: string) => CATALOG_BY_ID.has(id);
|
||||||
const avoidedItems = onlyValid<ItemId>(raw.avoidedItems, isItem);
|
const avoidedItems = onlyValid<ItemId>(raw.avoidedItems, isItem);
|
||||||
const onHandItems = Array.isArray(raw.onHandItems)
|
const onHandItems = Array.isArray(raw.onHandItems) ? onlyValid<ItemId>(raw.onHandItems, isItem) : PANTRY_STAPLE_IDS;
|
||||||
? onlyValid<ItemId>(raw.onHandItems, isItem)
|
|
||||||
: raw.ownsPantryStaples === false
|
|
||||||
? []
|
|
||||||
: PANTRY_STAPLE_IDS;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetBudget: clamp(raw.targetBudget, 10, 1000, DEFAULT_STATE.targetBudget),
|
targetBudget: clamp(raw.targetBudget, 10, 1000, DEFAULT_STATE.targetBudget),
|
||||||
|
|
@ -73,7 +59,7 @@ export function sanitizeState(input: unknown): MealPlanState {
|
||||||
selectedAppliances: Array.isArray(raw.selectedAppliances)
|
selectedAppliances: Array.isArray(raw.selectedAppliances)
|
||||||
? onlyValid<Appliance>(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v))
|
? onlyValid<Appliance>(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v))
|
||||||
: DEFAULT_STATE.selectedAppliances,
|
: DEFAULT_STATE.selectedAppliances,
|
||||||
preferredCuisines: onlyValid<Cuisine>(cuisinesIn, (v) => VALID_CUISINES.has(v)),
|
preferredCuisines: onlyValid<Cuisine>(raw.preferredCuisines, (v) => VALID_CUISINES.has(v)),
|
||||||
preferredStyles: onlyValid<CookingStyle>(raw.preferredStyles, (v) => VALID_STYLES.has(v)),
|
preferredStyles: onlyValid<CookingStyle>(raw.preferredStyles, (v) => VALID_STYLES.has(v)),
|
||||||
avoidedItems,
|
avoidedItems,
|
||||||
// An item can't be both avoided and on hand; "avoid" wins.
|
// An item can't be both avoided and on hand; "avoid" wins.
|
||||||
|
|
@ -81,26 +67,3 @@ export function sanitizeState(input: unknown): MealPlanState {
|
||||||
slots,
|
slots,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Plans saved before meal numbers were fixed counted from the first VISIBLE
|
|
||||||
* meal: with 2 meals a day, "1" meant Lunch. Today "1" is always Breakfast,
|
|
||||||
* so shift each meal up by the number of meals that were hidden in front of it.
|
|
||||||
* 2 meals/day: 1 (Lunch) → 2, 2 (Dinner) → 3
|
|
||||||
* 1 meal/day: 1 (Dinner) → 3
|
|
||||||
* Anything that lands past Dinner was already hidden and is dropped.
|
|
||||||
*/
|
|
||||||
export function migrateV1Slots(input: unknown): unknown {
|
|
||||||
if (typeof input !== "object" || input === null) return input;
|
|
||||||
const raw = input as Record<string, unknown>;
|
|
||||||
const mealsPerDay = Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay));
|
|
||||||
const offset = 3 - mealsPerDay;
|
|
||||||
|
|
||||||
const slots: Record<string, unknown> = {};
|
|
||||||
for (const [key, recipeId] of Object.entries((raw.slots as Record<string, unknown>) ?? {})) {
|
|
||||||
const [day, meal] = key.split("-").map(Number);
|
|
||||||
const fixedMeal = meal + offset;
|
|
||||||
if (fixedMeal >= 1 && fixedMeal <= 3) slots[`${day}-${fixedMeal}`] = recipeId;
|
|
||||||
}
|
|
||||||
return { ...raw, slots };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useReducer, useState } from "react";
|
import { useEffect, useReducer, useState } from "react";
|
||||||
import type { Appliance, CookingStyle, Cuisine, ItemId, ItemPreference, MealPlanState } from "@/app/types";
|
import type { Appliance, CookingStyle, Cuisine, ItemId, ItemPreference, MealPlanState } from "@/app/types";
|
||||||
import { toggle, withoutVisibleSlots } from "./plan";
|
import { toggle, withoutVisibleSlots } from "./plan";
|
||||||
import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
|
import { DEFAULT_STATE, sanitizeState } from "./sanitize";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// All plan state lives in one reducer, persisted to localStorage.
|
// All plan state lives in one reducer, persisted to localStorage.
|
||||||
|
|
@ -13,10 +13,7 @@ import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
|
||||||
// than a dozen useStates.
|
// than a dozen useStates.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// v2: meal numbers became fixed (1 = Breakfast, 2 = Lunch, 3 = Dinner).
|
|
||||||
// v1 numbered meals from the first VISIBLE one, so it's converted once on load.
|
|
||||||
const STORAGE_KEY = "budget-meal-planner:v2";
|
const STORAGE_KEY = "budget-meal-planner:v2";
|
||||||
const LEGACY_STORAGE_KEY = "budget-meal-planner:v1";
|
|
||||||
|
|
||||||
export type PlanAction =
|
export type PlanAction =
|
||||||
| { type: "setBudget"; value: number }
|
| { type: "setBudget"; value: number }
|
||||||
|
|
@ -90,12 +87,6 @@ function loadSavedState(): MealPlanState {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) return sanitizeState(JSON.parse(raw));
|
if (raw) return sanitizeState(JSON.parse(raw));
|
||||||
|
|
||||||
const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacy) {
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY); // the v2 copy is written on first render
|
|
||||||
return sanitizeState(migrateV1Slots(JSON.parse(legacy)));
|
|
||||||
}
|
|
||||||
return DEFAULT_STATE;
|
return DEFAULT_STATE;
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_STATE; // corrupt JSON or blocked storage
|
return DEFAULT_STATE; // corrupt JSON or blocked storage
|
||||||
|
|
|
||||||
6774
package-lock.json
generated
6774
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue