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",
"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",

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 { 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 (
<label
<CheckboxOption
key={allergy.id}
// `is-selected` (not a `:has(:checked)` CSS rule) drives the
// selected look — chaining `:has(...):has(:checked)` to react to
// a *state* change (rather than a DOM mutation) turned out to be
// 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" : ""}`}
checked={checked}
onChange={() => toggle(allergy.id)}
className="allergy-select__option"
>
<input type="checkbox" checked={checked} onChange={() => toggle(allergy.id)} />
<span className="check-mark" aria-hidden="true" />
{allergy.name}
</label>
</CheckboxOption>
);
})}
</fieldset>

View file

@ -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 (
<label key={diet.id} className={checked ? "is-selected" : undefined}>
<input type="checkbox" checked={checked} onChange={() => toggle(diet.id)} />
<span className="check-mark" aria-hidden="true" />
<CheckboxOption key={diet.id} checked={checked} onChange={() => toggle(diet.id)}>
{diet.name}
</label>
</CheckboxOption>
);
})}
</fieldset>

View file

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

View file

@ -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")}
>
<SettingsIcon />
<SettingsIcon aria-hidden="true" />
</button>
{isDisplayMenuOpen && (
<div className="ingredient-picker__display-menu">
<label className={showDiets ? "is-selected" : ""}>
<input
type="checkbox"
checked={showDiets}
onChange={(e) => setShowDiets(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
<CheckboxOption checked={showDiets} onChange={setShowDiets}>
{t("recipes.form.showDietsLabel")}
</label>
<label className={showAllergens ? "is-selected" : ""}>
<input
type="checkbox"
checked={showAllergens}
onChange={(e) => setShowAllergens(e.target.checked)}
/>
<span className="check-mark" aria-hidden="true" />
</CheckboxOption>
<CheckboxOption checked={showAllergens} onChange={setShowAllergens}>
{t("recipes.form.showAllergensLabel")}
</label>
</CheckboxOption>
</div>
)}
</div>

View file

@ -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)}
>
<Icon />
<Icon aria-hidden="true" />
{t(`recipes.tabs.${value}`)}
</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 -----------------------------------
// 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;

View file

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

View file

@ -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 (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
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>
);
}
// 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";

View file

@ -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 (
<label
<RadioOption
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" : ""}`}
name="theme"
value={option}
checked={checked}
onChange={handleChange}
className="theme-select__option"
>
<input
type="radio"
name="theme"
value={option}
checked={checked}
onChange={() => handleChange(option)}
/>
<span className="check-mark" aria-hidden="true" />
{t(`userPreferences.theme.${option}`)}
</label>
</RadioOption>
);
})}
</fieldset>

View file

@ -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 {

View file

@ -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);

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 { 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),
},
});

View file

@ -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: