chore(web): session de polish global — version, checkbox, danger zone, icônes

- Affiche le numéro de version (package.json, injecté via Vite) en bas de
  la sidebar, masqué en mode collapse et en mobile.
- Factorise les checkbox/radio dupliqués (AllergySelect, DietTagSelect,
  IngredientPicker, UserPreferencesPage) en composants partagés
  CheckboxOption/RadioOption (components/ui/), et inverse le layout pour
  que la case soit à gauche du label.
- Teinte la "zone de danger" de suppression de compte en rouge (fond +
  bordure), pas seulement le bouton.
- Migre les icônes de navigation générale vers lucide-react (nav-icons.tsx
  devient un fichier de ré-export) ; les pictogrammes d'ingrédients métier
  restent en SVG custom (pas d'équivalents fins côté lucide).

Vérifié : pnpm build, pnpm lint, pnpm --filter web e2e (43/43), et
vérification visuelle manuelle (sidebar desktop/collapsed/mobile, light/dark).
This commit is contained in:
Nicolas 2026-08-18 14:39:06 +02:00
parent c38097f522
commit 66f2a36b8c
17 changed files with 207 additions and 196 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.0.0", "version": "0.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -16,6 +16,7 @@
"@batch-cooking/date-tools": "workspace:*", "@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*", "@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.32.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.11", "react-i18next": "^17.0.11",

View file

@ -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 (
<label className={[className, checked && "is-selected"].filter(Boolean).join(" ")}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="check-mark" aria-hidden="true" />
{children}
</label>
);
}

View file

@ -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<T extends string>({
name,
value,
checked,
onChange,
children,
className,
}: {
name: string;
value: T;
checked: boolean;
onChange: (value: T) => void;
children: ReactNode;
className?: string;
}) {
return (
<label className={[className, checked && "is-selected"].filter(Boolean).join(" ")}>
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => onChange(value)}
/>
<span className="check-mark" aria-hidden="true" />
{children}
</label>
);
}

View file

