diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts
index b3c23df..f63bb63 100644
--- a/apps/web/cypress/e2e/planning-page.cy.ts
+++ b/apps/web/cypress/e2e/planning-page.cy.ts
@@ -182,6 +182,49 @@ describe("Planning grid", () => {
cy.contains("Semaine du 10 au 16 août 2026").should("be.visible");
});
+ // Issue #110 — a slot with many recipes used to stretch its `
`, and
+ // with it the whole row and the table, past `.planning-grid-wrapper`
+ // into a vertical scroll (the other cells of that row just gaining dead
+ // space). The chip list now scrolls inside its own cell instead.
+ it("keeps a heavy slot's recipes scrolling inside the cell, not stretching the grid", () => {
+ cy.intercept("GET", /\/planning\?/, {
+ statusCode: 200,
+ body: {
+ id: 1,
+ startDate: "2026-08-17T00:00:00.000Z",
+ finishDate: "2026-08-23T00:00:00.000Z",
+ items: Array.from({ length: 14 }, (_, i) => ({
+ id: i + 1,
+ weekDay: "lundi",
+ meal: "dejeuner",
+ portions: 2,
+ recipe: { id: i + 1, name: `Recette ${i + 1}` },
+ })),
+ },
+ });
+
+ cy.visit("/");
+ cy.get(".recipe-chip").should("have.length", 14);
+
+ // The busy cell's own recipe list is now the scroller: it's capped and
+ // scrolls its own overflow instead of stretching the ` | ` (and with
+ // it the whole row, then the table past `.planning-grid-wrapper` into a
+ // tall vertical scroll).
+ cy.get(".meal-cell__recipes").should(($lists) => {
+ const list = [...$lists].find(
+ (el) => el.querySelectorAll(".recipe-chip").length === 14,
+ ) as HTMLElement;
+ expect(list, "the 14-recipe cell's list").to.exist;
+ expect(getComputedStyle(list).overflowY).to.be.oneOf(["auto", "scroll"]);
+ expect(list.scrollHeight).to.be.greaterThan(list.clientHeight);
+ expect(list.clientHeight).to.be.lessThan(200);
+ });
+
+ cy.get(".planning-grid-wrapper").should(($w) => {
+ expect($w[0].scrollHeight).to.be.lessThan($w[0].clientHeight * 1.25);
+ });
+ });
+
it("jumps to an arbitrary week by picking a day in the calendar popover", () => {
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning");
diff --git a/apps/web/src/pages/planning/planning-page.scss b/apps/web/src/pages/planning/planning-page.scss
index 0534110..b1d7f78 100644
--- a/apps/web/src/pages/planning/planning-page.scss
+++ b/apps/web/src/pages/planning/planning-page.scss
@@ -84,6 +84,18 @@ table.planning-grid {
border-collapse: collapse;
table-layout: fixed;
+ // Share the wrapper's height evenly across the five meal rows instead of
+ // letting each row size to its own content. A `height` on a table row is
+ // a minimum the browser then stretches to absorb the table's leftover
+ // height; an equal tiny value on every row spreads that leftover evenly,
+ // so the grid genuinely fills the screen rather than being only as tall
+ // as its busiest column (with dead space under the short ones). The
+ // per-cell scroll below is what then keeps a heavy slot from pushing its
+ // row past that share.
+ tbody tr {
+ height: 1px;
+ }
+
th,
td {
border: 1px solid var(--color-border);
@@ -178,12 +190,32 @@ table.planning-grid {
flex-direction: column;
gap: var(--space-xs);
height: 100%;
+ // The content must not be what defines the row's height: a slot with
+ // several recipes would otherwise stretch its ` | `, and with it the
+ // whole row (every other day's cell gaining the same dead height under
+ // `vertical-align: top`), then the table past the wrapper into a
+ // vertical scroll. `min-height: 0` lets this flex column shrink to its
+ // share of the row so `.meal-cell__recipes` below can take over the
+ // scrolling.
+ min-height: 0;
}
.meal-cell__recipes {
display: flex;
flex-direction: column;
gap: var(--space-xs);
+ // A slot only ever holds a handful of recipes in real use; past that the
+ // list scrolls inside the cell instead of stretching the ` | ` (and
+ // with it the whole row, then the table past `.planning-grid-wrapper`
+ // into a vertical scroll — issue #110). A `rem` cap rather than a `%` of
+ // the cell: percentage heights against a ` | ` don't resolve
+ // consistently across engines. `flex: 1` still lets the list use the
+ // row's spare height up to that cap. `.add-recipe-btn` is a flex sibling
+ // (outside this box), so it stays pinned just below, always reachable.
+ flex: 1;
+ min-height: 0;
+ max-height: 8.5rem;
+ overflow-y: auto;
}
.recipe-chip {
|