batchCooking/apps/web/src/features/recipes/recipes.scss
Nicolas b49ef5bb40
Some checks failed
CI / lint (push) Successful in 1m37s
CI / intent-service-test (push) Successful in 5m11s
CI / build (push) Successful in 1m9s
CI / e2e (push) Failing after 7m13s
CI / test (push) Failing after 18m46s
CI / build-android (push) Has been skipped
feat(recipes): signaler une technique absente du catalogue (#130)
Calque le pattern « ingrédient hors-catalogue » sur `TechStep` : cinq
colonnes additives (`isPlaceholder`/`displayName`/`createdById`/`createdAt`/
`reviewedAt`), migration purement additive, FK `createdById → user_profiles`
en `SET NULL`.

Correction d'étape : `submitTechStepCorrectionSchema` accepte
`correctedTechStepName` (mutuellement exclusif de `correctedTechStepId`) ;
`submitTechStepCorrection` crée une `TechStep` placeholder dans la
transaction et poursuit comme si son id avait été fourni. La correction
s'applique immédiatement (`source: manual`) ; nouvel event analytics
`tech_step.placeholder_proposed`.

`TechStepView` gagne `isPlaceholder`/`displayName` ; `GET
/reference/tech-steps` exclut les placeholders. Nouveau helper web
`techStepLabel` (`displayName ?? t(catalog.techSteps.<key>)`), utilisé par
`StepDescription` et le popover. Le popover a une échappatoire « Ma
technique n'est pas dans la liste » (champ libre).

Admin : `admin-tech-step-catalog.service.ts` + routes
`GET/PATCH /admin/api/catalog/tech-step-placeholders` (liste plate — les
propositions de technique sont rares) ; `CatalogPage` gagne un sélecteur
Ingrédients / Techniques.

Tests : Mocha (proposition placeholder, rejet id+nom, exclusion catalogue,
liste + mark-reviewed admin) + Cypress (composant popover, e2e admin
catalogue). Assertions `.techStep` existantes mises à jour pour les 2
nouveaux champs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:40:35 +02:00

2099 lines
No EOL
56 KiB
SCSS

// =============================================================================
// Recipe catalog — shared by RecipesPage, RecipeFormPage and every component
// under features/recipes/. Colocated here (rather than split per-component)
// since the catalog is one cohesive visual unit and most rules are small
// enough that splitting them would just add files to jump between — same
// choice as settings-pages.scss for the settings pages.
//
// The master-detail layout (tabs + table + detail panel) was ported from a
// reviewed standalone HTML mockup onto the app's real design tokens — no
// light/dark duplication needed here, every `var(--color-*)` below already
// resolves per-theme globally (see styles/_theme.scss).
// =============================================================================
@use "../../styles/breakpoints" as bp;
// --- Allergen / diet / disliked-ingredient badges ---------------------------
// Three visually distinct pill languages, each with its own meaning — never
// mixed, so a glance tells them apart without reading the label:
// - allergens: --color-allergen, solid fill (a food-safety warning)
// - diets: --color-tag (turmeric — "category/classification tag" per
// _theme.scss), solid fill (a neutral reminder, not a warning)
// - disliked ingredients: neutral outline, dashed border (a personal
// taste preference — deliberately *not* on the allergen/warning scale)
.allergen-badges,
.diet-badges,
.disliked-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin: var(--space-sm) 0 0;
padding: 0;
list-style: none;
}
.allergen-badge,
.diet-badge,
.disliked-badge {
padding: 0.15rem 0.6rem;
font-size: var(--font-size-xs);
font-weight: 600;
border-radius: var(--radius-pill);
}
.allergen-badge {
color: var(--color-allergen-ink);
background: var(--color-allergen);
}
.diet-badge {
color: var(--color-tag-ink);
background: var(--color-tag);
}
.disliked-badge {
color: var(--color-text-muted);
background: none;
border: 1px dashed var(--color-border);
}
// A fourth, distinct pill language for `ReproducibleBadge` — neither a
// warning (allergen) nor a classification (diet) nor a negative (disliked):
// a positive nudge ("this could be homemade"), so it borrows
// --color-primary (the same tinted-fill treatment as `.is-selected` in
// global.scss) rather than any of the three above. `margin-top` matches its
// siblings' `.allergen-badges`/`.diet-badges` list wrappers even though this
// is a lone element, not a list, so it lines up the same way when it's the
// only badge present.
.reproducible-badge {
display: inline-block;
margin: var(--space-sm) 0 0;
padding: 0.15rem 0.6rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
border-radius: var(--radius-pill);
}
.reproducible-badge--link {
text-decoration: none;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
}
}
// --- Catalog page -------------------------------------------------------------
// `.app-content` (AppLayout.scss) already stretches to the full viewport
// height — same reasoning as `planning-page.scss`. `.recipes-page` fills
// that box as a column so `__catalog` can grow to fill whatever's left
// under the header/tabs, instead of being only as tall as its content.
.recipes-page {
height: 100%;
display: flex;
flex-direction: column;
&__header {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
&__search {
flex: 1 1 16rem;
max-width: 24rem;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
&__new-button {
flex: none;
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
text-decoration: none;
&:hover {
background: var(--color-primary-hover);
}
}
&__status {
margin-top: var(--space-xl);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
// The master-detail row: table (flexible) + detail panel (never under
// 35% of the *viewport* width — `minmax(35vw, 38vw)`, not a fraction of
// this grid, per the reviewed mockup), both stretched to the same full
// height so the detail panel never just shrink-wraps to its own content.
&__catalog {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(35vw, 38vw);
// Without this, the grid's single implicit row falls back to
// auto-sizing (tall enough for its content), not the full height of
// `&__catalog` itself — silently masked as long as `.app-content`'s own
// height was `min-height: 100vh` (auto-grew to fit anything, so
// nothing downstream ever actually needed to stretch to a fixed
// height). Fixing that (see AppLayout.scss) made `&__catalog`'s height
// genuinely definite for the first time, which surfaced this: the
// detail panel's own `height: 100%; overflow-y: auto` (see
// `.recipe-detail-panel` below) had nothing to stretch against, came
// out shorter than its content, and silently clipped it.
grid-template-rows: minmax(0, 1fr);
gap: var(--space-lg);
}
@include bp.tablet-down {
&__catalog {
grid-template-columns: 1fr;
// The single-row `1fr` track above only makes sense for the desktop
// side-by-side layout — collapsed to one column, the table and the
// detail panel stack into two separate rows instead, so forcing them
// both into one `1fr` track split the available height in half and
// gave the table row 0px (its own content pushed below the fold,
// clipped by its `overflow-y: auto`). Reset to the default `auto`
// (each row sized to its content) — matches `height: auto` below,
// which already stopped assuming a single fixed-height row here.
grid-template-rows: auto;
height: auto;
}
}
}
// --- Tabs ------------------------------------------------------------------
.recipe-tabs {
flex-shrink: 0;
display: flex;
gap: var(--space-xs);
// Never lets a tab overflow the page (which would force the whole body
// to scroll horizontally, see global.scss's rule against that) — scrolls
// within itself instead once the tabs (favoris/perso/foyer/publique,
// plus one per household-enabled source) don't all fit, same pattern as
// the sidebar's own nav.
overflow-x: auto;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-md);
&__tab {
appearance: none;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.4rem;
padding: var(--space-sm) var(--space-md);
font-family: var(--font-display);
font-size: var(--font-size-sm);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-muted);
background: none;
border: none;
border-bottom: 2.5px solid transparent;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
svg {
width: 1.05rem;
height: 1.05rem;
flex: none;
}
// A source tab's own icon (`SourceView.iconUrl`) — sized to match the
// Lucide `svg` icons above so a source tab doesn't stand out from the
// four real ones.
.recipe-tabs__source-icon {
width: 1.05rem;
height: 1.05rem;
flex: none;
object-fit: contain;
}
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
}
&.placeholder {
color: var(--color-border);
cursor: not-allowed;
&:hover {
color: var(--color-border);
}
}
}
}
// --- Catalog table -----------------------------------------------------------
.recipe-table-wrap {
height: 100%;
min-height: 0;
overflow-y: auto;
// Issue #90: unlike the planning grid's own wrapper (planning-page.scss),
// this one had no horizontal guard at all — on a narrow viewport the
// table's `width: 100%` below just let the browser compress every column
// (photo/name/allergens/diets) to fit instead of scrolling. Same fix,
// scaled down: 4 narrower columns don't need anywhere near the planning
// grid's 62rem, but they do need *some* floor so the badge columns don't
// get squeezed to the point of wrapping every single pill onto its own
// line. Shared by both the full catalog (`/recettes`) and
// `RecipePickerDialog`'s own instance of this same table/wrapper — fixes
// the identical compression there too, not just the full-page case.
overflow-x: auto;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.recipe-table {
width: 100%;
min-width: 36rem;
border-collapse: collapse;
text-align: left;
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
font-family: var(--font-display);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-muted);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
tbody tr {
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: var(--color-surface-alt);
}
&.selected {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
td:first-child {
box-shadow: inset 3px 0 0 var(--color-primary);
}
}
&:last-child td {
border-bottom: none;
}
}
&__photo {
width: 2.6rem;
height: 2.6rem;
border-radius: var(--radius-base);
background: var(--color-surface-alt);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
font-weight: 600;
}
&__fav-mark {
color: var(--color-tag);
margin-left: 0.3rem;
font-size: var(--font-size-sm);
}
&__muted {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
// --- Sources tab (RecipeSourcesPanel) ---------------------------------------
// Reuses .recipes-page__header/__search/__catalog and .recipe-table(-wrap)
// as-is (see RecipeSourcesPanel.tsx/SourceItemTable.tsx) — only what's
// actually new to this tab gets its own rules here.
.recipes-page__header--sources {
gap: var(--space-sm);
}
.source-item-table__imported-badge {
padding: 0.1rem 0.5rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
white-space: nowrap;
}
.source-items-column {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
// Browsing a source scrolls infinitely (SourceItemTable's own sentinel row
// triggers RecipeSourcesPanel's handleLoadMore) — this is only the inline
// retry action shown alongside recipes.sources.loadMoreError when a page
// fetch actually fails, not a persistent "load more" control.
.source-items-retry {
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 600;
color: var(--color-primary);
background: none;
border: none;
text-decoration: underline;
cursor: pointer;
}
// Pulsing placeholder rows `SourceItemTable` appends below the real items
// while `RecipeSourcesPanel`'s "load more" fetch is in flight — with that
// panel prefetching the next page ahead of time (as soon as the sentinel
// row scrolls near view), this is usually a very brief flash rather than
// an actual wait, but it keeps the list
// filling in instead of looking like nothing happened either way.
@keyframes source-item-skeleton-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}
// Invisible marker row `SourceItemTable`'s `IntersectionObserver` watches
// to trigger infinite scroll — no padding/border of its own, unlike a real
// row, so it doesn't show up as a stray empty stripe at the bottom of the
// list.
.source-item-table__sentinel td {
padding: 0;
border: none;
}
.source-item-table__row--skeleton {
cursor: default;
pointer-events: none;
&:hover {
background: none;
}
}
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
background: var(--color-border);
animation: source-item-skeleton-pulse 1.4s ease-in-out infinite;
}
.source-item-table__skeleton-bar {
display: block;
width: 60%;
height: 0.9rem;
border-radius: var(--radius-pill);
}
@media (prefers-reduced-motion: reduce) {
.source-item-table__photo--skeleton,
.source-item-table__skeleton-bar {
animation: none;
opacity: 0.6;
}
}
.source-item-preview__hint {
margin: 0 0 var(--space-sm);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-radius: var(--radius-base);
}
// --- Recipe detail panel -----------------------------------------------------
// 100% of the grid row's height (see `.recipes-page__catalog` above): the
// panel itself never scrolls, only its content does past that height
// (`overflow-y: auto`) — the photo/star/name stay visible while reading.
.recipe-detail-panel {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
&__status {
padding: var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__status--error {
color: var(--color-error);
}
&__header {
flex-shrink: 0;
position: relative;
}
&__photo {
height: 9rem;
width: 100%;
background: linear-gradient(160deg,
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
var(--color-surface-alt));
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-md) 0;
h2 {
font-size: var(--font-size-lg);
}
}
&__title-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
h2 {
margin: 0;
}
}
&__portions {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__title-badges {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
.allergen-badges,
.disliked-badges {
justify-content: flex-end;
margin-top: 0;
}
}
&__actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
}
&__danger-button {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: #fff;
background: var(--color-error);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__delete-confirm {
display: flex;
align-items: center;
gap: var(--space-sm);
button:not(.recipe-detail-panel__danger-button) {
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius-base);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}
}
&__section {
flex-shrink: 0;
padding: var(--space-md);
&+& {
border-top: 1px solid var(--color-border);
}
h3 {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
}
// The description gets its own, more generous section — the only "free
// text" on the panel, it needs more room to breathe than a badge or a
// short step.
&__section--description {
padding: var(--space-lg) var(--space-md);
}
&__description {
color: var(--color-text);
font-size: var(--font-size-base);
line-height: 1.7;
margin: 0;
}
&__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-md);
counter-reset: step;
// `> li`, not a bare descendant `li` — this used to match *any* `<li>`
// nested anywhere inside a step, not just the step's own direct one.
// Harmless while `StepDescription`'s editable mode only ever added
// plain text/buttons, but `TechStepCorrectionPopover` renders its own
// `<li>`s a few levels down (each attached ingredient/utensil/
// temperature row, `.tech-step-correction-popover__row`; the technique
// picker's `.catalog-search-picker__list`, up to ~74 entries) —
// every one of those was incrementing this *same* counter, throwing
// every step's own number off as soon as any correction popover with
// attached metadata (or its technique picker) was open.
> li {
counter-increment: step;
&::before {
content: counter(step) ". ";
font-weight: 700;
color: var(--color-primary);
}
// Same descendant-selector fix as `> li` above, for the same reason:
// both the step's own picture and its `StepDescription`-rendered `<p>`
// are direct children of this `<li>` — `TechStepCorrectionPopover`'s
// own `<p>`s (the selection line, hints, `.field-error`) sit deeper
// inside it and must not inherit "inline, no margin" meant only for
// the step's own paragraph.
> img {
display: block;
max-width: 100%;
border-radius: var(--radius-base);
margin-bottom: var(--space-xs);
}
> p {
display: inline;
margin: 0;
}
}
}
}
// A step's detected-technique keyword (see StepDescription.tsx) — a real
// <button> (natively focusable, so the Tooltip it wraps is
// keyboard-reachable, not just hoverable — see that component), reset here
// to read as inline highlighted text rather than a button: a subtle dotted
// underline + tinted background, same "primary-tinted" language as
// `.source-select__badge.is-official`, not a loud/distracting highlight
// since it sits inline within normal reading text.
.step-tech-step {
display: inline;
padding: 0 0.15em;
margin: 0;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border: none;
border-radius: 0.2em;
text-decoration: underline dotted var(--color-primary);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
// A real clickable trigger (opens `TechStepCorrectionPopover`), not just
// a hover-for-a-tooltip target — `pointer`, not `help`, same reasoning
// now shared by `.step-temperature` below.
cursor: pointer;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-primary) 24%, transparent);
outline: none;
}
}
// A `"manual"`-sourced match (a viewer's correction, applied immediately —
// see `StepTechStepView.source`) — same shape as `.step-tech-step`, but in
// `--color-tag` (Turmeric) instead of `--color-primary` (Basil), so the two
// origins are distinguishable at a glance, not just via the tooltip text.
.step-tech-step--manual {
background: color-mix(in srgb, var(--color-tag) 18%, transparent);
text-decoration-color: var(--color-tag);
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-tag) 28%, transparent);
}
}
// A detected temperature mention ("180 °C", "th. 6", "feu doux") — same
// inline "highlighted reading text" language as `.step-tech-step`, but in
// `--color-accent` (Vermillion, the "heat" accent) rather than
// `--color-primary`, so a temperature reads as its own kind of metadata at
// a glance. `--low`/`--medium`/`--high` only vary the underline weight —
// the qualitative scale is a nuance, not three different colors.
//
// Renders as a `<button>` (not a `<span>`) in editable mode when it has an
// owning technique occurrence to reopen (see `StepDescription.tsx`'s
// `findTechStepForTemperature`) — same button-reset-to-inline-text
// treatment as `.step-tech-step` above (`font`/`color`/`margin`/`border`),
// so the two read identically whether they're a `<button>` or a plain
// `<span>` (read-only, or no owner to reopen).
.step-temperature {
display: inline;
padding: 0 0.15em;
margin: 0;
font: inherit;
color: inherit;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: none;
border-radius: 0.2em;
text-decoration: underline dotted var(--color-accent);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: pointer;
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
outline: none;
}
&--high {
text-decoration-thickness: 2px;
}
&--low {
text-decoration-style: dashed;
}
}
// `.step-tech-step-context` (the wider clause a `.step-tech-step` keyword
// was found in) used to be highlighted here too, more subtly — turned back
// off (see `StepDescription.tsx`'s doc comment): the backend still
// computes and persists `contextStart`/`contextEnd`, this file just no
// longer gives that class any styling to render with.
// Discoverability hint above the steps list (RecipeDetailPanel.tsx) —
// muted so it reads as a small aside, not competing with the steps
// themselves for attention.
.recipe-detail-panel__tech-step-hint {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// --- Tech-step correction (StepDescription.tsx editable mode) ---------------
// Deliberately *not* `position: absolute` (unlike `.calendar-popover`) — see
// `TechStepCorrectionPopover.tsx`'s doc comment for why this renders inline
// in the document flow right below the step's own description instead of
// floating anchored at the selection.
.tech-step-correction-popover {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-xs);
padding: var(--space-md);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__selection {
font-weight: 600;
margin: 0;
}
// One shared shape for every section (Technique/Ingrédients/Ustensiles/
// Température) — see `TechStepCorrectionPopover.tsx`'s own doc comment
// for why all four render together, always, rather than one at a time.
&__section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__section-header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--color-border);
svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
}
// Same "kicker" label language as `.cooking-phase__recipe-group-title`
// (cooking-session-page.scss) — a small bold uppercase eyebrow, not a
// full heading, since four of these stack in one popover.
&__section-title {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
}
&__section-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
// One icon color per category — set on the icon itself (not the whole
// section) so the title text next to it stays neutral/muted.
&__technique-section &__section-header svg {
color: var(--color-primary);
}
&__ingredients-section &__section-header svg,
&__utensils-section &__section-header svg {
color: var(--color-tag);
}
&__temperature-section &__section-header svg {
color: var(--color-accent);
}
&__chosen-technique {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
&__technique-pill {
display: inline-flex;
align-items: center;
background: var(--color-primary);
color: var(--color-surface);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-pill);
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-sm);
}
&__technique-change {
background: none;
border: none;
color: var(--color-primary);
text-decoration: underline;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
padding: 0;
align-self: flex-start;
}
// "Ma technique n'est pas dans la liste" sub-form (issue #130) — a name
// field + a small confirm, shown in place of the "not in list" link once
// it's clicked.
&__missing-technique {
display: flex;
gap: var(--space-xs);
input {
flex: 1;
min-width: 0;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
}
&__remove-match {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
border-radius: var(--radius-pill);
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__cancel,
&__cancel-mini {
background: none;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
// Shown in place of a section's own "+ Ajouter…" button while
// `StepDescription` is waiting on a second text selection for *that*
// section (see `TechStepCorrectionPopover.tsx`'s own doc comment) — same
// styling intent as `.recipe-detail-panel__tech-step-hint`, a small
// muted aside.
&__hint {
margin: 0;
color: var(--color-text-muted);
font-style: italic;
font-size: var(--font-size-sm);
}
&__rows {
display: flex;
flex-direction: column;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
}
// One attached ingredient/utensil/temperature — a small card of its own
// (not a chip pill anymore: showing the source quote alongside the name
// needs more room than a pill comfortably gives).
&__row {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
box-shadow: var(--shadow-sm);
padding: var(--space-sm);
}
&__row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__row-name {
font-weight: 600;
}
&__row-quote {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
&__row-badge {
flex-shrink: 0;
align-self: center;
background: var(--color-surface-alt);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
padding: 2px var(--space-sm);
border-radius: var(--radius-pill);
}
&__row-remove {
flex-shrink: 0;
border: none;
background: none;
color: var(--color-text-muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-base);
cursor: pointer;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
color: var(--color-error);
background: var(--color-surface-alt);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// "+ Ajouter…" — a dashed ghost row, not a plain text link: at-a-glance
// an "add a new item" affordance, same weight as the rows it adds to.
&__add-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-base);
padding: var(--space-sm);
color: var(--color-text-muted);
background: none;
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// A section's own inline "adding a new mention" sub-form — replaces just
// that section's `__add-row` button, every other section (and the
// technique picker) stays visible and untouched alongside it.
&__add-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
padding: var(--space-sm);
}
&__add-form-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-sm);
}
&__quantity-line,
&__value-line {
display: flex;
align-items: center;
gap: var(--space-xs);
input[type="number"] {
width: 5rem;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
}
&__value-unit {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
// Température's 3-way mode switch (°C / Thermostat / Qualitatif) — a
// segmented control, same connected-pill language as the qualitative
// sub-choice below, in the accent color (Vermillion, the "heat" accent —
// same as `.step-temperature`) so the active mode reads at a glance.
&__mode-switch {
display: flex;
gap: 2px;
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
padding: 2px;
button {
flex: 1;
border: none;
background: none;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
font-size: var(--font-size-sm);
font-weight: 700;
color: var(--color-text-muted);
cursor: pointer;
&.active {
background: var(--color-accent);
color: var(--color-surface);
}
}
}
&__qual-switch {
display: flex;
gap: var(--space-xs);
button {
flex: 1;
border: 1.5px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&.active {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--color-surface);
}
}
}
&__confirm-mini {
background: var(--color-accent);
color: var(--color-surface);
border: none;
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 700;
padding: var(--space-xs) var(--space-md);
cursor: pointer;
&:hover {
background: var(--color-accent-hover);
}
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-md);
padding-top: var(--space-xs);
}
&__confirm-button {
padding: var(--space-sm) var(--space-lg);
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
// Reused by both the ingredient and utensil "attach to this correction"
// sub-flows (`TechStepCorrectionPopover.tsx`) — deliberately lighter than
// `.ingredient-picker` (no category/subcategory grid, no allergen/diet
// toggles), sized for a small popover rather than a full recipe form.
.catalog-search-picker {
display: flex;
flex-direction: column;
gap: var(--space-xs);
&__input {
width: 100%;
}
&__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0;
padding: 0;
// Raised from the original 8rem — this component is now also the
// technique picker (~74 entries, see this file's own doc comment),
// where 8rem left only a couple of rows visible before scrolling.
max-height: 14rem;
overflow-y: auto;
button {
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
border-color: var(--color-primary);
}
// The technique picker's current pick (`selectedId` prop) — stays
// visually marked even while filtered/scrolled past, so re-opening
// this popover's picker doesn't read as "nothing chosen yet" when
// something already is. Unused by the ingredient/utensil sub-flows
// (they never pass `selectedId` — each pick there just appends a
// fresh mention, nothing to mark as "current").
&.catalog-search-picker__item--selected {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
font-weight: 600;
}
}
}
}
// --- Favorite star toggle (detail panel header) -----------------------------
.favorite-star-button {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
border: none;
cursor: pointer;
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
box-shadow: var(--shadow-sm);
color: var(--color-border);
display: flex;
align-items: center;
justify-content: center;
transition:
transform 0.12s ease,
color 0.12s ease;
svg {
width: 1.2rem;
height: 1.2rem;
}
&:hover:not(:disabled) {
transform: scale(1.08);
}
&:disabled {
cursor: not-allowed;
}
// Overrides the icon's own `fill="none"` (a presentation attribute, lower
// CSS priority than this rule) — solid star once favorited, outline
// otherwise.
&.is-favorite {
color: var(--color-tag);
svg {
fill: currentColor;
}
}
}
// --- External source link (draft preview header) ----------------------------
// Same overlay slot/sizing as `.favorite-star-button` above — a draft
// preview never has both (nothing to favorite yet), so the two never
// compete for the corner. Deliberately muted/small ("discret" per the
// product decision this button follows): a way out to the original page,
// not a call to action the way the buttons it replaced were.
.recipe-detail-panel__source-link {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
box-shadow: var(--shadow-sm);
color: var(--color-text-muted);
transition:
transform 0.12s ease,
color 0.12s ease;
svg {
width: 1.1rem;
height: 1.1rem;
}
&:hover,
&:focus-visible {
color: var(--color-primary);
transform: scale(1.08);
outline: none;
}
}
// --- Recipe form (create/edit) ----------------------------------------------
// Per-field validation and whole-form error messages — same small rules as
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
// since this page only imports recipes.scss (see global.scss's doc comment
// on colocated, per-scope styles).
.field-error {
color: var(--color-error);
font-size: var(--font-size-xs);
margin: 0;
}
.form-error {
color: var(--color-error);
font-size: var(--font-size-sm);
}
.recipe-form {
display: flex;
flex-direction: column;
gap: var(--space-xs);
label {
font-size: var(--font-size-sm);
font-weight: 600;
margin-top: var(--space-sm);
}
input,
textarea,
select {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
resize: vertical;
}
&__section {
margin-top: var(--space-xl);
h2 {
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
}
&__ingredient-list {
list-style: none;
margin: 0 0 var(--space-sm);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
&__actions {
margin-top: var(--space-xl);
}
// Pins the Enregistrer/Annuler pair to the bottom of `.app-content` (the
// one scroll container — AppLayout.scss) so a long recipe never needs
// scrolling back down to save or leave. `bottom: 0` lands it a hair above
// the true viewport edge because `.app-content` carries its own padding —
// a small, acceptable gap, and correct on every breakpoint without a
// per-viewport override. The opaque `background` + top border keep the
// form content readable as it scrolls underneath.
&__actions--sticky {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding: var(--space-md) 0;
background: var(--color-background);
border-top: 1px solid var(--color-border);
}
button[type="submit"] {
padding: 0.6rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
border: none;
border-radius: var(--radius-base);
background: var(--color-primary);
color: #fff;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// Secondary action next to the primary Enregistrer — outline/ghost, same
// language as `.recipe-detail-panel__delete-confirm`'s non-danger button,
// so "Annuler" never competes visually with "Enregistrer".
&__cancel {
padding: 0.6rem var(--space-lg);
font-family: var(--font-body);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:hover {
background: var(--color-surface-alt);
}
}
}
// --- Unresolved-ingredient review (ImportRecipePage) ------------------------
// Amber-tinted callout, same "warning" language as .source-item-preview__hint
// — each line needs a person to pick the right ingredient (or drop it)
// before the form can submit at all (see ImportRecipePage.tsx's canSubmit).
.import-recipe__unresolved {
margin: var(--space-sm) 0;
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, var(--color-border));
border-radius: var(--radius-md);
h3 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-base);
}
}
.import-recipe__unresolved-list {
list-style: none;
margin: var(--space-sm) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.import-recipe__unresolved-row {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
span {
flex: 1 1 auto;
font-size: var(--font-size-sm);
}
button {
flex: none;
padding: 0.3rem var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
}
}
// --- Diet tag multi-select (recipe form) -------------------------------------
// Checkbox-grid, same visual language as AllergySelect (profile-forms.scss)
// — global.scss's `label:has(> input[type="checkbox"])` rule already
// supplies the selected/unselected look, this only arranges the rows.
.diet-tag-select {
border: none;
padding: 0;
margin: var(--space-xs) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
legend {
width: 100%;
padding: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
label {
margin-top: 0;
font-weight: 400;
}
}
// --- Ingredient row (selected ingredient in the form) -----------------------
.ingredient-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
&__icon {
display: flex;
flex-shrink: 0;
color: var(--color-text-muted);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
&__name {
font-weight: 600;
margin-right: auto;
}
// Marks a free-text placeholder line (nothing in the catalog matched) —
// the ingredient still needs a real catalog entry created by a
// maintainer, but the line is usable (quantity/unit) in the meantime.
&__placeholder-badge {
margin-left: var(--space-xs);
padding: 0 var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 500;
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text-muted);
}
&__quantity {
width: 5rem;
}
// Wide enough for the longest catalog label ("cuillère à soupe") not to
// clip — was sized for the old free-text input's short values ("g",
// "unité"…) before unit became a <select> over the reference catalog
// (see IngredientRow.tsx), left too narrow for that one.
&__unit {
width: 11rem;
}
// "Selon le goût" toggle — the app's selectable-card checkbox
// (global.scss's `label:has(> input[type="checkbox"])`), shrunk to sit
// inline in the row without dominating it.
&__to-taste {
margin-top: 0;
font-size: var(--font-size-xs);
font-weight: 400;
white-space: nowrap;
}
&__remove {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
&:hover {
color: var(--color-error);
border-color: var(--color-error);
}
}
// A resolved ingredient with no unit picked — the form's submit stays
// disabled until this is fixed, so it needs to be obvious *which* row is
// the reason why (see issue #53). `__error` takes the row's full width
// (it comes after the `flex-wrap`ped controls above, so it naturally
// drops to its own line).
&--incomplete &__unit {
border-color: var(--color-error);
}
&__error {
width: 100%;
margin: 0;
}
}
// --- Ingredient picker (recipe form + disliked-ingredients field) ----------
// Always-visible category chips + search + card grid — replaces the earlier
// type-to-filter dropdown (`.ingredient-autocomplete`, now gone), which
// stopped scaling once the reference list passed a couple hundred items.
// The category row uses the same "scroll, don't crush" pattern as the app
// sidebar nav (see AppLayout.scss's mobile breakpoint): chips never shrink
// below a tappable size, the row scrolls horizontally instead.
.ingredient-picker {
display: flex;
flex-direction: column;
gap: var(--space-sm);
// Every clickable chip/card below is a plain `<button>` with no text to
// actually select — without this, a click that moves even a pixel (a
// fast click, a trackpad) is read as a text-selection drag instead, and
// the browser's native selection highlight paints over the intended
// `.active` state. Reads as "the selection styling is flaky" from the
// outside; this is the actual cause, not the `.active` rules themselves.
user-select: none;
padding: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
&__search {
display: flex;
align-items: center;
gap: var(--space-xs);
input {
flex: 1 1 auto;
// Own min-width: 0 (not just relying on the flex row's) — same
// shrink-to-fit gotcha as `.disliked-ingredients-field`, belt and
// suspenders since a text input's natural min width is its content.
min-width: 0;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text);
// Undo the container's `user-select: none` — that rule exists for
// the chip/card buttons, not this text input; a search box the user
// can't select/copy text from would be its own bug.
user-select: text;
}
}
// Settings menu for the allergen/diet badge rows on every card below —
// trailing the search input. A single gear button + labeled checkboxes
// (not two bare icon-only toggles, the earlier design here) — an icon
// alone couldn't say clearly enough which badge row it hid.
&__display-options {
position: relative;
flex-shrink: 0;
}
&__display-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text-muted);
svg {
width: 1.5rem;
height: 1.5rem;
}
&:hover {
border-color: var(--color-primary);
color: var(--color-text);
}
&.active {
border-color: var(--color-primary);
color: var(--color-primary);
}
}
// Anchored below-right of the toggle — same transient-overlay reasoning
// as `.app-sidebar__account-menu` (AppLayout.scss).
&__display-menu {
position: absolute;
z-index: 10;
top: calc(100% + var(--space-xs));
right: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
// Box/checkmark look comes from the app-wide "selectable card" rule in
// global.scss (`label:has(> input[type="checkbox"])`) — this only adds
// what's specific to sitting inside a dropdown menu.
label {
font-size: var(--font-size-sm);
white-space: nowrap;
// The global rule trails the checkmark at the far right (`margin-left:
// auto`) — reads well as a selected chip in a grid (AllergySelect),
// but a vertical settings-menu list reads conventionally the other
// way: checkbox leading, label text after it. `.check-mark` already
// sits right after the (visually hidden) `<input>` and before the
// text in markup, so undoing the auto margin alone puts it there.
.check-mark {
margin-left: 0;
}
}
}
&__categories {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
padding-bottom: var(--space-xs);
}
&__category {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
appearance: none;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
white-space: nowrap;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: var(--color-surface-alt);
color: var(--color-text-muted);
svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
&:hover {
border-color: var(--color-primary);
color: var(--color-text);
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
}
// Second-tier drill-down, shown once a main category is picked — same
// "scroll, don't crush" row as `&__categories` above, but visually
// subordinate: smaller, and using `--color-tag` (not `--color-primary`)
// for its active state so the two levels stay legible at a glance.
&__subcategories {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
padding-bottom: var(--space-xs);
margin-top: calc(var(--space-xs) * -1);
}
&__subcategory {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
appearance: none;
padding: 2px var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-xs);
white-space: nowrap;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
svg {
flex-shrink: 0;
width: 0.85rem;
height: 0.85rem;
}
&:hover {
border-color: var(--color-tag);
color: var(--color-text);
}
&.active {
border-color: var(--color-tag);
background: var(--color-tag);
color: var(--color-tag-ink);
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-xs);
max-height: 18rem;
overflow-y: auto;
padding: 2px;
}
&__card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm);
text-align: center;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text);
&:hover {
border-color: var(--color-primary);
background: var(--color-surface);
}
}
&__card-icon {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 1.75rem;
height: 1.75rem;
}
}
&__card-name {
font-size: var(--font-size-sm);
}
// Now a container (not a bare `<p>`) — the "aucun ingrédient trouvé"
// message plus, when the picker allows it, an "ajouter « … » comme
// ingrédient à compléter" escape-hatch button stacked under it.
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
p {
margin: 0;
}
}
&__add-placeholder {
padding: var(--space-xs) var(--space-md);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px dashed var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
}
}
// --- Step list editor -----------------------------------------------------
.step-list-editor {
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
counter-reset: step;
}
&__item {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-surface);
border-radius: var(--radius-base);
counter-increment: step;
&::before {
content: counter(step);
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin-top: var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 600;
color: #fff;
background: var(--color-primary);
border-radius: 50%;
}
}
&__reorder {
display: flex;
flex-direction: column;
gap: 0.15rem;
button {
padding: 0.1rem 0.4rem;
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
}
&__fields {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-xs);
textarea,
input {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-background);
color: var(--color-text);
resize: vertical;
}
}
&__remove {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
line-height: 1;
cursor: pointer;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
&:hover {
color: var(--color-error);
border-color: var(--color-error);
}
}
&__add {
margin-top: var(--space-sm);
padding: 0.5rem var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
border: 1px dashed var(--color-border);
border-radius: var(--radius-base);
background: none;
color: var(--color-primary);
&:hover {
border-color: var(--color-primary);
}
}
}
// --- Disliked-ingredients field (preferences page) ---------------------------
// A search-and-add + removable-chips field, same idea as the recipe form's
// ingredient picker but without quantity/unit — see
// `features/profile/DislikedIngredientsField.tsx`.
.disliked-ingredients-field {
// `<fieldset>` carries a browser-default `min-width: min-content` that
// ordinary block elements don't — it refuses to shrink for its content,
// so `.ingredient-picker__grid` below (a `repeat(auto-fill, …)` grid)
// never gets narrow enough to wrap and blows out the whole page's width
// instead. Reset it back to the normal shrinkable behavior.
min-width: 0;
border: none;
padding: 0;
margin: var(--space-md) 0 0;
legend {
padding: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin: 0 0 var(--space-sm);
padding: 0;
list-style: none;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.3rem 0.2rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface-alt);
border-radius: var(--radius-pill);
svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
padding: 0;
font-size: var(--font-size-xs);
line-height: 1;
color: var(--color-text-muted);
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
&:hover {
background: var(--color-border);
color: var(--color-text);
}
}
}
}