Skip to content

Commit 0c8fa84

Browse files
authored
fix(grid): make the resize-handle geometry the spec states actually render (#332)
1 parent fb501f2 commit 0c8fa84

8 files changed

Lines changed: 689 additions & 50 deletions

File tree

docs/changes/0036-theming-contract-gaps.md

Lines changed: 21 additions & 3 deletions
Large diffs are not rendered by default.

docs/specs/grid-layout/index.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,17 @@ Why this needs stating: the stored-to-effective mapping is deliberately lossy in
307307
### Touch-First Sizing
308308

309309
- Interactive grid affordances MUST be enlarged on coarse-pointer devices: resize handles MUST grow to at least `32×32` (edge handles `32×60` / `60×32`) under `@media (pointer: coarse)`, and cards MUST provide active-press feedback and a tap-highlight there.
310+
- The enlarged handle MUST also be **visible** without a hover, and MUST stay an affordance ON the card rather than covering it. `react-grid-layout` hides every handle at `opacity: 0` until `:hover`, an event a touch device never sends, so a floor met in size alone ships a target nobody can see.
311+
- **A tile MUST keep a surface that is not a resize handle, and that outranks the floor.** The floor is written in pixels and a grid cell is not: at the 8-column `tablet` breakpoint on a 480px viewport a 1×1 tile measures 33×51, and eight handles at the sizes above cover every pixel of it — measured, 0 of 100 sampled points left free. A card whose whole surface is a handle cannot be dragged, tapped or opened. So no handle may exceed **40%** of its tile on either axis; where that binds, the handle is smaller than the floor asks and the card stays operable. The cap is inert on any tile large enough to honour the floor (about 150px for a 60px edge handle).
312+
- **This rule was unmet for the whole of its existence, and the reason is a cascade fact rather than a missing rule.** `GridLayoutSection.css` has always contained exactly the geometry above, one class shallower than either grid package selects the same handles — and both packages shipped **unlayered**, which outranks every cascade layer regardless of specificity. A coarse pointer therefore got the same 20×20 handles a mouse did. Closed by change [0036](../../changes/0036-theming-contract-gaps.md) PR 5, which demoted `react-grid-layout` and `react-resizable` into `liebe-base.vendor` and reconciled the two rule sets.
310313

311314
#### Scenario: Coarse pointer enlarges resize handles
312315

313316
- **GIVEN** a coarse-pointer device
314317
- **WHEN** the grid renders in edit mode
315-
- **THEN** `.react-resizable-handle` is sized `32×32` (`src/components/GridLayoutSection.css:244`)
318+
- **THEN** the corner handles measure at least `32×32` and the edge handles `32×60` / `60×32`, each visible without hover and each smaller than the card it resizes — unless the tile is too small to hold them, where the 40% cap applies instead and the tile keeps a free surface (`tests/e2e/grid-handle-geometry.spec.ts`)
319+
320+
**The evidence is e2e and cannot be anything else.** The stylesheet said `32×32` throughout the years the handle measured 20×20, so a test reading the CSS would have passed the whole time; only a real cascade in a real browser can answer it. The upper bound is in the scenario for the same reason — the first run after the demotion met every lower bound while all eight handles measured the full card, because a neighbouring rule (`.grid-item > *`) had started matching them too.
316321

317322
## Design
318323

@@ -384,7 +389,7 @@ Store actions consumed by the grid layer (defined in `../dashboard-config/`): `u
384389
- **`GridLayoutSection`** (`src/components/GridLayoutSection.tsx`): `react-grid-layout` config, responsive column scaling, row-height measurement, layout-change persistence.
385390
- **`GridCard`** (`src/components/GridCard.tsx`): shared card chrome and compound sub-components (`Icon`, `Title`, `Controls`, `Status`); size → `minHeight`/`padding`/font-size mapping; fullscreen portal.
386391
- **`Separator`** (`src/components/Separator.tsx`) and **`TextCard`** (`src/components/TextCard.tsx`): non-entity grid item types, each carrying a static `defaultDimensions`.
387-
- **`GridLayoutSection.css`**: `react-grid-layout` overrides, resize-handle styling, coarse-pointer touch sizing.
392+
- **`GridLayoutSection.css`**: `react-grid-layout` overrides, resize-handle styling, coarse-pointer touch sizing. Its handle rules only render because both grid packages sit in `liebe-base.vendor` (`src/theme/cssLayers.ts`); the resets on `.react-resizable-handle` are what keeps the vendor's own centring, rotation and grip image from composing with Liebe's.
388393

389394
### Business Logic
390395

@@ -445,7 +450,7 @@ item.width >= 4 && item.height >= 3
445450
- **Absolute item positioning (`positionStrategy={absoluteStrategy}`)**: items are laid out with `top`/`left`, not `transform: translate(...)`. This is a deliberate global choice so no grid-item transform creates a containing block that would trap the camera card's in-place `position: fixed` fullscreen overlay ([../camera-streaming/](../camera-streaming/index.md#fullscreen)); the trade-off is `top`/`left` positioning instead of compositor-friendly transforms.
446451
- **compactType null + preventCollision**: user positions are never auto-compacted at render time; compaction happens only on explicit `reorderGrid`.
447452
- **Integer grid cells against stored resolution**: all persisted coordinates are integers relative to `resolution.columns`. The stored-to-effective mapping is deliberately lossy — a span floored at one cell and an `x` clamped into bounds cannot be inverted — so only a field the user actually moved is ever scaled back. A genuine move is therefore coarse: it is rounded to the nearest stored column, landing on an exact multiple of the ratio where the ratio is a whole number (a drag to effective column 2 of 4 stores `x: 6` of 12) and on a rounded approximation where it is not (12 columns at the 8-column `tablet` breakpoint), and it may be clamped further to keep the item on screen. Narrow breakpoints therefore offer coarser placement than the stored resolution, not corrupted placement.
448-
- **Touch targets**: coarse-pointer resize handles are enlarged to ≥32px; the project's 44px touch-target principle is only partially met by handle CSS.
453+
- **Touch targets**: coarse-pointer resize handles are enlarged to ≥32px (`32×60` / `60×32` on the edges) and revealed without hover — live since change [0036](../../changes/0036-theming-contract-gaps.md) PR 5, and previously stated by CSS that never rendered. The project's 44px touch-target principle is still only partially met by handle CSS: 32 is the number the grid spec states, not 44.
449454
- **Whole-card drag**: dragging is enabled on the entire card, relying on `draggableCancel` to exclude interactive controls (buttons, inputs, `[role='button']`, `.no-drag`).
450455

451456
## Open Questions

docs/specs/theming/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The token contract exists so that theming is data, not code: the Liquid Glass th
6767
- **The baseline is three ordered tiers, because "the baseline" spans CSS with opposite needs**: `liebe-base.reset` (the universal reset) → `liebe-base.vendor` (vendored sheets that something first-party has to outrank — Radix Themes today) → `liebe-base` itself (the token sheet, the anatomy, the component styles, the touch floor, and every other vendored sheet). All three still lose to `liebe-theme` and `liebe-user` as one block, so the three layers this contract names remain the three a theme reasons about. Each tier wins only over the one before it, and each of the two boundaries exists for a measured reason:
6868
- **A vendored sheet MUST NOT share a layer with Liebe's own rules.** Inside one layer the cascade falls back to specificity, and a vendor sheet's class selectors outrank the element selectors a global baseline rule is written with. That is what silently unenforced the [44px touch floor](../design-system/index.md#card-anatomy): `app.css` declares it on bare `button` / `input` / `textarea`, Radix's `.rt-reset` declares `min-height: 0` on the same elements, so under an emulated coarse pointer a `size="3"` button measured 40px with a computed `min-height` of `0px` and a text field 38px at `auto`. Below the baseline, a Liebe rule wins by where it is rather than by out-specifying selectors that change on every dependency upgrade — which is also what makes Radix restyleable by the same cascade as everything else. **The floor MUST still lose to a token:** the anatomy's own sizes are class selectors in `liebe-base` itself, so the 34px `--liebe-chip-height` chip and the 38px pill stand, meeting the touch rule on the inline axis instead.
6969
- **The universal reset MUST sit below the vendored sheets**, because a reset is written to lose: `* { padding: 0; margin: 0 }` has zero specificity and normalises user-agent defaults only because origin beats layering. Put it level with a component sheet and it zeroes every padding that sheet declares — the reason it was layered at all in [0010](../../changes/0010-design-tokens-and-anatomy.md) was that unlayered, it did exactly that to the card anatomy. Left in `liebe-base` once the vendored sheets moved beneath, it did the same to Radix: table cells lost 16px of padding and inline code 5px, measured across 156 of the workshop's 622 stories.
70-
- **Which vendored sheets are demoted is a decision per package, not a rule about `node_modules`.** Demotion does not only let a first-party rule win where it was usefully losing; it activates **every** first-party rule that was losing to that sheet, including rules written against a cascade in which they never applied and which nobody has seen render. A sheet therefore joins `liebe-base.vendor` when something needs to outrank it and the consequences have been measured — and a package that nothing needs to outrank stays in `liebe-base`, where the cascade decides it against Liebe's own rules on specificity, exactly as before. `react-grid-layout` is the measured counter-example: it selects the grid's drag handles one class deeper than `GridLayoutSection.css` does, so Liebe's handle rules have never rendered, and demoting the package made them live — the south handle went from a 28×28 rotated square to a 40×20 bar shifted 16px left, enough to cover a card's action button and swallow its click. Those first-party rules may be the better design, and they carry the same touch-target intent as the floor; making them live is a change to how the grid is operated, with its own evidence to gather.
70+
- **Which vendored sheets are demoted is a decision per package, not a rule about `node_modules`.** Demotion does not only let a first-party rule win where it was usefully losing; it activates **every** first-party rule that was losing to that sheet, including rules written against a cascade in which they never applied and which nobody has seen render. A sheet therefore joins `liebe-base.vendor` when something needs to outrank it and the consequences have been measured — and a package that nothing needs to outrank stays in `liebe-base`, where the cascade decides it against Liebe's own rules on specificity, exactly as before. `react-grid-layout` is the worked example of what that costs, and it is now on the list — with `react-resizable`, because each styles the grid's handles through a selector the other does not. Demoting them made a body of first-party handle rules render for the first time, the coarse-pointer touch floor [grid-layout](../grid-layout/index.md#touch-first-sizing) states among them. **What a demotion owes before it lands, and what these two paid:** browser evidence that the affected component still behaves — here, that the handles are the geometry the grid spec states, that they neither intercept a card's own controls nor cover the tile, and that resizing still works. The measurements, the rules that had to be reconciled and the regression an unreconciled demotion produced are recorded in [0036](../../changes/0036-theming-contract-gaps.md) PRs 1 and 5. Two lessons keep: making dead rules live is a change to how a component is **operated**, not a cascade tidy; and the rules it wakes are not only the ones the sheet was demoted for.
7171
- **A dependency that ships its own `@layer` is wrapped anyway.** The layer a dependency authored is _its_ layer, registered wherever it was first seen — after `liebe-user` in the common case — so passing such a sheet through unwrapped would let its ordinary declarations outrank the theme and the user. Nesting it inside `liebe-base.vendor` keeps its internal order and contains it.
7272

7373
A single `@layer` statement in every sheet names all five positions, sub-layers included: a sub-layer's place inside its parent is fixed by first appearance just as a top-level layer's is, so leaving the two out would order the reset against the vendored sheets by whichever the bundler happened to emit first. Verified by measuring rendered controls under an emulated coarse pointer, because layer precedence is not observable from stylesheet text at all — and, for a demoted package, by measuring what the demotion made live as well as what it fixed. **Done by [0036](../../changes/0036-theming-contract-gaps.md) PR 1.**

src/components/GridLayoutSection.css

Lines changed: 112 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,49 @@
5252
flex-direction: column;
5353
}
5454

55-
/* Resize handles */
55+
/*
56+
* Resize handles.
57+
*
58+
* These rules are one class shallower than either vendor sheet styles the
59+
* same elements — `react-resizable` uses `.react-resizable-handle`,
60+
* `react-grid-layout` uses `.react-grid-item > .react-resizable-handle.react-resizable-handle-s`
61+
* — so for as long as those sheets shipped unlayered, not one declaration
62+
* below here rendered. Both packages are now in `liebe-base.vendor`
63+
* (`src/theme/cssLayers.ts`), which puts this block in charge.
64+
*
65+
* The three resets on the base rule are what that costs, and each neutralises
66+
* a vendor declaration Liebe has no counterpart for — so without them the
67+
* vendor's value survives underneath Liebe's and the two compose into
68+
* something neither sheet describes:
69+
*
70+
* - `background-image`: react-resizable paints a grey grip SVG. Liebe draws
71+
* its own dot in `::after`, so the vendor's would sit behind it.
72+
* - `margin`: react-resizable centres edge handles with `margin-left: -10px`
73+
* / `margin-top: -10px` while Liebe centres them with a translate. Both
74+
* apply, and the handle lands half its own width PLUS ten pixels off
75+
* centre — the "40×20 bar shifted 16px left" that made demotion wait for
76+
* this reconciliation.
77+
* - `transform`: react-resizable rotates each handle to point its grip
78+
* outward (45°, 135°, 225°, 315°). Liebe overrides that on the four edge
79+
* handles because it needs the property for centring, but sets no
80+
* transform on the corners — so their rotation would survive and spin the
81+
* `::after` dot to the wrong corner of a box whose own square shape hides
82+
* that the rotation happened at all.
83+
*
84+
* `padding` goes with them for tidiness rather than necessity: it is the
85+
* vendor's inset for that grip image, and an absolutely positioned `::after`
86+
* resolves against the padding box, so it changes nothing once the image is
87+
* gone. Reset anyway, so the box this file describes is the box that renders.
88+
*/
5689
.react-resizable-handle {
5790
position: absolute;
5891
width: 20px;
5992
height: 20px;
6093
background-color: transparent;
94+
background-image: none;
95+
margin: 0;
96+
padding: 0;
97+
transform: none;
6198
z-index: 1;
6299
}
63100

@@ -207,8 +244,26 @@
207244
bottom: 6px;
208245
}
209246

210-
/* Ensure ButtonCard fills the grid item */
211-
.grid-item > * {
247+
/*
248+
* Ensure the card fills the grid item.
249+
*
250+
* `:not(.react-resizable-handle)` is load-bearing, and it is the rule that
251+
* made demoting the grid packages a real change rather than a cascade tidy.
252+
* `react-grid-layout` puts the eight resize handles in the SAME element as the
253+
* card — `.react-grid-item` and `.grid-item` are one node, and the handles are
254+
* its direct children — so an unqualified `.grid-item > *` matches every
255+
* handle too and stretches each to the full card.
256+
*
257+
* That never showed while the vendor sheets were unlayered: their
258+
* `.react-grid-item > .react-resizable-handle { width: 20px }` out-specified
259+
* this rule and held the handles at 20×20. Demotion reversed it, and because
260+
* this selector is both later in the file and no less specific than
261+
* `.react-resizable-handle`, it won — eight invisible full-card handles
262+
* stacked over the tile, the last of them (`s`) swallowing every press. The
263+
* grid still resized, from whichever handle happened to be on top, which is
264+
* why the symptom was a card that only ever changed height.
265+
*/
266+
.grid-item > *:not(.react-resizable-handle) {
212267
width: 100%;
213268
height: 100%;
214269
}
@@ -228,25 +283,21 @@
228283
width: 24px;
229284
height: 24px;
230285
}
231-
232-
/* Disable hover effects on touch devices */
233-
@media (pointer: coarse) {
234-
.react-grid-item:hover .react-resizable-handle::after {
235-
opacity: 0;
236-
}
237-
238-
/* Show resize handles when in edit mode */
239-
.react-grid-item.react-draggable .react-resizable-handle::after {
240-
opacity: 1;
241-
}
242-
}
243286
}
244287

245288
@media (min-width: 768px) and (max-width: 1023px) {
246289
/* Tablet adjustments */
247290
}
248291

249-
/* Touch-friendly adjustments */
292+
/*
293+
* Touch-friendly adjustments.
294+
*
295+
* The floor [grid-layout](../../docs/specs/grid-layout/index.md#touch-first-sizing)
296+
* states as a MUST, and the reason the whole block above had to become live:
297+
* these three rules are the grid's half of the project's touch-target
298+
* principle, and for as long as the vendor sheets outranked them a coarse
299+
* pointer got the same 20×20 handles a mouse did.
300+
*/
250301
@media (pointer: coarse) {
251302
/* Larger touch targets for resize handles */
252303
.react-resizable-handle {
@@ -265,5 +316,50 @@
265316
width: 60px;
266317
height: 32px;
267318
}
319+
320+
/*
321+
* And never more than a share of the tile, whatever the numbers above say.
322+
*
323+
* The floor is written in pixels and a grid cell is not: at the 8-column
324+
* `tablet` breakpoint on a 480px viewport a 1×1 tile measures 33×51, and a
325+
* touch device asking for 32×32 corners with 60px edges puts eight handles
326+
* over every pixel of it — measured, 0 of 100 sampled points left uncovered.
327+
* A card whose whole surface is a resize handle cannot be dragged, tapped or
328+
* opened, so on the smallest tiles the floor and the card's own operability
329+
* are in direct conflict.
330+
*
331+
* The drag surface wins, which is the precedence
332+
* [grid-layout](../../docs/specs/grid-layout/index.md#touch-first-sizing)
333+
* states: a handle below its target is smaller than we would like, a tile
334+
* nobody can grab is broken. 40% is the largest share that leaves the tile's
335+
* centre reachable with all eight handles present, and it binds only on
336+
* tiles too small to have honoured the floor anyway — on any tile wide
337+
* enough for a 60px edge handle (150px and up) it is inert.
338+
*/
339+
.react-resizable-handle {
340+
max-width: 40%;
341+
max-height: 40%;
342+
}
343+
344+
/*
345+
* And visible, which the size alone does not buy. `react-grid-layout` hides
346+
* every handle at `opacity: 0` and reveals it on `:hover`, an event a touch
347+
* device never sends — so enlarging the target without this ships a 32×32
348+
* hit area nobody can see.
349+
*
350+
* This replaces a rule that tried the same thing and could not work: the
351+
* old one raised the `::after` dot's opacity while the vendor kept the
352+
* handle *containing* it at zero, and opacity composites down a subtree. It
353+
* was also nested inside the `max-width: 767px` block, so it said nothing
354+
* about a tablet. Revealing the handle itself is what the intent needed.
355+
*
356+
* No edit-mode qualifier: in view mode the grid passes no resize handles at
357+
* all (grid-layout — eight in edit mode, none in view), so a handle in the
358+
* DOM already means edit mode.
359+
*/
360+
.react-resizable-handle,
361+
.react-resizable-handle::after {
362+
opacity: 1;
363+
}
268364
}
269365
}

0 commit comments

Comments
 (0)