feat(recipes): refond le panneau de correction des métadonnées de tech-step #30

Merged
kyuno merged 1 commit from feat/tech-step-metadata-editor-redesign into feat/tech-step-temperature-correction 2026-09-05 08:05:55 +02:00
7 changed files with 1285 additions and 341 deletions
Showing only changes of commit 6c5978dd1c - Show all commits

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;
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;
}
}
&__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;
&__value-unit {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
}
// 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);
}
}
}
&__confirm-button {
align-self: flex-start;
padding: 0.4rem 1rem;
&__qual-switch {
display: flex;
gap: var(--space-xs);
button {
flex: 1;
border: 1.5px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
&.active {
border-color: var(--color-accent);
background: var(--color-accent);
color: var(--color-surface);
}
}
}
&__confirm-mini {
background: var(--color-accent);
color: var(--color-surface);
border: none;
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
font-weight: 700;
padding: var(--space-xs) var(--space-md);
cursor: pointer;
&:hover {
background: var(--color-accent-hover);
}
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-md);
padding-top: var(--space-xs);
}
&__confirm-button {
padding: var(--space-sm) var(--space-lg);
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-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,19 +215,55 @@ 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={className} data-offset={editable ? start : undefined}>
{segment.text}
</span>
</Tooltip>
);
}
const ownerStart = owner.start;
const ownerEnd = owner.end;
return (
<Tooltip key={key} content={label}>
<span
className={`step-temperature step-temperature--${modifier}`}
data-offset={editable ? start : undefined}
<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}
</span>
</button>
</Tooltip>
);
}
@ -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

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