diff --git a/apps/web/package.json b/apps/web/package.json
index cf78bf5..eb06d08 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "web",
- "version": "0.0.0",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6",
+ "lucide-react": "^1.32.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
diff --git a/apps/web/src/components/ui/Checkbox.tsx b/apps/web/src/components/ui/Checkbox.tsx
new file mode 100644
index 0000000..fbd6d9f
--- /dev/null
+++ b/apps/web/src/components/ui/Checkbox.tsx
@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+
+/**
+ * The app-wide "selectable card" checkbox — see `global.scss`'s
+ * `label:has(> input[type="checkbox"])` rule for the actual look (hidden
+ * native input, a `.check-mark` that scales in, `is-selected` driving the
+ * tinted/bordered state). Factors out the JSX triplet (`label` → hidden
+ * `input` → `span.check-mark` → label text) that used to be duplicated
+ * across `AllergySelect`, `DietTagSelect`, `IngredientPicker`'s display
+ * menu, and `UserPreferencesPage`'s theme picker (see {@link RadioOption}
+ * for its `type="radio"` sibling) — one place to get the markup/a11y right
+ * instead of four.
+ *
+ * `is-selected` is applied in JS from the same `checked` boolean the caller
+ * already has, not derived via a CSS `:has(:checked)` chain — that turned
+ * out unreliable across browsers (see the callers this replaces for the
+ * original note).
+ *
+ * `className` is for the *container* layout only (grid item, flex-wrap
+ * chip, stacked list…) — the control's own look never varies, so there's
+ * no `variant` prop here.
+ */
+export function CheckboxOption({
+ checked,
+ onChange,
+ children,
+ className,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/Radio.tsx b/apps/web/src/components/ui/Radio.tsx
new file mode 100644
index 0000000..1a3e15f
--- /dev/null
+++ b/apps/web/src/components/ui/Radio.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+
+/**
+ * The `type="radio"` sibling of {@link CheckboxOption} — same "selectable
+ * card" markup/look (see `global.scss`'s `label:has(> input[...])` rule,
+ * shared by both), just a native radio input under the hood so a group of
+ * `RadioOption`s sharing `name` behaves as mutually exclusive (see
+ * `UserPreferencesPage`'s theme picker, the one caller so far).
+ */
+export function RadioOption({
+ name,
+ value,
+ checked,
+ onChange,
+ children,
+ className,
+}: {
+ name: string;
+ value: T;
+ checked: boolean;
+ onChange: (value: T) => void;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/profile/AllergySelect.tsx b/apps/web/src/features/profile/AllergySelect.tsx
index 824b321..1782279 100644
--- a/apps/web/src/features/profile/AllergySelect.tsx
+++ b/apps/web/src/features/profile/AllergySelect.tsx
@@ -1,4 +1,5 @@
import type { AllergyView } from "@batch-cooking/shared";
+import { CheckboxOption } from "../../components/ui/Checkbox";
import "./profile-forms.scss";
interface AllergySelectProps {
@@ -38,19 +39,14 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel
{allergies.map((allergy) => {
const checked = value.includes(allergy.id);
return (
-
+
);
})}
diff --git a/apps/web/src/features/recipes/DietTagSelect.tsx b/apps/web/src/features/recipes/DietTagSelect.tsx
index febbfff..6413e79 100644
--- a/apps/web/src/features/recipes/DietTagSelect.tsx
+++ b/apps/web/src/features/recipes/DietTagSelect.tsx
@@ -1,5 +1,6 @@
import type { DietView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
+import { CheckboxOption } from "../../components/ui/Checkbox";
import "./recipes.scss";
/**
@@ -29,11 +30,9 @@ export function DietTagSelect({
{diets.map((diet) => {
const checked = value.includes(diet.id);
return (
-
+
);
})}
diff --git a/apps/web/src/features/recipes/FavoriteStarButton.tsx b/apps/web/src/features/recipes/FavoriteStarButton.tsx
index 9b9912f..638fc1d 100644
--- a/apps/web/src/features/recipes/FavoriteStarButton.tsx
+++ b/apps/web/src/features/recipes/FavoriteStarButton.tsx
@@ -52,7 +52,7 @@ export function FavoriteStarButton({
disabled={isSaving}
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
>
-
+
);
}
diff --git a/apps/web/src/features/recipes/IngredientPicker.tsx b/apps/web/src/features/recipes/IngredientPicker.tsx
index 79c18fc..0fae139 100644
--- a/apps/web/src/features/recipes/IngredientPicker.tsx
+++ b/apps/web/src/features/recipes/IngredientPicker.tsx
@@ -7,6 +7,7 @@ import {
} from "@batch-cooking/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { CheckboxOption } from "../../components/ui/Checkbox";
import { SettingsIcon } from "../../layouts/nav-icons";
import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges";
@@ -101,28 +102,16 @@ export function IngredientPicker({
aria-expanded={isDisplayMenuOpen}
title={t("recipes.form.displayOptions")}
>
-
+
{isDisplayMenuOpen && (
-
-
+
)}
diff --git a/apps/web/src/features/recipes/RecipeTabs.tsx b/apps/web/src/features/recipes/RecipeTabs.tsx
index 0a9788a..6826eba 100644
--- a/apps/web/src/features/recipes/RecipeTabs.tsx
+++ b/apps/web/src/features/recipes/RecipeTabs.tsx
@@ -1,10 +1,11 @@
import type { RecipeTab } from "@batch-cooking/shared";
+import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
import "./recipes.scss";
/** Every functional tab, in display order, with its icon — reuses `AccountIcon`/`HouseholdIcon` from the sidebar's own icon set (see nav-icons.tsx) rather than a second "person"/"house" glyph. */
-const TABS: Array<{ value: RecipeTab; Icon: () => JSX.Element }> = [
+const TABS: Array<{ value: RecipeTab; Icon: LucideIcon }> = [
{ value: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon },
@@ -37,7 +38,7 @@ export function RecipeTabs({
className={`recipe-tabs__tab${value === active ? " active" : ""}`}
onClick={() => onChange(value)}
>
-
+
{t(`recipes.tabs.${value}`)}
))}
diff --git a/apps/web/src/layouts/AppLayout.scss b/apps/web/src/layouts/AppLayout.scss
index 0468431..a58c09b 100644
--- a/apps/web/src/layouts/AppLayout.scss
+++ b/apps/web/src/layouts/AppLayout.scss
@@ -237,6 +237,19 @@
}
}
+ // App version — a quiet diagnostic footnote below the account menu, not
+ // an interactive element (hence `aria-hidden` on the `
` in
+ // AppLayout.tsx). Hidden whenever space is at a premium — the icon-only
+ // rail and the mobile horizontal bar (see the `.collapsed` block and the
+ // `@media (max-width: 640px)` block below).
+ &__version {
+ margin: var(--space-xs) 0 0;
+ padding: 0 var(--space-sm);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ text-align: center;
+ }
+
// --- Collapsed (icon-only rail) state -----------------------------------
// A single class toggle on the root element — every nested rule below
// just hides labels/chevrons and re-centers icons via CSS, no child
@@ -282,6 +295,10 @@
display: none;
}
+ .app-sidebar__version {
+ display: none;
+ }
+
// The popover would otherwise shrink to the icon rail's own width,
// squashing "Mon compte"/"Se déconnecter" — give it a normal,
// comfortable width instead, still anchored to the rail's left edge.
@@ -342,6 +359,11 @@
display: none;
}
+ // No room for this on a horizontal bar either.
+ &__version {
+ display: none;
+ }
+
&__nav {
flex: 1 1 auto;
min-width: 0;
diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx
index 8012f9a..664d5ea 100644
--- a/apps/web/src/layouts/AppLayout.tsx
+++ b/apps/web/src/layouts/AppLayout.tsx
@@ -85,7 +85,7 @@ export function AppLayout() {
onClick={toggleCollapsed}
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
>
-
+
@@ -102,7 +102,7 @@ export function AppLayout() {
className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.nav.${key}`)}
>
-
+ {t(`layout.nav.${key}`)}
))}
@@ -110,6 +110,9 @@ export function AppLayout() {
+
+ v{__APP_VERSION__}
+
@@ -146,7 +149,7 @@ function SettingsMenu() {
title={t("layout.settings.toggle")}
>
-
+ {t("layout.settings.toggle")}
@@ -163,7 +166,7 @@ function SettingsMenu() {
className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.settings.nav.${key}`)}
>
-
+ {t(`layout.settings.nav.${key}`)}
))}
diff --git a/apps/web/src/layouts/nav-icons.tsx b/apps/web/src/layouts/nav-icons.tsx
index 90adceb..84d62b3 100644
--- a/apps/web/src/layouts/nav-icons.tsx
+++ b/apps/web/src/layouts/nav-icons.tsx
@@ -1,130 +1,27 @@
-import type { ReactNode } from "react";
-
-// Small, hand-drawn line-icon set for the sidebar nav (24×24 viewBox,
-// matches the reviewed mockup — see the plan/PR description) rather than
-// pulling in an icon library for a handful of glyphs. Sized entirely via
-// CSS (`.app-sidebar__nav svg` etc., see AppLayout.scss) — no width/height
-// attribute here, so the same markup works at any size the caller picks.
-// `aria-hidden` on every icon: each one is always paired with visible text
-// (the nav label, or a `title` tooltip when collapsed) that already
-// conveys the meaning — the icon itself is decorative.
-
-function Icon({ children }: { children: ReactNode }) {
- return (
-
- );
-}
-
-export function PlanningIcon() {
- return (
-
-
-
-
- );
-}
-
-export function RecipesIcon() {
- return (
-
-
-
-
- );
-}
-
-export function ShoppingListIcon() {
- return (
-
-
-
-
-
- );
-}
-
-export function SettingsIcon() {
- return (
-
-
-
-
- );
-}
-
-export function AccountIcon() {
- return (
-
-
-
-
- );
-}
-
-export function DietPreferencesIcon() {
- return (
-
-
-
-
- );
-}
-
-export function HouseholdIcon() {
- return (
-
-
-
-
- );
-}
-
-export function UserPreferencesIcon() {
- return (
-
-
-
-
-
-
-
- );
-}
-
-export function ChevronLeftIcon() {
- return (
-
-
-
- );
-}
-
-/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
-export function FavoriteIcon() {
- return (
-
-
-
- );
-}
-
-/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
-export function PublicIcon() {
- return (
-
-
-
-
-
- );
-}
+// Sidebar/nav icon set — thin, named re-exports of lucide-react glyphs
+// rather than importing `lucide-react` directly in every consumer. Keeps a
+// single place documenting "which glyph stands for which app concept"
+// (the mapping itself, decided once, isn't obvious from a bare `Settings`
+// or `Home` import) and keeps consumer diffs small if a glyph ever needs
+// to change. Previously a hand-drawn custom SVG set (24×24, stroke-based,
+// no fill) — lucide-react uses the same stroke-based "line icon" language
+// (default `strokeWidth={2}`, round caps/joins) so the switch is visually
+// a no-op, just swaps who draws the paths.
+//
+// Every one of these is always paired with visible text (the nav label, or
+// a `title` tooltip when the sidebar is collapsed) — purely decorative, so
+// every consumer passes `aria-hidden="true"` itself (unlike the old custom
+// `Icon` wrapper, lucide doesn't set this by default).
+export {
+ Calendar as PlanningIcon,
+ BookOpen as RecipesIcon,
+ ShoppingCart as ShoppingListIcon,
+ Settings as SettingsIcon,
+ User as AccountIcon,
+ Leaf as DietPreferencesIcon,
+ Home as HouseholdIcon,
+ Palette as UserPreferencesIcon,
+ ChevronLeft as ChevronLeftIcon,
+ Star as FavoriteIcon,
+ Globe as PublicIcon,
+} from "lucide-react";
diff --git a/apps/web/src/pages/settings/UserPreferencesPage.tsx b/apps/web/src/pages/settings/UserPreferencesPage.tsx
index fe50380..9ec0c27 100644
--- a/apps/web/src/pages/settings/UserPreferencesPage.tsx
+++ b/apps/web/src/pages/settings/UserPreferencesPage.tsx
@@ -2,6 +2,7 @@ import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooki
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError } from "../../api/client";
+import { RadioOption } from "../../components/ui/Radio";
import { useTheme } from "../../features/theme/ThemeContext";
import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss";
@@ -46,23 +47,16 @@ export function UserPreferencesPage() {
{THEME_PREFERENCES.map((option) => {
const checked = theme === option;
return (
-
+
);
})}
diff --git a/apps/web/src/pages/settings/settings-pages.scss b/apps/web/src/pages/settings/settings-pages.scss
index 9717d4d..0dee2b4 100644
--- a/apps/web/src/pages/settings/settings-pages.scss
+++ b/apps/web/src/pages/settings/settings-pages.scss
@@ -127,7 +127,8 @@
// visually distinct, consistent "danger" treatment wherever they appear.
.settings-page__danger-zone {
margin-top: var(--space-lg);
- border-color: var(--color-error);
+ border: 1.5px solid var(--color-error);
+ background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
}
.settings-page__danger-button {
diff --git a/apps/web/src/styles/global.scss b/apps/web/src/styles/global.scss
index 18ada73..dffae1e 100644
--- a/apps/web/src/styles/global.scss
+++ b/apps/web/src/styles/global.scss
@@ -60,15 +60,16 @@ h1 {
// `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark
-// fading in on the trailing edge.
+// fading in on the leading edge.
//
// The base (unselected) look below is detected structurally with `:has()`
// — safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected`
-// class each caller (AllergySelect.tsx, UserPreferencesPage.tsx) toggles
-// in JS from the same boolean it already passes to `checked` — chaining a
-// second `:has(:checked)` to react to that live state turned out to be
-// unreliable across browsers, so this only needs one always-true `:has()`.
+// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
+// in JS from the same boolean their caller already passes to `checked` —
+// chaining a second `:has(:checked)` to react to that live state turned
+// out to be unreliable across browsers, so this only needs one
+// always-true `:has()`.
//
// Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling
@@ -126,15 +127,15 @@ input[type="radio"] {
opacity: 0;
}
-// The checkmark — a real element (see AllergySelect.tsx /
-// UserPreferencesPage.tsx) shown via the same `is-selected` class as the
-// label's own look above, not a separate CSS-only trigger. Scaled in from
-// nothing so toggling has a bit of motion. Same mark for both checkbox and
-// radio: one consistent "selected" language app-wide rather than a
-// checkmark here and a dot there.
+// The checkmark — a real element (see components/ui/Checkbox.tsx /
+// Radio.tsx) shown via the same `is-selected` class as the label's own
+// look above, not a separate CSS-only trigger. Scaled in from nothing so
+// toggling has a bit of motion. Same mark for both checkbox and radio: one
+// consistent "selected" language app-wide rather than a checkmark here and
+// a dot there. Sits first in the row (before the label text, per DOM
+// order) — a classic "control on the left" layout rather than trailing.
.check-mark {
flex: none;
- margin-left: auto;
width: 0.9rem;
height: 0.9rem;
background: var(--color-primary);
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..132c95d
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1,5 @@
+///
+
+// Injected by `define` in vite.config.ts, sourced from package.json's
+// version field — see AppLayout.tsx for where it's rendered.
+declare const __APP_VERSION__: string;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index a4886c6..fbd5fdc 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,6 +1,11 @@
+import { readFileSync } from "node:fs";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
+// Read once at config-eval time — cheaper than a plugin hook, and this file
+// only ever runs in Node (dev server / build), never bundled into the app.
+const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
+
export default defineConfig({
plugins: [react()],
css: {
@@ -13,4 +18,10 @@ export default defineConfig({
},
},
},
+ // Exposes package.json's version as a compile-time constant — see
+ // src/vite-env.d.ts for the matching ambient declaration, and
+ // AppLayout.tsx for where it's rendered.
+ define: {
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a842ad5..88b6a91 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,6 +99,9 @@ importers:
i18next:
specifier: ^26.3.6
version: 26.3.6(typescript@5.9.3)
+ lucide-react:
+ specifier: ^1.32.0
+ version: 1.32.0(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -2098,6 +2101,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz}
+ lucide-react@1.32.0:
+ resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==, tarball: https://registry.npmjs.org/lucide-react/-/lucide-react-1.32.0.tgz}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==, tarball: https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz}
engines: {node: '>=12'}
@@ -4764,6 +4772,10 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@1.32.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
luxon@3.7.2: {}
make-dir@3.1.0: