Merge pull request 'feat(recipes): refond le panneau de correction des métadonnées de tech-step' (#30) from feat/tech-step-metadata-editor-redesign into feat/tech-step-temperature-correction
Some checks failed
CI / lint (push) Successful in 3m37s
CI / build (push) Successful in 3m46s
CI / intent-service-test (push) Successful in 5m19s
CI / e2e (push) Successful in 11m22s
CI / test (push) Failing after 17m48s

This commit is contained in:
kyuno 2026-09-05 08:05:53 +02:00
commit df9693ffac
7 changed files with 1285 additions and 341 deletions

View file

@ -16,6 +16,9 @@ const butter = { id: 10, key: "butter" };
const pan = { id: 20, key: "pan" };
const gram = { id: 30, key: "gram" };
/** Long enough that `range={start:0,end:5}` ("Cuire") and a resolved metadata span at `[20,26)` ("Beurre") both fall within it — see {@link Harness}'s fixed `onRequestSpan` stub. */
const DESCRIPTION = "Cuire un peu de beurre.";
/**
* A real `StepDescription` resolves `onRequestSpan` into a fresh
* `resolvedMetadataSpan` via an actual browser text selection — out of
@ -29,18 +32,20 @@ function Harness({
previousTechStepId = null,
existingIngredients = [],
existingUtensils = [],
existingTemperatures = [],
onClose = () => {},
onSubmitted = () => {},
}: Partial<{
previousTechStepId: number | null;
existingIngredients: unknown[];
existingUtensils: unknown[];
existingTemperatures: unknown[];
onClose: () => void;
onSubmitted: (result: unknown) => void;
}>) {
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
kind: "ingredient" | "utensil" | "temperature";
range: { start: number; end: number };
text: string;
} | null>(null);
@ -52,13 +57,16 @@ function Harness({
<TechStepCorrectionPopover
recipeId={2}
stepId={2}
description={DESCRIPTION}
selectedText="Cuire"
range={{ start: 0, end: 5 }}
previousTechStepId={previousTechStepId}
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView/TemperatureView props — precise typing isn't the point here.
existingIngredients={existingIngredients as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingUtensils={existingUtensils as any}
// biome-ignore lint/suspicious/noExplicitAny: see above.
existingTemperatures={existingTemperatures as any}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={(kind) =>
setResolvedMetadataSpan({
@ -90,34 +98,40 @@ describe("TechStepCorrectionPopover", () => {
);
});
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
it("shows the selected text, the technique picker (searchable) and all three metadata sections together", () => {
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
// separate "pick, then metadata reveals itself" step, both render at
// once, and the technique catalog goes through the same searchable
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
// unfiltered list of the real ~74-entry catalog isn't browsable).
// separate "pick, then metadata reveals itself" step, all four
// sections render at once, and the technique catalog goes through the
// same searchable `CatalogSearchPicker` as the ingredient/utensil
// sub-flows (a plain unfiltered list of the real ~74-entry catalog
// isn't browsable) — visible by default since nothing's picked yet.
cy.get(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
).should("have.length", 2);
cy.contains("h4", "Ingrédients").should("be.visible");
cy.contains("h4", "Ustensiles").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Ustensiles").should("be.visible");
cy.contains(".tech-step-correction-popover__section-title", "Température").should("be.visible");
cy.contains("button", "Valider").should("be.visible");
});
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
cy.mount(<Harness previousTechStepId={null} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("not.exist");
cy.get(".tech-step-correction-popover__remove-match").should("not.exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
cy.mount(<Harness previousTechStepId={cook.id} />);
cy.wait("@getTechSteps");
cy.get(".tech-step-correction-popover__remove").should("exist");
cy.get(".tech-step-correction-popover__remove-match").should("exist");
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
// The picker itself hides behind "Changer" once something's already
// picked (see TechStepCorrectionPopover's own doc comment) — reopened
// here just to confirm it still marks the current pick correctly.
cy.contains("button", "Changer").click();
cy.contains(".catalog-search-picker__list button", "Cuire").should(
"have.class",
"catalog-search-picker__item--selected",
@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => {
cy.contains("button", "Valider").should("not.be.disabled");
});
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
it("submits the selected technique (no metadata touched) with ingredients/utensils/temperatures omitted from the request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => {
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "+ Ajouter un ingrédient").click();
cy.contains("button", "Ajouter un ingrédient").click();
cy.contains(".catalog-search-picker button", "Beurre").click();
cy.get('input[type="number"]').type("50");
cy.get("select").select(String(gram.id));
cy.contains("button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Beurre")
.should("contain.text", "Beurre")
.and("contain.text", "50 g");
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => {
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
],
utensils: [],
temperatures: [],
});
});
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
it("adds a temperature via the span-selection flow and its 3-way mode switch, included in the submitted request", () => {
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
statusCode: 201,
body: {
id: 1,
start: 0,
end: 5,
previousTechStep: null,
correctedTechStep: simmer,
createdAt: new Date().toISOString(),
},
}).as("submitCorrection");
cy.mount(<Harness />);
cy.wait("@getTechSteps");
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
"Mijoter",
).click();
cy.contains("button", "Ajouter une température").click();
// Defaults to the qualitative mode — switching to °C swaps the input
// shown, never both at once (mutually exclusive, see
// `manualStepTechStepTemperatureInputSchema`'s own doc comment,
// packages/shared).
cy.contains(".tech-step-correction-popover__mode-switch button", "°C").click();
cy.get('.tech-step-correction-popover__value-line input[type="number"]').type("180");
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
cy.contains(".tech-step-correction-popover__row", "180")
.should("contain.text", "°C")
.and("contain.text", "Beurre"); // the (stubbed) source quote
cy.contains("button", "Valider").click();
cy.wait("@submitCorrection")
.its("request.body")
.should("deep.equal", {
start: 0,
end: 5,
previousTechStepId: null,
correctedTechStepId: simmer.id,
ingredients: [],
utensils: [],
temperatures: [{ celsius: 180, gasMark: null, qualitative: null, start: 20, end: 26 }],
});
});
it("pre-seeds existing ingredients/utensils/temperatures, each removable via its own row", () => {
cy.mount(
<Harness
previousTechStepId={cook.id}
existingIngredients={[
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
]}
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
existingUtensils={[{ utensil: pan, start: 9, end: 15, source: "auto" }]}
existingTemperatures={[
{ celsius: null, gasMark: null, qualitative: "low", start: 16, end: 22, source: "auto" },
]}
/>,
);
// An existing match starts pre-selected on itself (see
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
// immediately, no need to re-pick "Cuire" from a list first.
// TechStepCorrectionPopover's own doc comment) — the metadata
// sections, pre-seeded from `existingIngredients`/`existingUtensils`/
// `existingTemperatures`, are visible immediately, no need to re-pick
// "Cuire" from a list first.
cy.wait("@getTechSteps");
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Feu doux").should("be.visible");
cy.contains(".tech-step-correction-popover__row", "Beurre")
.find(".tech-step-correction-popover__row-remove")
.click();
cy.contains(".tech-step-correction-popover__row", "Beurre").should("not.exist");
});
it("shows an error message and stays open when the submission fails", () => {

View file

@ -45,6 +45,21 @@ Feature: Managing a recipe from the catalog
When I focus the highlighted technique "Cuire"
Then the tooltip should show "Mijoter (correction manuelle)"
Scenario: Clicking a highlighted temperature reopens its technique's correction popover
# Same master-detail room requirement as recipes.cy.ts's own equivalent
# test — Cypress's default 1000×660 doesn't leave the detail panel
# (with the popover open below its step) enough height to show
# everything without its own scroll.
Given the viewport is 1600 by 900
And the recipe catalog contains "Gratin"
And recipe 4's detail is available
And the tech steps reference list includes preheat
When I visit "/recettes/4"
Then I should see the highlighted temperature "feu moyen"
When I click the highlighted temperature "feu moyen"
Then I should see the technique correction options
And the chosen technique should be "Préchauffer"
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
Given the recipe catalog contains "Omelette"
And recipe 2's detail is available

View file

@ -48,6 +48,50 @@ const omeletteDetail = {
],
};
/**
* A separate, self-contained fixture for the "clicking a temperature
* highlight" scenario — kept apart from `omeletteDetail` above rather than
* adding a temperature to its "Cuire" occurrence, so this doesn't entangle
* with that fixture's own "Corrects a detected technique" scenario (which
* relabels the same occurrence without touching metadata, and must keep
* meaning "nothing here changed").
*/
const gratinDetail = {
id: 4,
name: "Gratin",
description: null,
picture: null,
portions: 4,
authorId: 1,
visibility: "PERSONAL",
allergens: [],
diets: [],
isFavorite: false,
ingredients: [],
steps: [
{
id: 10,
// "Préchauffez" = [0, 11), "à feu moyen" = [20, 31) — counted char by
// char (accented/multi-byte characters here are each still one JS
// string index, same convention `start`/`end` use everywhere else).
description: "Préchauffez le four à feu moyen.",
picture: null,
order: 1,
techSteps: [
{
techStep: { id: 9, key: "preheat" },
start: 0,
end: 11,
source: "auto",
temperatures: [
{ celsius: null, gasMark: null, qualitative: "medium", start: 20, end: 31 },
],
},
],
},
],
};
Given("the disliked ingredients list is empty", () => {
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
});
@ -68,6 +112,23 @@ Given("deleting recipe 2 will succeed", () => {
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
});
Given("recipe 4's detail is available", () => {
cy.intercept("GET", "**/recipes/4", { statusCode: 200, body: gratinDetail });
});
// TechStepCorrectionPopover fetches the full technique catalog on mount
// (to resolve the chosen technique's own label) — a dedicated fixture
// naming just `preheat` (`gratinDetail`'s own technique), kept separate
// from "the tech steps reference list has options" above (that one's
// `cook`/`simmer` pair is purpose-built for the *other* scenario's
// relabel, not a generic catalog stand-in).
Given("the tech steps reference list includes preheat", () => {
cy.intercept("GET", "**/reference/tech-steps", {
statusCode: 200,
body: [{ id: 9, key: "preheat" }],
});
});
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
// existing match is `cook` (id 1) — see that fixture above. The response
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
source: "manual",
ingredients: [],
utensils: [],
temperatures: [],
},
],
},
@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => {
cy.get(".tech-step-correction-popover").should("be.visible");
});
Then("I should see the highlighted temperature {string}", (text: string) => {
// Same "off the fold, needs an explicit scroll" reasoning as
// common.steps.ts's "I should see the highlighted technique" — a bare
// `.should("be.visible")` doesn't auto-scroll.
cy.contains(".step-temperature", text).scrollIntoView().should("be.visible");
});
// Temperature is corrected through its owning technique's own popover, not
// on its own — see `StepDescription.tsx`'s `findTechStepForTemperature`.
When("I click the highlighted temperature {string}", (text: string) => {
cy.contains(".step-temperature", text).click();
});
Then("the chosen technique should be {string}", (label: string) => {
cy.contains(".tech-step-correction-popover__chosen-technique", label).should("be.visible");
});
// Picking a technique only *selects* it — it takes a separate "Valider"
// click to actually submit (room was made for attaching ingredient/utensil
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
@ -118,8 +197,13 @@ Then("I should see the technique correction options", () => {
// technique catalog is picked via the same searchable `CatalogSearchPicker`
// the ingredient/utensil sub-flows use, scoped to
// `__technique-section` since that same search-and-pick component is
// reused inside this popover for more than just techniques.
// reused inside this popover for more than just techniques. This step is
// only ever used to *relabel* an existing match (this feature's one
// scenario using it) — the picker hides behind "Changer" in that case
// (nothing to decide once a technique's already confirmed correct, see
// TechStepCorrectionPopover's own doc comment), so it's reopened first.
When("I choose {string} as the correct technique", (label: string) => {
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
cy.contains(
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
label,

View file

@ -673,7 +673,10 @@
text-decoration: underline dotted var(--color-primary);
text-decoration-thickness: 1px;
text-underline-offset: 0.15em;
cursor: help;
// 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 {
@ -702,17 +705,31 @@
// `--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: help;
cursor: pointer;
&:hover {
&:hover,
&:focus-visible {
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
outline: none;
}
&--high {
@ -746,6 +763,9 @@
// 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);
@ -754,30 +774,94 @@
&__selection {
font-weight: 600;
margin: 0 0 var(--space-sm);
margin: 0;
}
// Technique picker + Ingrédients/Ustensiles render together as one
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
// this section just needs its own small header row, the actual picker
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
// utensil sub-flows.
&__technique-section {
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
&__technique-header {
// 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;
align-items: baseline;
justify-content: space-between;
flex-direction: column;
gap: var(--space-sm);
}
&__remove {
&__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;
}
&__remove-match {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-error);
@ -792,7 +876,8 @@
}
}
&__cancel {
&__cancel,
&__cancel-mini {
background: none;
border: none;
color: var(--color-text-muted);
@ -802,113 +887,262 @@
font-size: var(--font-size-sm);
}
// Shown in place of the technique list/metadata sections while
// `StepDescription` is waiting on a second text selection (see
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
// 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 0 var(--space-sm);
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;
}
&__span-picker {
&__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);
}
&__quantity-line {
&__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;
}
}
&__confirm {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
// here anymore (re-picking happens directly through the search picker
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
// merged editor), just a small status line.
&__chosen-technique {
margin: 0 0 var(--space-xs);
font-weight: 600;
}
&__metadata-section {
margin-top: var(--space-sm);
h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// "+ Ajouter…" button — deliberately a plain text-link style, not
// another pill button (`.catalog-search-picker__list button`) — this
// is a secondary action inside an already-open popover, not a
// top-level choice competing with the chips above it.
> button {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-size: var(--font-size-sm);
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
list-style: none;
margin: 0 0 var(--space-xs);
padding: 0;
}
&__chip {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0.3rem 0.6rem;
font-size: var(--font-size-sm);
background: var(--color-surface);
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 {
background: none;
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;
padding: 0;
line-height: 1;
&:hover:not(:disabled) {
color: var(--color-error);
&.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 {
align-self: flex-start;
padding: 0.4rem 1rem;
font-size: var(--font-size-sm);
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-md);
border-radius: var(--radius-base);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;

View file

@ -1,4 +1,8 @@
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
import type {
StepTechStepTemperatureView,
StepTechStepView,
SubmitTechStepCorrectionResult,
} from "@batch-cooking/shared";
import { Fragment, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../../components/ui/Tooltip";
@ -7,6 +11,27 @@ import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
import { temperatureLabel } from "./temperature-label";
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
/**
* Finds which of `techSteps`' occurrences a given temperature mention
* belongs to — the read-only highlight flattens every occurrence's
* `temperatures` together (see this file's own `temperatures` above),
* losing that association, so a click handler that needs to reopen "the
* technique this temperature is attached to" re-derives it by matching
* span. `undefined` when nothing matches (shouldn't happen for a real
* persisted temperature, but degrades to "not clickable" rather than a
* crash — same posture as `use-text-selection.ts`'s `resolveOffset`).
*/
function findTechStepForTemperature(
techSteps: StepTechStepView[],
temperature: StepTechStepTemperatureView,
): StepTechStepView | undefined {
return techSteps.find((techStep) =>
(techStep.temperatures ?? []).some(
(candidate) => candidate.start === temperature.start && candidate.end === temperature.end,
),
);
}
/**
* A recipe step's description, with every detected technique's exact
* matched words highlighted and given a {@link Tooltip} naming the
@ -32,10 +57,15 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
* color, so the two are visually distinguishable at a glance rather than
* only via the tooltip text.
*
* `editable` (off by default) additionally lets the viewer select text or
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
* see `use-text-selection.ts` for how a browser selection is translated
* into an absolute `[start, end)` span. When `!editable`, every segment
* `editable` (off by default) additionally lets the viewer select text, or
* click an existing technique or temperature highlight, to open a
* {@link TechStepCorrectionPopover} — a temperature highlight reopens
* whichever technique occurrence it's attached to (see
* `findTechStepForTemperature` above), since temperature is corrected
* through that same popover, not on its own (same as ingredients/
* utensils). See `use-text-selection.ts` for how a browser selection is
* translated into an absolute `[start, end)` span. When `!editable`, every
* segment
* renders exactly as before (no extra wrapping elements, no `data-offset`,
* no click handlers) — this mode is purely additive, not a rewrite of the
* read-only rendering.
@ -90,18 +120,18 @@ export function StepDescription({
} | null>(null);
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
// (as an ingredient/utensil mention span) instead of opening a brand-new
// correction — set when that popover calls `onRequestSpan`, cleared once
// (as an ingredient/utensil/temperature mention span) instead of opening
// a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
// `handleMouseUp` resolves the selection below. See
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
// entirely alongside the still-visible, still-selectable description
// rather than needing the popover itself to move/hide.
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
null,
);
const [pendingSpanRequest, setPendingSpanRequest] = useState<
"ingredient" | "utensil" | "temperature" | null
>(null);
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
nonce: number;
kind: "ingredient" | "utensil";
kind: "ingredient" | "utensil" | "temperature";
range: TextSelectionRange;
text: string;
} | null>(null);
@ -185,23 +215,59 @@ export function StepDescription({
if (segment.temperature) {
// A detected temperature mention ("180°C", "feu doux") — its
// own subtle highlight + a Tooltip with the normalized value.
// Not correctable (no popover), same as utensils; still gets a
// `data-offset` in editable mode so a text selection spanning
// it resolves correctly.
// In editable mode, clicking it reopens the correction popover
// for whichever technique occurrence it's attached to (found by
// span, see `findTechStepForTemperature` — temperature is a
// per-clause metadata like ingredients/utensils, corrected
// through that technique's own popover, not on its own) — same
// `data-offset` treatment either way, so a text selection
// spanning it still resolves correctly.
const label = temperatureLabel(segment.temperature, t);
const modifier = segment.temperature.qualitative ?? "value";
const owner = editable
? findTechStepForTemperature(liveTechSteps, segment.temperature)
: undefined;
const className = `step-temperature step-temperature--${modifier}`;
if (owner === undefined || owner.start === null || owner.end === null) {
// Read-only, or (for a persisted temperature this shouldn't
// happen, see `findTechStepForTemperature`'s own doc comment)
// no owning occurrence with a real span to reopen — degrades
// to the same non-interactive span as before rather than a
// dead click.
return (
<Tooltip key={key} content={label}>
<span
className={`step-temperature step-temperature--${modifier}`}
data-offset={editable ? start : undefined}
>
<span className={className} data-offset={editable ? start : undefined}>
{segment.text}
</span>
</Tooltip>
);
}
const ownerStart = owner.start;
const ownerEnd = owner.end;
return (
<Tooltip key={key} content={label}>
<button
type="button"
className={className}
data-offset={start}
onClick={() => {
setPendingSpanRequest(null);
setResolvedMetadataSpan(null);
setActiveCorrection({
range: { start: ownerStart, end: ownerEnd },
selectedText: description.slice(ownerStart, ownerEnd),
previousTechStepId: owner.techStep.id,
});
}}
>
{segment.text}
</button>
</Tooltip>
);
}
if (!segment.techStep || !segment.isKeyword) {
// Context-only or plain run — rendered as plain text in
// read-only mode, same as before this component supported
@ -263,11 +329,13 @@ export function StepDescription({
<TechStepCorrectionPopover
recipeId={recipeId}
stepId={stepId}
description={description}
range={activeCorrection.range}
selectedText={activeCorrection.selectedText}
previousTechStepId={activeCorrection.previousTechStepId}
existingIngredients={activeStepTechStep?.ingredients ?? []}
existingUtensils={activeStepTechStep?.utensils ?? []}
existingTemperatures={activeStepTechStep?.temperatures ?? []}
resolvedMetadataSpan={resolvedMetadataSpan}
onRequestSpan={setPendingSpanRequest}
onClose={closeActiveCorrection}

View file

@ -2,46 +2,255 @@ import {
ErrorCode,
type IngredientView,
type StepTechStepIngredientView,
type StepTechStepTemperatureView,
type StepTechStepUtensilView,
type SubmitTechStepCorrectionResult,
type TechStepView,
type UnitView,
type UtensilView,
} from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import { temperatureLabel } from "./temperature-label";
import type { TextSelectionRange } from "./use-text-selection";
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
/** Which of the three metadata categories a span-selection sub-flow is currently for — see `onRequestSpan`/`resolvedMetadataSpan` below, and `StepDescription.tsx`'s own doc comment for the round-trip this drives. */
type MetadataKind = "ingredient" | "utensil" | "temperature";
/**
* One ingredient the viewer has attached (or is about to submit) — the
* trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept
* separately from `StepTechStepIngredientView` since a pending one has no
* resolved `IngredientView`/`UnitView` to carry yet, only ids. `quote` is
* purely for this popover's own row display (the exact description text at
* `[start, end)`, resolved once at seed/add time) — never sent over the
* wire, the server already knows its own text (see {@link confirm}).
*/
interface PendingIngredient {
ingredientId: number;
quantity: number | null;
unitId: number | null;
start: number;
end: number;
quote: string;
}
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
interface PendingUtensil {
utensilId: number;
start: number;
end: number;
quote: string;
}
/**
* Same as {@link PendingIngredient}, for a temperature — mutually-exclusive
* `celsius`/`gasMark`/`qualitative`, same convention as
* `StepTechStepTemperatureView`/`manualStepTechStepTemperatureInputSchema`
* (`packages/shared`). No `raw` here either: the server derives it from the
* step's own description at submit time, see that schema's own doc comment
* for why a client-supplied copy isn't trusted.
*/
interface PendingTemperature {
celsius: number | null;
gasMark: number | null;
qualitative: "low" | "medium" | "high" | null;
start: number;
end: number;
quote: string;
}
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
function toPendingIngredient(
view: StepTechStepIngredientView,
description: string,
): PendingIngredient {
return {
ingredientId: view.ingredient.id,
quantity: view.quantity,
unitId: view.unit?.id ?? null,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
return { utensilId: view.utensil.id, start: view.start, end: view.end };
function toPendingUtensil(view: StepTechStepUtensilView, description: string): PendingUtensil {
return {
utensilId: view.utensil.id,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
function toPendingTemperature(
view: StepTechStepTemperatureView,
description: string,
): PendingTemperature {
return {
celsius: view.celsius,
gasMark: view.gasMark,
qualitative: view.qualitative,
start: view.start,
end: view.end,
quote: description.slice(view.start, view.end),
};
}
// --- Icons ------------------------------------------------------------------
// Stroke-based, 24×24, matching the `lucide-react` line-icon language the
// rest of the app draws its icons from (see `layouts/nav-icons.tsx`'s own
// doc comment) — hand-drawn here rather than pulled from that shared barrel
// since these four are only ever used inside this one component, not worth
// promoting to an app-wide icon.
function TagIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 3H5a2 2 0 0 0-2 2v6l9.6 9.6a2 2 0 0 0 2.8 0l6-6a2 2 0 0 0 0-2.8L11 3Z" />
<circle cx="7.5" cy="7.5" r="1.2" />
</svg>
);
}
function LeafIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<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" />
</svg>
);
}
function PanIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="10" cy="14" r="6" />
<line x1="15.5" y1="9.5" x2="21" y2="4" />
</svg>
);
}
function ThermometerIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10 13.5V4a2 2 0 1 1 4 0v9.5a4 4 0 1 1-4 0Z" />
<line x1="12" y1="8" x2="14" y2="8" />
</svg>
);
}
function PlusIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}
function RemoveIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
);
}
/** A metadata section's icon + uppercase label + item count — Technique/Ingrédients/Ustensiles/Température all share this same header shape. */
function SectionHeader({ icon, title, count }: { icon: ReactNode; title: string; count?: number }) {
return (
<div className="tech-step-correction-popover__section-header">
{icon}
<span className="tech-step-correction-popover__section-title">
{title}
{count !== undefined && (
<span className="tech-step-correction-popover__section-count"> · {count}</span>
)}
</span>
</div>
);
}
/** One attached ingredient/utensil/temperature — name/value, the exact description text it was resolved from, an optional trailing badge (an ingredient's quantity), and a remove button. Ingrédients/Ustensiles/Température all render their pending list through this same row shape. */
function MetadataRow({
label,
quote,
badge,
onRemove,
removeLabel,
disabled,
}: {
label: string;
quote: string;
badge?: string;
onRemove: () => void;
removeLabel: string;
disabled: boolean;
}) {
return (
<li className="tech-step-correction-popover__row">
<div className="tech-step-correction-popover__row-body">
<span className="tech-step-correction-popover__row-name">{label}</span>
<span className="tech-step-correction-popover__row-quote">« {quote} »</span>
</div>
{badge !== undefined && (
<span className="tech-step-correction-popover__row-badge">{badge}</span>
)}
<button
type="button"
className="tech-step-correction-popover__row-remove"
onClick={onRemove}
disabled={disabled}
title={removeLabel}
aria-label={removeLabel}
>
<RemoveIcon />
</button>
</li>
);
}
/**
@ -57,37 +266,43 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
* position — simpler and more robust than tracking a caret-anchored
* position across scroll/resize, at the cost of a little visual distance
* from the selected text itself. That placement matters beyond cosmetics
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
* the viewer to select a second span of text without closing this popover
* first — the description stays fully visible and selectable the whole
* time, nothing overlays it.
* here: it's *why* the "attach an ingredient/utensil/temperature" flow
* below can ask the viewer to select a second span of text without closing
* this popover first — the description stays fully visible and selectable
* the whole time, nothing overlays it.
*
* **One merged editor, not a wizard**: picking a technique
* **One merged editor, not a wizard**: the technique picker
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
* entries, an unfiltered flat list wasn't browsable) and editing its
* Ingrédients/Ustensiles metadata render together on the same screen,
* always — there's no separate "pick, then a metadata step reveals
* itself" sequence to go through, and no dead end where metadata is
* technically attachable but not visible until some other action happens
* first. A single "Valider" submits everything at once; disabled until a
* entries, an unfiltered flat list wasn't browsable) and its Ingrédients/
* Ustensiles/Température metadata all render on the same screen, always —
* there's no separate "pick, then a metadata step reveals itself" sequence,
* and adding one metadata mention never hides the other sections or the
* technique picker while it's in progress (each section manages its own
* "adding" sub-form independently, see `activeSpan`/`awaitingSpanFor`
* below). A single "Valider" submits everything at once; disabled until a
* technique is actually selected (there's nothing to attach metadata to
* otherwise). **Removing** a match (`submit(null)`) stays its own
* immediate action next to the picker — nothing to attach when removing.
* The technique picker itself hides behind "Changer" once a technique is
* already picked (there's nothing to decide once it's confirmed correct) —
* shown by default only while nothing's picked yet.
*
* The two metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils` (whatever's already attached to this occurrence, auto-
* or manually-sourced — `[]` for a brand-new technique) and editable via
* add/remove — see `metadataTouched` below for why what's *displayed* here
* isn't automatically what gets *submitted*.
* The three metadata sections are pre-seeded from `existingIngredients`/
* `existingUtensils`/`existingTemperatures` (whatever's already attached to
* this occurrence, auto- or manually-sourced — `[]` for a brand-new
* technique) and editable via add/remove — see `metadataTouched` below for
* why what's *displayed* here isn't automatically what gets *submitted*.
*/
export function TechStepCorrectionPopover({
recipeId,
stepId,
description,
selectedText,
range,
previousTechStepId,
existingIngredients,
existingUtensils,
existingTemperatures,
resolvedMetadataSpan,
onRequestSpan,
onClose,
@ -95,14 +310,17 @@ export function TechStepCorrectionPopover({
}: {
recipeId: number;
stepId: number;
/** The step's full, original text — needed to show each attached ingredient/utensil/temperature's own source quote (`description.slice(start, end)`), not just the technique's own `selectedText`. */
description: string;
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
selectedText: string;
range: TextSelectionRange;
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
previousTechStepId: number | null;
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
/** Whatever ingredients/utensils/temperatures already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
existingIngredients: StepTechStepIngredientView[];
existingUtensils: StepTechStepUtensilView[];
existingTemperatures: StepTechStepTemperatureView[];
/**
* A text span `StepDescription` just resolved on this popover's behalf,
* after a call to `onRequestSpan` below — `null` until then. Identified
@ -112,26 +330,23 @@ export function TechStepCorrectionPopover({
*/
resolvedMetadataSpan: {
nonce: number;
kind: "ingredient" | "utensil";
kind: MetadataKind;
range: TextSelectionRange;
text: string;
} | null;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: "ingredient" | "utensil") => void;
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil/temperature mention, not a new technique correction" — see this component's own doc comment. */
onRequestSpan: (kind: MetadataKind) => void;
onClose: () => void;
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
}) {
const { t } = useTranslation();
const popoverRef = useRef<HTMLDivElement>(null);
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
// Opening this popover on an *already-detected* match (`previousTechStepId
// !== null`, i.e. the user clicked an existing highlight rather than
// selecting fresh text) starts pre-selected on that same technique, its
// name shown next to the picker right away — since the picker and the
// metadata sections render together regardless (see this component's own
// doc comment), this just saves re-picking the technique that's already
// correct before its metadata becomes editable.
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
// The technique picker hides behind "Changer" once something's already
// picked (see this component's own doc comment) — starts open only when
// there's nothing to show yet (a brand-new selection).
const [isChangingTechnique, setIsChangingTechnique] = useState(previousTechStepId === null);
const [catalogs, setCatalogs] = useState<{
ingredients: IngredientView[];
units: UnitView[];
@ -139,24 +354,28 @@ export function TechStepCorrectionPopover({
} | null>(null);
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
existingIngredients.map(toPendingIngredient),
existingIngredients.map((view) => toPendingIngredient(view, description)),
);
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
existingUtensils.map(toPendingUtensil),
existingUtensils.map((view) => toPendingUtensil(view, description)),
);
// Flips true the moment the viewer adds/removes a pending entry — never
// from the initial seeding above. `submit()` below only includes
// `ingredients`/`utensils` in the request when this is true, so
// relabeling/confirming a technique without ever opening either section
// leaves existing metadata completely alone server-side (see
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
// for why an *omitted* field — not an empty array — is what "don't
// touch it" means over the wire).
const [pendingTemperatures, setPendingTemperatures] = useState<PendingTemperature[]>(() =>
existingTemperatures.map((view) => toPendingTemperature(view, description)),
);
// Flips true the moment the viewer adds/removes a pending entry in *any*
// of the three metadata sections — never from the initial seeding above.
// `confirm()` below only includes ingredients/utensils/temperatures in
// the request when this is true, so relabeling/confirming a technique
// without ever touching a metadata section leaves existing metadata
// completely alone server-side (see `submitTechStepCorrectionSchema`'s
// own doc comment, `packages/shared`, for why an *omitted* field — not
// an empty array — is what "don't touch it" means over the wire). The
// three travel as one bundle: touching any one resends all three.
const [metadataTouched, setMetadataTouched] = useState(false);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
const [awaitingSpanFor, setAwaitingSpanFor] = useState<MetadataKind | null>(null);
const [activeSpan, setActiveSpan] = useState<{
kind: "ingredient" | "utensil";
kind: MetadataKind;
range: TextSelectionRange;
text: string;
} | null>(null);
@ -167,6 +386,14 @@ export function TechStepCorrectionPopover({
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
const [spanQuantity, setSpanQuantity] = useState("");
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
// Only meaningful while `activeSpan?.kind === "temperature"` — which of
// the three mutually-exclusive representations the mini-form currently
// shows an input for (see `manualStepTechStepTemperatureInputSchema`'s
// own doc comment, `packages/shared`, for why only one at a time).
const [tempMode, setTempMode] = useState<"celsius" | "gasMark" | "qualitative">("qualitative");
const [tempCelsiusValue, setTempCelsiusValue] = useState("");
const [tempGasMarkValue, setTempGasMarkValue] = useState("");
const [tempQualChoice, setTempQualChoice] = useState<"low" | "medium" | "high">("low");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -186,8 +413,8 @@ export function TechStepCorrectionPopover({
};
}, []);
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
// render alongside the technique picker from the start (see this
// Fetched unconditionally on mount — the Ingrédients/Ustensiles/Température
// sections render alongside the technique picker from the start (see this
// component's own doc comment on the merged editor), so there's no later
// point to defer this to anymore.
useEffect(() => {
@ -207,7 +434,7 @@ export function TechStepCorrectionPopover({
// Consumes a span `StepDescription` just resolved on this popover's
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
// matching sub-picker and clears the "awaiting a selection" hint.
// matching section's sub-form and clears the "awaiting a selection" hint.
useEffect(() => {
if (resolvedMetadataSpan === null) return;
setActiveSpan({
@ -219,6 +446,10 @@ export function TechStepCorrectionPopover({
setPickedIngredientId(null);
setSpanQuantity("");
setSpanUnitId(null);
setTempMode("qualitative");
setTempCelsiusValue("");
setTempGasMarkValue("");
setTempQualChoice("low");
// Depends on the whole object, not just `.nonce` — `StepDescription`
// only ever calls its setter with a brand-new object (never mutates
// one in place), so reference equality alone already gives this the
@ -265,7 +496,13 @@ export function TechStepCorrectionPopover({
end: range.end,
previousTechStepId,
correctedTechStepId: selectedTechStepId,
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
...(metadataTouched
? {
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
utensils: pendingUtensils.map(({ quote: _quote, ...rest }) => rest),
temperatures: pendingTemperatures.map(({ quote: _quote, ...rest }) => rest),
}
: {}),
});
onSubmitted(result);
onClose();
@ -276,7 +513,7 @@ export function TechStepCorrectionPopover({
}
}
function requestSpan(kind: "ingredient" | "utensil") {
function requestSpan(kind: MetadataKind) {
setAwaitingSpanFor(kind);
onRequestSpan(kind);
}
@ -300,6 +537,7 @@ export function TechStepCorrectionPopover({
unitId: spanUnitId,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
@ -311,7 +549,33 @@ export function TechStepCorrectionPopover({
if (activeSpan === null) return;
setPendingUtensils((prev) => [
...prev,
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
{
utensilId,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
}
function confirmTemperatureSpan() {
if (activeSpan === null) return;
const value =
tempMode === "celsius"
? { celsius: Number(tempCelsiusValue) || null, gasMark: null, qualitative: null }
: tempMode === "gasMark"
? { celsius: null, gasMark: Number(tempGasMarkValue) || null, qualitative: null }
: { celsius: null, gasMark: null, qualitative: tempQualChoice };
setPendingTemperatures((prev) => [
...prev,
{
...value,
start: activeSpan.range.start,
end: activeSpan.range.end,
quote: activeSpan.text,
},
]);
setMetadataTouched(true);
setActiveSpan(null);
@ -325,10 +589,18 @@ export function TechStepCorrectionPopover({
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
function removeTemperature(index: number) {
setPendingTemperatures((prev) => prev.filter((_, i) => i !== index));
setMetadataTouched(true);
}
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
const currentTechniqueLabel =
selectedTechStepId !== null
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
: null;
return (
<div className="tech-step-correction-popover" ref={popoverRef}>
@ -336,17 +608,100 @@ export function TechStepCorrectionPopover({
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
</p>
{awaitingSpanFor !== null ? (
{techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<>
<section className="tech-step-correction-popover__section tech-step-correction-popover__technique-section">
<SectionHeader
icon={<TagIcon />}
title={t("recipes.techStepCorrection.techniqueSection")}
/>
<div className="tech-step-correction-popover__chosen-technique">
{currentTechniqueLabel !== null ? (
<span className="tech-step-correction-popover__technique-pill">
{currentTechniqueLabel}
</span>
) : (
t("recipes.techStepCorrection.noTechniqueSelected")
)}
{currentTechniqueLabel !== null && !isChangingTechnique && (
<button
type="button"
className="tech-step-correction-popover__technique-change"
onClick={() => setIsChangingTechnique(true)}
>
{t("recipes.techStepCorrection.changeTechnique")}
</button>
)}
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove-match"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</div>
{(currentTechniqueLabel === null || isChangingTechnique) && (
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={(id) => {
setSelectedTechStepId(id);
setIsChangingTechnique(false);
}}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__ingredients-section">
<SectionHeader
icon={<LeafIcon />}
title={t("recipes.techStepCorrection.ingredientsSection")}
count={pendingIngredients.length}
/>
{pendingIngredients.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
return (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
key={index}
label={view ? ingredientLabel(view, t) : "…"}
quote={ingredient.quote}
badge={
ingredient.quantity !== null
? `${ingredient.quantity}${unit ? ` ${t(`catalog.units.${unit.key}`)}` : ""}`
: undefined
}
onRemove={() => removeIngredient(index)}
removeLabel={t("recipes.techStepCorrection.removeIngredient")}
disabled={isSubmitting}
/>
);
})}
</ul>
)}
{awaitingSpanFor === "ingredient" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan !== null ? (
<div className="tech-step-correction-popover__span-picker">
<p className="tech-step-correction-popover__selection">
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
</p>
{activeSpan.kind === "ingredient" ? (
pickedIngredientId === null ? (
) : activeSpan?.kind === "ingredient" ? (
<div className="tech-step-correction-popover__add-form">
{pickedIngredientId === null ? (
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
@ -368,7 +723,9 @@ export function TechStepCorrectionPopover({
/>
<select
value={spanUnitId ?? ""}
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
onChange={(e) =>
setSpanUnitId(e.target.value ? Number(e.target.value) : null)
}
aria-label={t("recipes.form.unitLabel")}
>
<option value="">{t("recipes.form.unitPlaceholder")}</option>
@ -378,12 +735,66 @@ export function TechStepCorrectionPopover({
</option>
))}
</select>
<button type="button" onClick={confirmIngredientSpan}>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
onClick={confirmIngredientSpan}
>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
)
)}
<button
type="button"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("ingredient")}
disabled={isSubmitting}
>
<PlusIcon />
{t("recipes.techStepCorrection.addIngredient")}
</button>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__utensils-section">
<SectionHeader
icon={<PanIcon />}
title={t("recipes.techStepCorrection.utensilsSection")}
count={pendingUtensils.length}
/>
{pendingUtensils.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
key={index}
label={view ? t(`catalog.utensils.${view.key}`) : "…"}
quote={utensil.quote}
onRemove={() => removeUtensil(index)}
removeLabel={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
/>
);
})}
</ul>
)}
{awaitingSpanFor === "utensil" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan?.kind === "utensil" ? (
<div className="tech-step-correction-popover__add-form">
<CatalogSearchPicker
items={(catalogs?.utensils ?? []).map((utensil) => ({
id: utensil.id,
@ -393,111 +804,169 @@ export function TechStepCorrectionPopover({
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
/>
)}
<button
type="button"
className="tech-step-correction-popover__cancel"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
</div>
) : techSteps === null ? (
<p>{t("recipes.loading")}</p>
) : (
<div className="tech-step-correction-popover__confirm">
<section className="tech-step-correction-popover__technique-section">
<div className="tech-step-correction-popover__technique-header">
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
{previousTechStepId !== null && (
<button
type="button"
disabled={isSubmitting}
onClick={removeMatch}
className="tech-step-correction-popover__remove"
>
{t("recipes.techStepCorrection.removeMatch")}
</button>
)}
</div>
<p className="tech-step-correction-popover__chosen-technique">
{selectedTechStepId !== null
? t("recipes.techStepCorrection.currentTechnique", {
technique: t(
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
),
})
: t("recipes.techStepCorrection.noTechniqueSelected")}
</p>
<CatalogSearchPicker
items={techSteps.map((techStep) => ({
id: techStep.id,
label: t(`catalog.techSteps.${techStep.key}`),
}))}
selectedId={selectedTechStepId ?? undefined}
onSelect={setSelectedTechStepId}
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
/>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingIngredients.map((ingredient, index) => {
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? ingredientLabel(view, t) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
{label}
<button
type="button"
onClick={() => removeIngredient(index)}
title={t("recipes.techStepCorrection.removeIngredient")}
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("utensil")}
disabled={isSubmitting}
>
✕
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
{t("recipes.techStepCorrection.addIngredient")}
</button>
</section>
<section className="tech-step-correction-popover__metadata-section">
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
<ul className="tech-step-correction-popover__chips">
{pendingUtensils.map((utensil, index) => {
const view = utensilById.get(utensil.utensilId);
return (
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
<li key={index} className="tech-step-correction-popover__chip">
{view ? t(`catalog.utensils.${view.key}`) : "…"}
<button
type="button"
onClick={() => removeUtensil(index)}
title={t("recipes.techStepCorrection.removeUtensil")}
disabled={isSubmitting}
>
✕
</button>
</li>
);
})}
</ul>
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
<PlusIcon />
{t("recipes.techStepCorrection.addUtensil")}
</button>
)}
</section>
<section className="tech-step-correction-popover__section tech-step-correction-popover__temperature-section">
<SectionHeader
icon={<ThermometerIcon />}
title={t("recipes.techStepCorrection.temperatureSection")}
count={pendingTemperatures.length}
/>
{pendingTemperatures.length > 0 && (
<ul className="tech-step-correction-popover__rows">
{pendingTemperatures.map((temperature, index) => (
<MetadataRow
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
key={index}
label={temperatureLabel(temperature, t)}
quote={temperature.quote}
onRemove={() => removeTemperature(index)}
removeLabel={t("recipes.techStepCorrection.removeTemperature")}
disabled={isSubmitting}
/>
))}
</ul>
)}
{awaitingSpanFor === "temperature" ? (
<p className="tech-step-correction-popover__hint">
{t("recipes.techStepCorrection.selectSpanHint")}
</p>
) : activeSpan?.kind === "temperature" ? (
<div className="tech-step-correction-popover__add-form">
<div className="tech-step-correction-popover__mode-switch">
<button
type="button"
className={tempMode === "celsius" ? "active" : ""}
onClick={() => setTempMode("celsius")}
>
{t("recipes.techStepCorrection.temperatureMode.celsius")}
</button>
<button
type="button"
className={tempMode === "gasMark" ? "active" : ""}
onClick={() => setTempMode("gasMark")}
>
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
</button>
<button
type="button"
className={tempMode === "qualitative" ? "active" : ""}
onClick={() => setTempMode("qualitative")}
>
{t("recipes.techStepCorrection.temperatureMode.qualitative")}
</button>
</div>
{tempMode === "celsius" && (
<div className="tech-step-correction-popover__value-line">
<input
type="number"
value={tempCelsiusValue}
onChange={(e) => setTempCelsiusValue(e.target.value)}
aria-label={t("recipes.techStepCorrection.temperatureMode.celsius")}
/>
<span className="tech-step-correction-popover__value-unit">°C</span>
</div>
)}
{tempMode === "gasMark" && (
<div className="tech-step-correction-popover__value-line">
<span className="tech-step-correction-popover__value-unit">
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
</span>
<input
type="number"
min="1"
step="1"
value={tempGasMarkValue}
onChange={(e) => setTempGasMarkValue(e.target.value)}
aria-label={t("recipes.techStepCorrection.temperatureMode.gasMark")}
/>
</div>
)}
{tempMode === "qualitative" && (
<div className="tech-step-correction-popover__qual-switch">
<button
type="button"
className={tempQualChoice === "low" ? "active" : ""}
onClick={() => setTempQualChoice("low")}
>
{t("recipes.temperature.qualitative.low")}
</button>
<button
type="button"
className={tempQualChoice === "medium" ? "active" : ""}
onClick={() => setTempQualChoice("medium")}
>
{t("recipes.temperature.qualitative.medium")}
</button>
<button
type="button"
className={tempQualChoice === "high" ? "active" : ""}
onClick={() => setTempQualChoice("high")}
>
{t("recipes.temperature.qualitative.high")}
</button>
</div>
)}
<div className="tech-step-correction-popover__add-form-actions">
<button
type="button"
className="tech-step-correction-popover__cancel-mini"
onClick={cancelSpanSelection}
>
{t("recipes.techStepCorrection.cancelSpanSelection")}
</button>
<button
type="button"
className="tech-step-correction-popover__confirm-mini"
onClick={confirmTemperatureSpan}
>
{t("recipes.techStepCorrection.addToList")}
</button>
</div>
</div>
) : (
<button
type="button"
className="tech-step-correction-popover__add-row"
onClick={() => requestSpan("temperature")}
disabled={isSubmitting}
>
<PlusIcon />
{t("recipes.techStepCorrection.addTemperature")}
</button>
)}
</section>
<div className="tech-step-correction-popover__actions">
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
<button
type="button"
className="tech-step-correction-popover__confirm-button"
@ -507,17 +976,10 @@ export function TechStepCorrectionPopover({
{t("recipes.techStepCorrection.confirm")}
</button>
</div>
</>
)}
{error && <p className="field-error">{error}</p>}
<button
type="button"
className="tech-step-correction-popover__cancel"
onClick={onClose}
disabled={isSubmitting}
>
{t("recipes.techStepCorrection.cancel")}
</button>
</div>
);
}

View file

@ -183,21 +183,29 @@
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
"confirm": "Valider",
"techniqueSection": "Technique",
"currentTechnique": "Technique retenue : {{technique}}",
"changeTechnique": "Changer",
"noTechniqueSelected": "Aucune technique sélectionnée.",
"searchTechniquePlaceholder": "Rechercher une technique…",
"noTechniqueFound": "Aucune technique trouvée.",
"ingredientsSection": "Ingrédients",
"utensilsSection": "Ustensiles",
"addIngredient": "+ Ajouter un ingrédient",
"addUtensil": "+ Ajouter un ustensile",
"temperatureSection": "Température",
"addIngredient": "Ajouter un ingrédient",
"addUtensil": "Ajouter un ustensile",
"addTemperature": "Ajouter une température",
"removeIngredient": "Retirer cet ingrédient",
"removeUtensil": "Retirer cet ustensile",
"removeTemperature": "Retirer cette température",
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
"cancelSpanSelection": "Annuler la sélection",
"searchUtensilPlaceholder": "Rechercher un ustensile…",
"noUtensilFound": "Aucun ustensile trouvé.",
"addToList": "Ajouter"
"addToList": "Ajouter",
"temperatureMode": {
"celsius": "°C",
"gasMark": "Thermostat",
"qualitative": "Qualitatif"
}
},
"tabs": {
"favoris": "Favoris",