Remplace la FK morte `Ingredient.alternateRecipeId` (jamais branchée nulle part — confirmé par exploration : zéro usage en dehors de schema.prisma) par un flag booléen `reproducible`, plus simple : pas de liaison recette↔ingrédient en base, juste une info "ça vaut le coup d'être fait maison" plus un raccourci de recherche. - Migration : drop `alternate_recipe` (colonne + FK), ajoute `reproducible BOOLEAN NOT NULL DEFAULT false` sur `ingredients`. - `reference-seed-data.ts` : `IngredientSeed` gagne `reproducible?`, threadé dans le flatten + la réconciliation `seedReferenceData`. Premier lot de 27 ingrédients marqués (pains, pâtes à cuire, sauces de base, bouillons/fonds) — même logique que la curation Ciqual : un lot solide plutôt qu'exhaustif sur les 546 ingrédients. - `IngredientView` (shared) + les deux endroits qui la construisent (`reference.service.ts`, `recipe.service.ts`) gagnent `reproducible`. - `ReproducibleBadge` (nouveau) : pastille "Faisable maison" — simple dans `IngredientPicker` (avec son propre toggle d'affichage), lien cliquable dans `IngredientRow` vers `/recettes?search=<nom>` ouvert dans un nouvel onglet (pour ne jamais perdre le formulaire de recette en cours — pas de persistance de brouillon dans `RecipeFormPage`). - `RecipesPage` lit `?search=` au montage pour permettre ce deep-link. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1165 lines
29 KiB
SCSS
1165 lines
29 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).
|
|
// =============================================================================
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
&__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 (including the disabled "Sources"
|
|
// placeholder) 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;
|
|
}
|
|
|
|
&: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;
|
|
background: var(--color-surface);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.recipe-table {
|
|
width: 100%;
|
|
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);
|
|
}
|
|
}
|
|
|
|
// --- 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-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 {
|
|
counter-increment: step;
|
|
|
|
&::before {
|
|
content: counter(step) ". ";
|
|
font-weight: 700;
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
img {
|
|
display: block;
|
|
max-width: 100%;
|
|
border-radius: var(--radius-base);
|
|
margin-bottom: var(--space-xs);
|
|
}
|
|
|
|
p {
|
|
display: inline;
|
|
margin: 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
max-width: 40rem;
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
&__quantity {
|
|
width: 5rem;
|
|
}
|
|
|
|
&__unit {
|
|
width: 6rem;
|
|
}
|
|
|
|
&__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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
&__empty {
|
|
padding: var(--space-md);
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-muted);
|
|
text-align: center;
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
}
|
|
}
|
|
}
|