@ -1,4 +1,5 @@
import type { AllergyView } from "@batch-cooking/shared"; import type { AllergyView } from "@batch-cooking/shared";
import { CheckboxOption } from "../../components/ui/Checkbox";
import "./profile-forms.scss"; import "./profile-forms.scss";
interface AllergySelectProps { interface AllergySelectProps {
@ -38,19 +39,14 @@ export function AllergySelect({ legend, allergies, value, onChange }: AllergySel
{allergies.map((allergy) => { {allergies.map((allergy) => {
const checked = value.includes(allergy.id); const checked = value.includes(allergy.id);
return ( return (
<label <CheckboxOption
key={allergy.id} key={allergy.id}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the checked={checked}
// selected look — chaining `:has(...):has(:checked)` to react to onChange={() => toggle(allergy.id)}
// a *state* change (rather than a DOM mutation) turned out to be className="allergy-select__option"
// unreliable, so this stays a plain, always-correct React class
// instead of relying on CSS to derive it.
className={`allergy-select__option${checked ? " is-selected" : ""}`}
> >
<input type="checkbox" checked={checked} onChange={() => toggle(allergy.id)} />
<span className="check-mark" aria-hidden="true" />
{allergy.name} {allergy.name}
</label> </CheckboxOption>
); );
})} })}
</fieldset> </fieldset>

View file

@ -1,5 +1,6 @@
import type { DietView } from "@batch-cooking/shared"; import type { DietView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../components/ui/Checkbox";
import "./recipes.scss"; import "./recipes.scss";
/** /**
@ -29,11 +30,9 @@ export function DietTagSelect({
{diets.map((diet) => { {diets.map((diet) => {
const checked = value.includes(diet.id); const checked = value.includes(diet.id);
return ( return (
<label key={diet.id} className={checked ? "is-selected" : undefined}> <CheckboxOption key={diet.id} checked={checked} onChange={() => toggle(diet.id)}>
<input type="checkbox" checked={checked} onChange={() => toggle(diet.id)} />
<span className="check-mark" aria-hidden="true" />
{diet.name} {diet.name}
</label> </CheckboxOption>
); );
})} })}
</fieldset> </fieldset>

View file

@ -52,7 +52,7 @@ export function FavoriteStarButton({
disabled={isSaving} disabled={isSaving}
title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")} title={t(isFavorite ? "recipes.detail.unfavorite" : "recipes.detail.favorite")}
> >
<FavoriteIcon /> <FavoriteIcon aria-hidden="true" />
</button> </button>
); );
} }

View file

@ -7,6 +7,7 @@ import {
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../components/ui/Checkbox";
import { SettingsIcon } from "../../layouts/nav-icons"; import { SettingsIcon } from "../../layouts/nav-icons";
import { AllergenBadges } from "./AllergenBadges"; import { AllergenBadges } from "./AllergenBadges";
import { DietBadges } from "./DietBadges"; import { DietBadges } from "./DietBadges";
@ -101,28 +102,16 @@ export function IngredientPicker({
aria-expanded={isDisplayMenuOpen} aria-expanded={isDisplayMenuOpen}
title={t("recipes.form.displayOptions")} title={t("recipes.form.displayOptions")}
> >
<SettingsIcon /> <SettingsIcon aria-hidden="true" />
</button> </button>
{isDisplayMenuOpen && ( {isDisplayMenuOpen && (
<div className="ingredient-picker__display-menu"> <div className="ingredient-picker__display-menu">
<label className={showDiets ? "is-selected" : ""}> <CheckboxOption checked={showDiets} onChange={setShowDiets}>
<input
type="checkbox"
checked={showDiets}
onChange={(e) => setShowDiets(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
{t("recipes.form.showDietsLabel")} {t("recipes.form.showDietsLabel")}
</label> </CheckboxOption>
<label className={showAllergens ? "is-selected" : ""}> <CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
<input
type="checkbox"
checked={showAllergens}
onChange={(e) => setShowAllergens(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
{t("recipes.form.showAllergensLabel")} {t("recipes.form.showAllergensLabel")}
</label> </CheckboxOption>
</div> </div>
)} )}
</div> </div>

View file

@ -1,10 +1,11 @@
import type { RecipeTab } from "@batch-cooking/shared"; import type { RecipeTab } from "@batch-cooking/shared";
import type { LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons"; import { AccountIcon, FavoriteIcon, HouseholdIcon, PublicIcon } from "../../layouts/nav-icons";
import "./recipes.scss"; 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. */ /** 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: "favoris", Icon: FavoriteIcon },
{ value: "perso", Icon: AccountIcon }, { value: "perso", Icon: AccountIcon },
{ value: "foyer", Icon: HouseholdIcon }, { value: "foyer", Icon: HouseholdIcon },
@ -37,7 +38,7 @@ export function RecipeTabs({
className={`recipe-tabs__tab${value === active ? " active" : ""}`} className={`recipe-tabs__tab${value === active ? " active" : ""}`}
onClick={() => onChange(value)} onClick={() => onChange(value)}
> >
<Icon /> <Icon aria-hidden="true" />
{t(`recipes.tabs.${value}`)} {t(`recipes.tabs.${value}`)}
</button> </button>
))} ))}

View file

@ -237,6 +237,19 @@
} }
} }
// App version a quiet diagnostic footnote below the account menu, not
// an interactive element (hence `aria-hidden` on the `<p>` 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 ----------------------------------- // --- Collapsed (icon-only rail) state -----------------------------------
// A single class toggle on the root element every nested rule below // A single class toggle on the root element every nested rule below
// just hides labels/chevrons and re-centers icons via CSS, no child // just hides labels/chevrons and re-centers icons via CSS, no child
@ -282,6 +295,10 @@
display: none; display: none;
} }
.app-sidebar__version {
display: none;
}
// The popover would otherwise shrink to the icon rail's own width, // The popover would otherwise shrink to the icon rail's own width,
// squashing "Mon compte"/"Se déconnecter" give it a normal, // squashing "Mon compte"/"Se déconnecter" give it a normal,
// comfortable width instead, still anchored to the rail's left edge. // comfortable width instead, still anchored to the rail's left edge.
@ -342,6 +359,11 @@
display: none; display: none;
} }
// No room for this on a horizontal bar either.
&__version {
display: none;
}
&__nav { &__nav {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;

View file

@ -85,7 +85,7 @@ export function AppLayout() {
onClick={toggleCollapsed} onClick={toggleCollapsed}
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")} title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
> >
<ChevronLeftIcon /> <ChevronLeftIcon aria-hidden="true" />
</button> </button>
</div> </div>
@ -102,7 +102,7 @@ export function AppLayout() {
className={({ isActive }) => (isActive ? "active" : undefined)} className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.nav.${key}`)} title={t(`layout.nav.${key}`)}
> >
<Icon /> <Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span> <span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink> </NavLink>
))} ))}
@ -110,6 +110,9 @@ export function AppLayout() {
<SettingsMenu /> <SettingsMenu />
<AccountMenu /> <AccountMenu />
<p className="app-sidebar__version" aria-hidden="true">
v{__APP_VERSION__}
</p>
</aside> </aside>
<main className="app-content"> <main className="app-content">
@ -146,7 +149,7 @@ function SettingsMenu() {
title={t("layout.settings.toggle")} title={t("layout.settings.toggle")}
> >
<span className="app-sidebar__settings-toggle-left"> <span className="app-sidebar__settings-toggle-left">
<SettingsIcon /> <SettingsIcon aria-hidden="true" />
<span className="label">{t("layout.settings.toggle")}</span> <span className="label">{t("layout.settings.toggle")}</span>
</span> </span>
<span className="chevron" aria-hidden="true"> <span className="chevron" aria-hidden="true">
@ -163,7 +166,7 @@ function SettingsMenu() {
className={({ isActive }) => (isActive ? "active" : undefined)} className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.settings.nav.${key}`)} title={t(`layout.settings.nav.${key}`)}
> >
<Icon /> <Icon aria-hidden="true" />
<span className="label">{t(`layout.settings.nav.${key}`)}</span> <span className="label">{t(`layout.settings.nav.${key}`)}</span>
</NavLink> </NavLink>
))} ))}

View file

@ -1,130 +1,27 @@
import type { ReactNode } from "react"; // Sidebar/nav icon set — thin, named re-exports of lucide-react glyphs
// rather than importing `lucide-react` directly in every consumer. Keeps a
// Small, hand-drawn line-icon set for the sidebar nav (24×24 viewBox, // single place documenting "which glyph stands for which app concept"
// matches the reviewed mockup — see the plan/PR description) rather than // (the mapping itself, decided once, isn't obvious from a bare `Settings`
// pulling in an icon library for a handful of glyphs. Sized entirely via // or `Home` import) and keeps consumer diffs small if a glyph ever needs
// CSS (`.app-sidebar__nav svg` etc., see AppLayout.scss) — no width/height // to change. Previously a hand-drawn custom SVG set (24×24, stroke-based,
// attribute here, so the same markup works at any size the caller picks. // no fill) — lucide-react uses the same stroke-based "line icon" language
// `aria-hidden` on every icon: each one is always paired with visible text // (default `strokeWidth={2}`, round caps/joins) so the switch is visually
// (the nav label, or a `title` tooltip when collapsed) that already // a no-op, just swaps who draws the paths.
// conveys the meaning — the icon itself is decorative. //
// Every one of these is always paired with visible text (the nav label, or
function Icon({ children }: { children: ReactNode }) { // a `title` tooltip when the sidebar is collapsed) — purely decorative, so
return ( // every consumer passes `aria-hidden="true"` itself (unlike the old custom
<svg // `Icon` wrapper, lucide doesn't set this by default).
viewBox="0 0 24 24" export {
fill="none" Calendar as PlanningIcon,
stroke="currentColor" BookOpen as RecipesIcon,
strokeWidth="2" ShoppingCart as ShoppingListIcon,
strokeLinecap="round" Settings as SettingsIcon,
strokeLinejoin="round" User as AccountIcon,
aria-hidden="true" Leaf as DietPreferencesIcon,
> Home as HouseholdIcon,
{children} Palette as UserPreferencesIcon,
</svg> ChevronLeft as ChevronLeftIcon,
); Star as FavoriteIcon,
} Globe as PublicIcon,
} from "lucide-react";
export function PlanningIcon() {
return (
<Icon>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</Icon>
);
}
export function RecipesIcon() {
return (
<Icon>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</Icon>
);
}
export function ShoppingListIcon() {
return (
<Icon>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</Icon>
);
}
export function SettingsIcon() {
return (
<Icon>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Icon>
);
}
export function AccountIcon() {
return (
<Icon>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Icon>
);
}
export function DietPreferencesIcon() {
return (
<Icon>
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z" />
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
</Icon>
);
}
export function HouseholdIcon() {
return (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</Icon>
);
}
export function UserPreferencesIcon() {
return (
<Icon>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1.1-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-4.4-4-8-10-8z" />
</Icon>
);
}
export function ChevronLeftIcon() {
return (
<Icon>
<path d="M15 18l-6-6 6-6" />
</Icon>
);
}
/** Favorites — the recipe catalog's "Favoris" tab (`RecipeTabs`) and the recipe detail panel's favorite toggle (`FavoriteStarButton`). */
export function FavoriteIcon() {
return (
<Icon>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</Icon>
);
}
/** Public recipes — the recipe catalog's "Publique" tab (`RecipeTabs`). */
export function PublicIcon() {
return (
<Icon>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</Icon>
);
}

View file

@ -2,6 +2,7 @@ import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooki
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { RadioOption } from "../../components/ui/Radio";
import { useTheme } from "../../features/theme/ThemeContext"; import { useTheme } from "../../features/theme/ThemeContext";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
import "./settings-pages.scss"; import "./settings-pages.scss";
@ -46,23 +47,16 @@ export function UserPreferencesPage() {
{THEME_PREFERENCES.map((option) => { {THEME_PREFERENCES.map((option) => {
const checked = theme === option; const checked = theme === option;
return ( return (
<label <RadioOption
key={option} key={option}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
// selected look — see AllergySelect.tsx for why this stays
// in JS rather than pure CSS.
className={`theme-select__option${checked ? " is-selected" : ""}`}
>
<input
type="radio"
name="theme" name="theme"
value={option} value={option}
checked={checked} checked={checked}
onChange={() => handleChange(option)} onChange={handleChange}
/> className="theme-select__option"
<span className="check-mark" aria-hidden="true" /> >
{t(`userPreferences.theme.${option}`)} {t(`userPreferences.theme.${option}`)}
</label> </RadioOption>
); );
})} })}
</fieldset> </fieldset>

View file

@ -127,7 +127,8 @@
// visually distinct, consistent "danger" treatment wherever they appear. // visually distinct, consistent "danger" treatment wherever they appear.
.settings-page__danger-zone { .settings-page__danger-zone {
margin-top: var(--space-lg); 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 { .settings-page__danger-button {

View file

@ -60,15 +60,16 @@ h1 {
// `display: none`) and the whole label row it lives in becomes the // `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a // interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark // 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()` // The base (unselected) look below is detected structurally with `:has()`
// safe, since "does this label contain a checkbox/radio" never changes // safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected` // after mount. The *selected* look is instead driven by the `is-selected`
// class each caller (AllergySelect.tsx, UserPreferencesPage.tsx) toggles // class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
// in JS from the same boolean it already passes to `checked` chaining a // in JS from the same boolean their caller already passes to `checked`
// second `:has(:checked)` to react to that live state turned out to be // chaining a second `:has(:checked)` to react to that live state turned
// unreliable across browsers, so this only needs one always-true `:has()`. // 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 // Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling // grid, the theme picker, anywhere future) rather than each feature styling
@ -126,15 +127,15 @@ input[type="radio"] {
opacity: 0; opacity: 0;
} }
// The checkmark a real element (see AllergySelect.tsx / // The checkmark a real element (see components/ui/Checkbox.tsx /
// UserPreferencesPage.tsx) shown via the same `is-selected` class as the // Radio.tsx) shown via the same `is-selected` class as the label's own
// label's own look above, not a separate CSS-only trigger. Scaled in from // look above, not a separate CSS-only trigger. Scaled in from nothing so
// nothing so toggling has a bit of motion. Same mark for both checkbox and // toggling has a bit of motion. Same mark for both checkbox and radio: one
// radio: one consistent "selected" language app-wide rather than a // consistent "selected" language app-wide rather than a checkmark here and
// checkmark here and a dot there. // 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 { .check-mark {
flex: none; flex: none;
margin-left: auto;
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
background: var(--color-primary); background: var(--color-primary);

5
apps/web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
// 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;

View file

@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
css: { 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),
},
}); });

View file

@ -99,6 +99,9 @@ importers:
i18next: i18next:
specifier: ^26.3.6 specifier: ^26.3.6
version: 26.3.6(typescript@5.9.3) version: 26.3.6(typescript@5.9.3)
lucide-react:
specifier: ^1.32.0
version: 1.32.0(react@18.3.1)
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@ -2098,6 +2101,11 @@ packages:
lru-cache@5.1.1: 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} 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: luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==, tarball: https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz} resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==, tarball: https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -4764,6 +4772,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.32.0(react@18.3.1):
dependencies:
react: 18.3.1
luxon@3.7.2: {} luxon@3.7.2: {}
make-dir@3.1.0: make-dir@3.1.0: