@12-apps/ui 1.11.1 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.10.1",
197
+ "@12-apps/forms-core": "^1.11.1",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.11.1",
213
- "@12-apps/typescript-config": "^1.11.1",
212
+ "@12-apps/eslint-config": "^1.12.2",
213
+ "@12-apps/typescript-config": "^1.12.2",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -14,6 +14,7 @@ import { Text } from "../../typography/Text";
14
14
 
15
15
  import { DragHandle, useDragItem } from "./data-views-drag";
16
16
  import {
17
+ CARD_RADIUS,
17
18
  cardSurfaceStyles,
18
19
  isActionable,
19
20
  isSelectable,
@@ -28,9 +29,11 @@ export interface BaseCardProps extends CardSurfaceProps {
28
29
  /** Tile proportion. Defaults to "4:3". */
29
30
  aspectRatio?: CardAspectRatio;
30
31
  /**
31
- * Size multiplier from the zoom slider (1 = base). Scales the card's padding +
32
- * typography in step with its width (the grid sizes the track to base × scale),
33
- * so the whole card grows together — a card at scale 2 renders at ~twice the
32
+ * Size multiplier from the zoom slider (1 = base).
33
+ *
34
+ * Applied as `zoom`, so the whole card grows together — padding, typography,
35
+ * the checkbox, the menu and the media alike — in step with its width, which
36
+ * the grid sizes to base × scale. A card at scale 2 renders at ~twice the
34
37
  * size, its proportion preserved by `aspectRatio`. Default 1.
35
38
  */
36
39
  scale?: number;
@@ -58,16 +61,26 @@ export interface BaseCardProps extends CardSurfaceProps {
58
61
  checkboxTestId?: string;
59
62
  }
60
63
 
61
- /** The outlined-tile style: fixed ratio, scaled padding, selected/dimmed states. */
64
+ /**
65
+ * The outlined-tile style: fixed ratio, scaled padding, selected/dimmed states.
66
+ *
67
+ * See the note on the row: `zoom` scales EVERY length in the subtree, including
68
+ * the MUI internals (checkbox, chip, icon button) that no prop of ours reaches.
69
+ * The grid still sizes the track to BASE_CARD_WIDTH x scale, so the tile fills
70
+ * its track physically and lays its contents out in the unscaled box behind it
71
+ * — which is what "the whole card grows together" was always meant to mean.
72
+ */
62
73
  function cardSx(opts: {
63
74
  aspectRatio: CardAspectRatio;
64
75
  pad: number;
76
+ scale: number;
65
77
  interactive: boolean;
66
78
  draggable: boolean;
67
79
  }): CardSx {
68
- const { aspectRatio, pad, interactive, draggable } = opts;
80
+ const { aspectRatio, pad, scale, interactive, draggable } = opts;
69
81
  return {
70
82
  position: "relative",
83
+ zoom: scale,
71
84
  aspectRatio: String(CARD_ASPECT_RATIOS[aspectRatio]),
72
85
  display: "flex",
73
86
  flexDirection: "column",
@@ -78,6 +91,35 @@ function cardSx(opts: {
78
91
  };
79
92
  }
80
93
 
94
+ /**
95
+ * How far the first block in flow must start below the tile's top edge to clear
96
+ * the absolute overlays: their 4px inset plus the checkbox's 38px hit area (20px
97
+ * glyph in MUI's 9px padding), which is the taller of the two corners.
98
+ */
99
+ const OVERLAY_BAND_PX = 42;
100
+
101
+ /**
102
+ * The top margin each stacked slot needs, in MUI spacing units.
103
+ *
104
+ * The overlays are absolute, so they cost the flow nothing — over MEDIA that is
105
+ * the whole point (a checkbox on a thumbnail reads as a checkbox on a
106
+ * thumbnail). Over TEXT it is a collision: a title-only tile started its first
107
+ * line at the padding edge, directly under the checkbox, and rendered
108
+ * `Selecionada` with the box sitting on the `Se`. So when nothing pictorial is
109
+ * there to absorb them, whichever block comes first clears the band instead.
110
+ */
111
+ function contentGaps(opts: {
112
+ hasMedia: boolean;
113
+ hasBody: boolean;
114
+ overlays: boolean;
115
+ pad: number;
116
+ }): { body: number; caption: number } {
117
+ const { hasMedia, hasBody, overlays, pad } = opts;
118
+ const gap = pad * 0.5;
119
+ const clear = !hasMedia && overlays ? Math.max(0, OVERLAY_BAND_PX / 8 - pad) : 0;
120
+ return { body: hasMedia ? gap : clear, caption: hasMedia || hasBody ? gap : clear };
121
+ }
122
+
81
123
  /** The top-corner overlays: the select checkbox (left) and the menu slot (right). */
82
124
  function CardOverlays({
83
125
  selected,
@@ -141,12 +183,10 @@ function CardMedia({ image, imageFallback }: Pick<BaseCardProps, "image" | "imag
141
183
  function CardBodyContent({
142
184
  children,
143
185
  fill,
144
- scale,
145
186
  topGap,
146
187
  }: {
147
188
  children: ReactNode;
148
189
  fill: boolean;
149
- scale: number;
150
190
  topGap: number;
151
191
  }): React.JSX.Element {
152
192
  return (
@@ -158,7 +198,7 @@ function CardBodyContent({
158
198
  flexDirection: "column",
159
199
  alignItems: "center",
160
200
  justifyContent: "center",
161
- gap: 0.75 * scale,
201
+ gap: 0.75,
162
202
  mt: topGap,
163
203
  overflow: "hidden",
164
204
  }}
@@ -172,12 +212,10 @@ function CardBodyContent({
172
212
  function CardCaption({
173
213
  title,
174
214
  subtitle,
175
- scale,
176
215
  topGap,
177
216
  }: {
178
217
  title?: ReactNode;
179
218
  subtitle?: ReactNode;
180
- scale: number;
181
219
  topGap: number;
182
220
  }): React.JSX.Element {
183
221
  return (
@@ -190,7 +228,7 @@ function CardCaption({
190
228
  as="p"
191
229
  style={{
192
230
  lineHeight: 1.15,
193
- fontSize: `${0.95 * scale}rem`,
231
+ fontSize: "0.95rem",
194
232
  overflow: "hidden",
195
233
  textOverflow: "ellipsis",
196
234
  whiteSpace: "nowrap",
@@ -215,32 +253,25 @@ function CardContents({
215
253
  imageFallback,
216
254
  title,
217
255
  subtitle,
218
- scale,
256
+ overlays,
219
257
  pad,
220
258
  }: Pick<BaseCardProps, "children" | "image" | "imageFallback" | "title" | "subtitle"> & {
221
- scale: number;
259
+ overlays: boolean;
222
260
  pad: number;
223
261
  }): React.JSX.Element {
224
262
  const hasMedia = image != null || imageFallback != null;
225
263
  const hasBody = children != null;
226
264
  const hasCaption = title != null || subtitle != null;
227
- const gap = pad * 0.5;
265
+ const top = contentGaps({ hasMedia, hasBody, overlays, pad });
228
266
  return (
229
267
  <>
230
268
  {hasMedia && <CardMedia image={image} imageFallback={imageFallback} />}
231
269
  {hasBody && (
232
- <CardBodyContent fill={!hasMedia} scale={scale} topGap={hasMedia ? gap : 0}>
270
+ <CardBodyContent fill={!hasMedia} topGap={top.body}>
233
271
  {children}
234
272
  </CardBodyContent>
235
273
  )}
236
- {hasCaption && (
237
- <CardCaption
238
- title={title}
239
- subtitle={subtitle}
240
- scale={scale}
241
- topGap={hasMedia || hasBody ? gap : 0}
242
- />
243
- )}
274
+ {hasCaption && <CardCaption title={title} subtitle={subtitle} topGap={top.caption} />}
244
275
  </>
245
276
  );
246
277
  }
@@ -275,12 +306,11 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
275
306
  const { selectable, actionable, theme, slot, scale, drag } = useCardShell(props);
276
307
  const dataTestId = props.testId;
277
308
  const checkboxTestId = slot("checkbox");
278
- const pad = 1.5 * scale;
309
+ const pad = 1.5;
279
310
 
280
311
  return (
281
312
  <Card
282
313
  variant="outlined"
283
- borderRadius="lg"
284
314
  onClick={onClick}
285
315
  dataTestId={dataTestId}
286
316
  className={props.className}
@@ -292,6 +322,10 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
292
322
  // nothing at all — the styles vanish silently, which is exactly how the
293
323
  // first version of this shipped with no variants and no effects.
294
324
  sx={{
325
+ // The same corner the row uses: a Grade and a Lista of the same records
326
+ // sit beside each other, and a tile that rounds harder than the row
327
+ // reads as a different component.
328
+ borderRadius: CARD_RADIUS,
295
329
  // The shared half — variant, colour, selection, effects — identical to
296
330
  // the one BaseListCard uses, so a Grade and a Lista of the same records
297
331
  // are dressed by one set of rules.
@@ -308,8 +342,7 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
308
342
  ),
309
343
  // …and the tile's own geometry on top.
310
344
  ...cardSx({
311
- aspectRatio,
312
- pad,
345
+ aspectRatio, pad, scale,
313
346
  interactive: onClick != null,
314
347
  draggable: drag.draggable && drag.handleProps === undefined,
315
348
  }),
@@ -338,7 +371,7 @@ export function BaseCard(props: BaseCardProps): React.JSX.Element {
338
371
  imageFallback={props.imageFallback}
339
372
  title={props.title}
340
373
  subtitle={props.subtitle}
341
- scale={scale}
374
+ overlays={selectable || menu != null}
342
375
  pad={pad}
343
376
  >
344
377
  {props.children}
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { Checkbox } from "../../form/Checkbox";
4
+ import { Box } from "../../../mui/Box";
5
+
6
+ import { DragHandle, useDragItem } from "./data-views-drag";
7
+
8
+ /**
9
+ * THE TWO GUTTERS AT THE HEAD OF A ROW — the drag grip and the select checkbox.
10
+ *
11
+ * Split from `base-list-card` at the size gate. Both answer the same question in
12
+ * the same way: whether to occupy a rail, hold one open, or leave the template
13
+ * altogether. See `railsTemplateFor` for why the third option has to exist.
14
+ */
15
+
16
+ /**
17
+ * MUI's Checkbox carries 9px of hit-area padding on every side.
18
+ *
19
+ * Left uncancelled, a row asking for 8px of padding puts the checkbox SQUARE 17px
20
+ * in — and with the empty drag gutter's gap ahead of it, 46px in, which is what
21
+ * made the left edge look like a mistake. The hit area is worth keeping; the
22
+ * optical inset is not, so the padding is cancelled with a negative margin and
23
+ * the glyph lands where the row's own padding says it should.
24
+ */
25
+ const CHECKBOX_PAD = "-9px";
26
+
27
+ /** The grip. Its gutter is reserved only when the list says to reserve it. */
28
+ export function DragSlot({
29
+ drag,
30
+ reserve,
31
+ testId,
32
+ }: {
33
+ drag: ReturnType<typeof useDragItem>;
34
+ reserve: boolean;
35
+ testId?: string;
36
+ }): React.JSX.Element | null {
37
+ if (!drag.draggable) return reserve ? <Box data-slot="drag" /> : null;
38
+ return (
39
+ // display:flex, as on the checkbox below: the grip is an inline-grid, and in
40
+ // a block parent an inline box sits on the text baseline — the line box adds
41
+ // descender leading under it (21px glyph in a 31px slot) and drops the grip
42
+ // 5px below the row's centre. A flex parent has no baseline to sit on.
43
+ <Box
44
+ data-slot="drag"
45
+ sx={{ position: "relative", zIndex: 1, display: "flex", alignItems: "center" }}
46
+ >
47
+ <DragHandle
48
+ handleProps={drag.handleProps}
49
+ gated={drag.handleProps !== undefined}
50
+ testId={testId}
51
+ />
52
+ </Box>
53
+ );
54
+ }
55
+
56
+ /** The select checkbox. Its gutter is reserved only when the list says to. */
57
+ export function SelectSlot({
58
+ selectable,
59
+ selected,
60
+ reserve,
61
+ onToggleSelect,
62
+ testId,
63
+ }: {
64
+ selectable: boolean;
65
+ selected: boolean;
66
+ reserve: boolean;
67
+ onToggleSelect?: (event?: React.MouseEvent) => void;
68
+ testId?: string;
69
+ }): React.JSX.Element | null {
70
+ if (!selectable) return reserve ? <Box data-slot="select" /> : null;
71
+ return (
72
+ // z-index 1: above the stretched link, or the anchor swallows the click.
73
+ <Box
74
+ data-slot="select"
75
+ sx={{ position: "relative", zIndex: 1, mx: CHECKBOX_PAD, display: "flex" }}
76
+ >
77
+ <Checkbox
78
+ checked={selected}
79
+ onChange={() => onToggleSelect?.()}
80
+ onClick={(event) => {
81
+ event.stopPropagation();
82
+ onToggleSelect?.(event);
83
+ }}
84
+ size="small"
85
+ data-testid={testId}
86
+ aria-label="Selecionar"
87
+ />
88
+ </Box>
89
+ );
90
+ }
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { type ReactNode } from "react";
3
+ import { Fragment, type ReactNode } from "react";
4
4
 
5
5
  import { DescriptionItem, type DescriptionItemProps } from "../DescriptionItem";
6
+ import { RAIL_GAP_PX } from "./list-card-rails";
6
7
  import { Box } from "../../../mui/Box";
7
8
  import { Text } from "../../typography/Text";
8
9
 
@@ -41,14 +42,12 @@ export function ListCardCaption({
41
42
  subtitle,
42
43
  href,
43
44
  target,
44
- scale,
45
45
  testId,
46
46
  }: {
47
47
  title?: ReactNode;
48
48
  subtitle?: ReactNode;
49
49
  href?: string;
50
50
  target?: string;
51
- scale: number;
52
51
  testId?: string;
53
52
  }): React.JSX.Element {
54
53
  return (
@@ -61,7 +60,7 @@ export function ListCardCaption({
61
60
  sx={{
62
61
  display: "block",
63
62
  lineHeight: 1.2,
64
- fontSize: `${0.9 * scale}rem`,
63
+ fontSize: "0.9rem",
65
64
  color: "inherit",
66
65
  textDecoration: "none",
67
66
  ...CLAMP,
@@ -95,19 +94,56 @@ export function ListCardCaption({
95
94
  );
96
95
  }
97
96
 
97
+ /** One rule between two columns. Shared, so every gap gets the same mark. */
98
+ function MetaRule(): React.JSX.Element {
99
+ return (
100
+ <Box
101
+ aria-hidden
102
+ sx={{
103
+ flex: "0 0 auto",
104
+ color: "text.disabled",
105
+ opacity: 0.5,
106
+ userSelect: "none",
107
+ fontSize: "0.875rem",
108
+ // A cancelled row strikes the whole meta cluster, and a struck `|` is a
109
+ // dagger. The rule is punctuation, not data — nothing to void.
110
+ textDecoration: "none",
111
+ }}
112
+ >
113
+ |
114
+ </Box>
115
+ );
116
+ }
117
+
98
118
  /**
99
119
  * The middle columns: stacked label-over-value pairs, separated by a rule.
100
120
  *
101
121
  * Stacked rather than side by side because `DATA 05/08/2026` inline spends the
102
122
  * horizontal room the value rail needs, and the separator is what stops two
103
123
  * adjacent pairs reading as one four-word phrase.
124
+ *
125
+ * EVEN GAPS, NOT EVEN BOXES. The rail takes a share of the row's spare width
126
+ * per pair (see `metaRail`), and the obvious way to spend it — give every pair
127
+ * an equal-width box and centre its content — puts the separator on the BOX
128
+ * boundary, which is only the visual midpoint when both pairs are the same
129
+ * width. They never are: `05/08/2026, 13:45` reaches almost to the edge of its
130
+ * box while `PIX` stops well short of its own, so the rule ends up crowded
131
+ * against the date and adrift from the method.
132
+ *
133
+ * So the pairs are sized to their content and the SPARE space is what gets
134
+ * divided evenly. `space-evenly` gives every gap — including the two either
135
+ * side of each separator — the same width, which puts each rule exactly halfway
136
+ * between the two labels it separates, whatever they happen to say.
104
137
  */
105
138
  export function ListCardMeta({
106
139
  meta,
107
140
  metaSlot,
141
+ trailingRule,
108
142
  }: {
109
143
  meta?: DescriptionItemProps[];
110
144
  metaSlot?: ReactNode;
145
+ /** Close the run with a rule, dividing the last pair from the value. */
146
+ trailingRule?: boolean;
111
147
  }): React.JSX.Element {
112
148
  const items = meta ?? [];
113
149
  return (
@@ -116,7 +152,11 @@ export function ListCardMeta({
116
152
  sx={{
117
153
  display: "flex",
118
154
  alignItems: "center",
119
- gap: 2,
155
+ justifyContent: "space-evenly",
156
+ // A FLOOR, not the spacing itself: `space-evenly` supplies the rest, and
157
+ // this is only what keeps a rail with no room to spare from running the
158
+ // date straight into the rule.
159
+ gap: 1,
120
160
  whiteSpace: "nowrap",
121
161
  ...TABULAR,
122
162
  // Rung 1 of the ladder.
@@ -124,34 +164,62 @@ export function ListCardMeta({
124
164
  }}
125
165
  >
126
166
  {items.map((item, index) => (
127
- <Box key={`${item.label}-${index}`} sx={{ display: "flex", alignItems: "center", gap: 2 }}>
128
- {index > 0 && (
129
- <Box
130
- aria-hidden
131
- sx={{ color: "text.disabled", opacity: 0.5, userSelect: "none", fontSize: "0.875rem" }}
132
- >
133
- |
134
- </Box>
135
- )}
167
+ <Fragment key={`${item.label}-${index}`}>
168
+ {/* ITS OWN ITEM, not the first child of the pair that follows it.
169
+ Nested inside, the rule travelled with that pair's centred content
170
+ and came to rest nearer one label than the other — a separator
171
+ visibly closer to MÉTODO than to DATA reads as belonging to it. */}
172
+ {index > 0 && <MetaRule />}
136
173
  {/* Centred: a stacked pair sitting in its own column reads as one
137
174
  unit only when the label is over the value rather than flush left
138
175
  of a value shorter than it — `MÉTODO` above `PIX` was visibly
139
176
  unhinged from it. Overridable per item. */}
140
- <DescriptionItem orientation="vertical" align="center" {...item} />
141
- </Box>
177
+ <Box
178
+ sx={{
179
+ display: "flex",
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ minWidth: 0,
183
+ // Sized to the pair, so the gaps either side of it are free space
184
+ // that `space-evenly` can equalise.
185
+ flex: "0 0 auto",
186
+ }}
187
+ >
188
+ <DescriptionItem orientation="vertical" align="center" {...item} />
189
+ </Box>
190
+ </Fragment>
142
191
  ))}
143
192
  {metaSlot}
193
+ {trailingRule && items.length > 0 && <MetaRule />}
144
194
  </Box>
145
195
  );
146
196
  }
147
197
 
198
+ /**
199
+ * The value sits flush against the meta rail, with no gap of its own.
200
+ *
201
+ * Its divider is now the LAST ITEM OF THE META CLUSTER, placed by the same
202
+ * `space-evenly` that places the rules between the pairs — so it is equidistant
203
+ * from `PIX` and from `R$ 13,90` for free, which a rule centred in the rail gap
204
+ * could never be: the cluster's content stops well short of the rail's right
205
+ * edge, leaving that rule 150px from the method and 12px from the money.
206
+ *
207
+ * Cancelling this one gap is what makes the two distances equal. Below
208
+ * `META_BREAK` the cluster (and its rule) are gone, so the gap comes back —
209
+ * otherwise the value would sit straight against the title.
210
+ */
211
+ const VALUE_FLUSH = {
212
+ marginLeft: `-${RAIL_GAP_PX}px`,
213
+ [`@container (max-width: ${META_BREAK}px)`]: { marginLeft: 0 },
214
+ } as const;
215
+
148
216
  /** The value, on its own rail, never truncated and never shunted by the chip. */
149
- function ListCardValue({ value, scale }: { value: ReactNode; scale: number }): React.JSX.Element {
217
+ function ListCardValue({ value }: { value: ReactNode }): React.JSX.Element {
150
218
  return (
151
219
  <Text variant="body" size="sm" weight="medium" as="span">
152
220
  <Box
153
221
  component="span"
154
- sx={{ fontSize: `${0.875 * scale}rem`, whiteSpace: "nowrap", ...TABULAR }}
222
+ sx={{ fontSize: "0.875rem", whiteSpace: "nowrap", ...TABULAR }}
155
223
  >
156
224
  {value}
157
225
  </Box>
@@ -218,25 +286,33 @@ function ListCardActions({
218
286
  /** Value, status and actions — three rails, not one flex run. */
219
287
  export function ListCardTail({
220
288
  value,
289
+ separated,
221
290
  status,
222
291
  actions,
223
292
  actionsAlwaysVisible,
224
293
  menu,
225
- scale,
226
294
  testId,
227
295
  }: {
228
296
  value?: ReactNode;
297
+ /** Whether a meta cluster precedes the value and wants dividing from it. */
298
+ separated?: boolean;
229
299
  status?: ReactNode;
230
300
  actions?: ReactNode;
231
301
  actionsAlwaysVisible?: boolean;
232
302
  menu?: ReactNode;
233
- scale: number;
234
303
  testId: (slot: string) => string | undefined;
235
304
  }): React.JSX.Element {
236
305
  return (
237
306
  <>
238
- <Box data-slot="value" sx={{ textAlign: "right" }} data-testid={testId("value")}>
239
- {value != null && <ListCardValue value={value} scale={scale} />}
307
+ <Box
308
+ data-slot="value"
309
+ sx={{
310
+ textAlign: "right",
311
+ ...(separated && value != null ? VALUE_FLUSH : {}),
312
+ }}
313
+ data-testid={testId("value")}
314
+ >
315
+ {value != null && <ListCardValue value={value} />}
240
316
  </Box>
241
317
  <Box
242
318
  data-slot="status"
@@ -3,21 +3,22 @@
3
3
  import { useTheme } from "@mui/material/styles";
4
4
  import { type ReactNode } from "react";
5
5
 
6
- import { Checkbox } from "../../form/Checkbox";
7
6
  import { Card } from "../../layout/Card";
8
7
  import type { DescriptionItemProps } from "../DescriptionItem";
9
8
  import { Box } from "../../../mui/Box";
10
9
 
11
10
  import {
11
+ CARD_RADIUS,
12
12
  cardSurfaceStyles,
13
13
  isActionable,
14
14
  isSelectable,
15
15
  slotTestIds,
16
16
  type CardSurfaceProps,
17
17
  } from "./card-surface";
18
- import { DragHandle, useDragItem } from "./data-views-drag";
18
+ import { useDragItem } from "./data-views-drag";
19
+ import { DragSlot, SelectSlot } from "./base-list-card-gutters";
19
20
  import { DENSITY_ROW_PADDING, type DataViewsDensity } from "./data-views-layout-context";
20
- import { RAIL_COUNT, useListRails } from "./list-card-rails";
21
+ import { RAIL_COUNT, RAIL_GAP, railsTemplateFor, useListRails } from "./list-card-rails";
21
22
  import {
22
23
  ListCardCaption,
23
24
  ListCardMeta,
@@ -89,7 +90,13 @@ export interface BaseListCardProps extends CardSurfaceProps {
89
90
  onToggleSelect?: (event?: React.MouseEvent) => void;
90
91
  /** How much air the row gets. Vertical only — a full-width row cannot narrow. */
91
92
  density?: DataViewsDensity;
92
- /** Fine-grained size multiplier on top of `density`. */
93
+ /**
94
+ * Fine-grained size multiplier on top of `density` (1 = base).
95
+ *
96
+ * Applied as `zoom`, so it scales EVERYTHING the row draws — type, gutters,
97
+ * gaps, padding, the checkbox, the grip, the chip and the menu — not just the
98
+ * handful of font sizes it once multiplied.
99
+ */
93
100
  scale?: number;
94
101
  /** Flush style: a bottom rule instead of an outline. Row-only. */
95
102
  divider?: boolean;
@@ -97,9 +104,7 @@ export interface BaseListCardProps extends CardSurfaceProps {
97
104
  dragId?: string | number;
98
105
  }
99
106
 
100
- /** The rails, for a card with no group above it. */
101
- const STANDALONE_RAILS =
102
- "auto auto auto minmax(0, 1fr) auto max-content max-content auto";
107
+
103
108
 
104
109
  /**
105
110
  * Whether a click was really a click, or the tail of a text selection.
@@ -120,70 +125,41 @@ function clickKeys(onClick: () => void) {
120
125
  };
121
126
  }
122
127
 
123
- /** The grip. Its gutter is reserved whether or not anything drags. */
124
- function DragSlot({
125
- drag,
126
- testId,
127
- }: {
128
- drag: ReturnType<typeof useDragItem>;
129
- testId?: string;
130
- }): React.JSX.Element {
131
- if (!drag.draggable) return <Box data-slot="drag" />;
132
- return (
133
- <Box data-slot="drag" sx={{ position: "relative", zIndex: 1 }}>
134
- <DragHandle
135
- handleProps={drag.handleProps}
136
- gated={drag.handleProps !== undefined}
137
- testId={testId}
138
- />
139
- </Box>
140
- );
141
- }
142
-
143
- /** The select checkbox. Its gutter is reserved whether or not it is here. */
144
- function SelectSlot({
145
- selectable,
146
- selected,
147
- onToggleSelect,
148
- testId,
149
- }: {
150
- selectable: boolean;
151
- selected: boolean;
152
- onToggleSelect?: (event?: React.MouseEvent) => void;
153
- testId?: string;
154
- }): React.JSX.Element {
155
- if (!selectable) return <Box data-slot="select" />;
156
- return (
157
- // z-index 1: above the stretched link, or the anchor swallows the click.
158
- <Box data-slot="select" sx={{ position: "relative", zIndex: 1 }}>
159
- <Checkbox
160
- checked={selected}
161
- onChange={() => onToggleSelect?.()}
162
- onClick={(event) => {
163
- event.stopPropagation();
164
- onToggleSelect?.(event);
165
- }}
166
- size="small"
167
- data-testid={testId}
168
- aria-label="Selecionar"
169
- />
170
- </Box>
171
- );
172
- }
173
-
174
- /** Geometry: the rails, the density padding, the two-line collapse. */
128
+ /**
129
+ * Geometry: the rails, the density padding, the two-line collapse.
130
+ *
131
+ * SCALE IS `zoom`, NOT A SET OF MULTIPLIED FONT SIZES.
132
+ *
133
+ * It used to be the latter, on exactly three values — title, value, row padding
134
+ * — so the slider left the subtitle, the meta labels, the rules, the checkbox,
135
+ * the grip, the chip and the menu at their fixed sizes, and the row came apart
136
+ * as you dragged it. Multiplying the rest is not a fix: the checkbox and the
137
+ * chip are MUI internals sized in their own px, and no prop we pass reaches
138
+ * them.
139
+ *
140
+ * `zoom` scales the used value of every length in the subtree — ours and theirs
141
+ * — and unlike `transform: scale` it REFLOWS, so the row still occupies the
142
+ * space it draws and the rails still resolve. Verified against the group: at
143
+ * 1.4 all four rows keep identical rail edges, because subgrid tracks and the
144
+ * zoom that reads them are the same for every row.
145
+ */
175
146
  function rowSx(opts: {
176
147
  inGroup: boolean;
148
+ gutters: { drag: boolean; select: boolean };
149
+ metaColumns: number;
177
150
  pad: number;
178
151
  padY: number;
152
+ scale: number;
179
153
  divider: boolean;
180
154
  interactive: boolean;
181
155
  draggable: boolean;
182
156
  }): Record<string, unknown> {
183
- const { inGroup, pad, padY, divider, interactive, draggable } = opts;
157
+ const { inGroup, gutters, metaColumns, pad, padY, scale, divider, interactive, draggable } = opts;
184
158
  return {
185
159
  position: "relative",
160
+ borderRadius: CARD_RADIUS,
186
161
  display: "grid",
162
+ zoom: scale,
187
163
  // SUBGRID AND `container-type` CANNOT COEXIST on one element: containment
188
164
  // makes the box size independently of its parent, so the browser drops the
189
165
  // subgrid and every slot stacks into one column. (Observed exactly that —
@@ -193,12 +169,15 @@ function rowSx(opts: {
193
169
  // subgrid; standalone, the row is both the container and its own grid.
194
170
  ...(inGroup
195
171
  ? { gridColumn: `span ${RAIL_COUNT}`, gridTemplateColumns: "subgrid" }
196
- : { containerType: "inline-size", gridTemplateColumns: STANDALONE_RAILS }),
172
+ : {
173
+ containerType: "inline-size",
174
+ gridTemplateColumns: railsTemplateFor(gutters, metaColumns),
175
+ }),
197
176
  alignItems: "center",
198
177
  // Wide enough that the meta cluster, the value and the status read as three
199
178
  // columns rather than one run of text — the complaint that started all this
200
179
  // was `MÉTODO PIX R$ 13,90` scanning as a single phrase.
201
- columnGap: 3,
180
+ columnGap: RAIL_GAP,
202
181
  width: "100%",
203
182
  px: pad,
204
183
  py: padY,
@@ -248,6 +227,31 @@ function rowSx(opts: {
248
227
  *
249
228
  * A SHELL, like {@link BaseCard}. Domain rows live in the app and compose it.
250
229
  */
230
+ /**
231
+ * The id this row drags under, or nothing.
232
+ *
233
+ * `undefined` is the inert answer: `useDragItem` treats a missing id as "no
234
+ * drag", which is how the card's own veto (`draggable={false}`) and an
235
+ * unactionable record both switch dragging off without the card knowing
236
+ * anything about the container above it.
237
+ */
238
+ function dragIdFor(
239
+ props: BaseListCardProps,
240
+ actionable: boolean,
241
+ ): string | number | undefined {
242
+ return props.draggable === false || !actionable ? undefined : props.dragId;
243
+ }
244
+
245
+ /**
246
+ * What the meta cluster amounts to — asked once, used three times: to size the
247
+ * rail, to close the cluster with a rule, and to decide whether the value has
248
+ * anything to be divided FROM.
249
+ */
250
+ function metaShape(props: BaseListCardProps): { columns: number; present: boolean } {
251
+ const columns = props.meta?.length ?? 0;
252
+ return { columns, present: columns > 0 || props.metaSlot != null };
253
+ }
254
+
251
255
  /**
252
256
  * Everything the row derives before it can render — extracted so the component
253
257
  * stays inside the size budget.
@@ -263,9 +267,18 @@ function useRowShell(props: BaseListCardProps) {
263
267
  theme: useTheme(),
264
268
  selectable: isSelectable(props),
265
269
  slot: slotTestIds(props.testId),
266
- drag: useDragItem(props.draggable === false || !actionable ? undefined : props.dragId),
267
- pad: 1.5 * scale,
268
- padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"] * scale,
270
+ drag: useDragItem(dragIdFor(props, actionable)),
271
+ // Standalone holds no gutter open: there is no list beside it to line up
272
+ // with, so a reserved-but-empty rail is pure inset.
273
+ reserve: group?.reserveGutters ?? false,
274
+ // 1, not 1.5. The row's contents still have to line up with the toolbar
275
+ // above them, but 12px of card padding stacked on the checkbox's own 9px
276
+ // and an empty drag gutter's 24px read as a row indented for no reason.
277
+ //
278
+ // Not multiplied by `scale` — the row's `zoom` already scales it, and doing
279
+ // both would square the factor on padding alone.
280
+ pad: 1,
281
+ padY: DENSITY_ROW_PADDING[group?.density ?? props.density ?? "cozy"],
269
282
  // A card that navigates does so with a real anchor, so it takes no click
270
283
  // handler of its own — the stretched link IS the target.
271
284
  acts: props.onClick != null && props.href == null && actionable,
@@ -285,15 +298,45 @@ function actionProps(onClick: (() => void) | undefined) {
285
298
  };
286
299
  }
287
300
 
301
+ /** The surface and the geometry, merged — the row's whole `sx` in one place. */
302
+ function rowStyles(
303
+ props: BaseListCardProps,
304
+ shell: ReturnType<typeof useRowShell>,
305
+ ): Record<string, unknown> {
306
+ const { group, theme, selectable, drag, reserve, pad, padY, scale, acts } = shell;
307
+ return {
308
+ ...cardSurfaceStyles(
309
+ {
310
+ ...props,
311
+ selectable,
312
+ shape: "row",
313
+ state: drag.dragging ? "disabled" : props.state,
314
+ },
315
+ theme,
316
+ ),
317
+ ...rowSx({
318
+ inGroup: group !== null,
319
+ gutters: { drag: reserve || drag.draggable, select: reserve || selectable },
320
+ metaColumns: metaShape(props).columns,
321
+ pad,
322
+ padY,
323
+ scale,
324
+ divider: props.divider ?? false,
325
+ interactive: acts || props.href != null,
326
+ draggable: drag.draggable && drag.handleProps === undefined,
327
+ }),
328
+ };
329
+ }
330
+
288
331
  export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
289
- const { selected = false, divider = false, href, onClick } = props;
290
- const { group, actionable, scale, theme, selectable, slot, drag, pad, padY, acts } =
291
- useRowShell(props);
332
+ const { selected = false, href, onClick } = props;
333
+ const shell = useRowShell(props);
334
+ const { actionable, selectable, slot, drag, reserve, pad, acts } = shell;
335
+ const meta = metaShape(props);
292
336
 
293
337
  return (
294
338
  <Card
295
339
  variant="outlined"
296
- borderRadius="lg"
297
340
  className={props.className}
298
341
  dataTestId={props.testId}
299
342
  aria-label={props["aria-label"]}
@@ -302,22 +345,13 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
302
345
  {...(acts ? actionProps(onClick) : {})}
303
346
  {...drag.itemProps}
304
347
  // A PLAIN OBJECT: `Card` merges by spreading, so a function sx vanishes.
305
- sx={{
306
- ...cardSurfaceStyles({ ...props, selectable, shape: "row", state: drag.dragging ? "disabled" : props.state }, theme),
307
- ...rowSx({
308
- inGroup: group !== null,
309
- pad,
310
- padY,
311
- divider,
312
- interactive: acts || href != null,
313
- draggable: drag.draggable && drag.handleProps === undefined,
314
- }),
315
- }}
348
+ sx={rowStyles(props, shell)}
316
349
  >
317
- <DragSlot drag={drag} testId={slot("drag")} />
350
+ <DragSlot drag={drag} reserve={reserve} testId={slot("drag")} />
318
351
  <SelectSlot
319
352
  selectable={selectable}
320
353
  selected={selected}
354
+ reserve={reserve}
321
355
  onToggleSelect={props.onToggleSelect}
322
356
  testId={slot("checkbox")}
323
357
  />
@@ -329,17 +363,20 @@ export function BaseListCard(props: BaseListCardProps): React.JSX.Element {
329
363
  subtitle={props.subtitle}
330
364
  href={href}
331
365
  target={props.target}
332
- scale={scale}
333
366
  testId={slot("title")}
334
367
  />
335
- <ListCardMeta meta={props.meta} metaSlot={props.metaSlot} />
368
+ <ListCardMeta
369
+ meta={props.meta}
370
+ metaSlot={props.metaSlot}
371
+ trailingRule={props.value != null}
372
+ />
336
373
  <ListCardTail
337
374
  value={props.value}
375
+ separated={meta.present}
338
376
  status={props.status}
339
377
  actions={actionable ? props.actions : undefined}
340
378
  actionsAlwaysVisible={props.actionsAlwaysVisible}
341
379
  menu={props.menu}
342
- scale={scale}
343
380
  testId={slot}
344
381
  />
345
382
  {props.children != null && (
@@ -125,6 +125,20 @@ export interface CardSurfaceProps {
125
125
  "aria-label"?: string;
126
126
  }
127
127
 
128
+ /**
129
+ * ONE RADIUS FOR BOTH SHAPES, off the token scale on purpose.
130
+ *
131
+ * `Card`'s own steps are 4px and 8px and its `lg` is 16px, all chosen for a
132
+ * surface nearly as tall as it is wide. A 56px row is not that shape — the
133
+ * corner runs most of the way down both ends and curves away from the square
134
+ * checkbox and square thumbnail inside it — and a tile that rounds harder than
135
+ * the row it sits beside in the same view reads as a different component.
136
+ *
137
+ * 3px takes the hard point off the corner without being seen as a curve.
138
+ * `divider` rows override it back to 0; a bottom rule has no corners to round.
139
+ */
140
+ export const CARD_RADIUS = "3px";
141
+
128
142
  /** Every slot's test id, derived from the card's one. */
129
143
  export function slotTestIds(testId: string | undefined): (slot: string) => string | undefined {
130
144
  return (slot) => (testId ? `${testId}-${slot}` : undefined);
@@ -150,7 +164,7 @@ export function isActionable(state: CardState | undefined): boolean {
150
164
  * draw. Ghost is "a surface, quietly"; text is "no surface at all", which is
151
165
  * what a host wants when it is drawing the container itself.
152
166
  */
153
- function variantSx(variant: CardSurfaceVariant): CardSx {
167
+ function variantSx(variant: CardSurfaceVariant, theme: Theme): CardSx {
154
168
  if (variant === "ghost") {
155
169
  return {
156
170
  border: 0,
@@ -162,10 +176,26 @@ function variantSx(variant: CardSurfaceVariant): CardSx {
162
176
  return { border: 0, backgroundColor: "transparent", boxShadow: "none" };
163
177
  }
164
178
  if (variant === "glass") {
179
+ // THEME-AWARE, and it keeps its edges.
180
+ //
181
+ // This was a hardcoded `rgba(255,255,255,·)` fill with a near-white border,
182
+ // which had two problems. On a dark palette a 55%-white pane is not frosted
183
+ // glass, it is a pale rectangle. And on a light one BOTH the fill and the
184
+ // border land on white and vanish, so `glass` rendered as `text` — the same
185
+ // nothing, reached by a different name.
186
+ //
187
+ // The blur is what makes it glass, and a blur is only visible over
188
+ // something. So the pane keeps a real hairline and a shallow shadow: over a
189
+ // plain surface it still reads as a raised pane rather than as absence, and
190
+ // over an image or a gradient the blur does the rest.
165
191
  return {
166
- backgroundColor: "rgba(255,255,255,0.55)",
192
+ backgroundColor: alpha(theme.palette.background.paper, 0.55),
167
193
  backdropFilter: "blur(8px)",
168
- borderColor: "rgba(255,255,255,0.35)",
194
+ // `divider` as-is. NOT `alpha(divider, …)`: MUI's `alpha` REPLACES the
195
+ // channel rather than scaling it, and `divider` is already a 12% black —
196
+ // so asking for 80% of it produced an 80% black slab of a border.
197
+ borderColor: theme.palette.divider,
198
+ boxShadow: `0 1px 3px ${alpha(theme.palette.common.black, 0.1)}`,
169
199
  };
170
200
  }
171
201
  return {};
@@ -210,13 +240,28 @@ function accentBar(main: string): CardSx {
210
240
  }
211
241
 
212
242
  /**
213
- * The tile's halo. Button's glow, kept for `BaseCard` — a grid of tiles has gaps
214
- * around each card, so the spread has somewhere to go that a row does not.
243
+ * The tile's lift — a card that has been RAISED, not one that is lit from within.
244
+ *
245
+ * This was Button's glow: a 20px bloom at 0.6 over a 40px bloom at 0.3, plus
246
+ * `brightness(1.05)`. On a button, which is small and pressed and gone, that
247
+ * reads as energy. On a tile it read as a neon sign — 40px of saturated colour
248
+ * bleeding into the gaps of a grid whose whole job is to let the eye compare
249
+ * cards, and the brightness filter washing the image and the caption with it.
250
+ *
251
+ * Three shadows doing three jobs instead: a hairline ring that tints the card's
252
+ * own edge in the accent colour, a tight contact shadow, and one soft shadow
253
+ * offset DOWNWARD. Offset is what separates elevation from emission — a glow is
254
+ * symmetrical because its source is the object; a shadow falls because the
255
+ * light is somewhere else. It stays legible on a dark canvas, where the old
256
+ * bloom was the brightest thing on the screen.
215
257
  */
216
- function glowSx(main: string): CardSx {
258
+ function liftSx(main: string): CardSx {
217
259
  return {
218
- boxShadow: `0 0 20px 5px ${alpha(main, 0.6)}, 0 0 40px 10px ${alpha(main, 0.3)}`,
219
- filter: "brightness(1.05)",
260
+ boxShadow: [
261
+ `0 0 0 1px ${alpha(main, 0.45)}`,
262
+ `0 1px 2px ${alpha(main, 0.12)}`,
263
+ `0 8px 20px -6px ${alpha(main, 0.3)}`,
264
+ ].join(", "),
220
265
  };
221
266
  }
222
267
 
@@ -251,10 +296,10 @@ function pulseSx(main: string): CardSx {
251
296
  /** The emphasis treatments, in the terms the shape can actually carry. */
252
297
  function emphasisSx(emphasis: CardEmphasis, main: string, shape: CardShape): CardSx {
253
298
  if (emphasis === "attention") {
254
- // The bar on both; the halo only where there is room for it to spread.
299
+ // The bar on both; the lift only where there is room for it to spread.
255
300
  return {
256
301
  ...accentBar(main),
257
- ...(shape === "tile" ? glowSx(main) : {}),
302
+ ...(shape === "tile" ? liftSx(main) : {}),
258
303
  ...pulseSx(main),
259
304
  };
260
305
  }
@@ -271,6 +316,42 @@ function emphasisSx(emphasis: CardEmphasis, main: string, shape: CardShape): Car
271
316
  * The shared half of a card's styling. Each component adds its own geometry —
272
317
  * a tile's aspect ratio, a row's rails.
273
318
  */
319
+ /**
320
+ * WHAT THE TWO NON-DEFAULT STATES LOOK LIKE — and why they cannot look alike.
321
+ *
322
+ * Both were `opacity: 0.6` and nothing else, so a record the business CANCELLED
323
+ * and a row this user merely cannot touch were pixel-identical. The type has
324
+ * always drawn the distinction (`CardState`); only the styling had not, which
325
+ * made `cancelled` a comment rather than a state.
326
+ *
327
+ * `cancelled` is a VOIDED record: still there, still worth reading, no longer
328
+ * counting. The figures get struck through, because a dimmed `R$ 1.250,00`
329
+ * still reads as money owed and a struck one cannot.
330
+ *
331
+ * `disabled` is INERT: nothing to read into, nothing to do with. It goes
332
+ * greyscale — which also drains the status chip's colour, the one thing on the
333
+ * row still shouting for attention — and says so on hover.
334
+ */
335
+ function stateSx(state: CardState): CardSx {
336
+ if (state === "cancelled") {
337
+ return {
338
+ opacity: 0.65,
339
+ // The META CLUSTER is struck too. It was left out, so a voided row kept a
340
+ // crisp `05/08/2026, 13:45` and `PIX` between a struck title and a struck
341
+ // total — the middle of the row reading as live data on a record the two
342
+ // ends call void. Both lines of each pair go, as both lines of the caption
343
+ // already do: on a cancelled record the label is as void as the value.
344
+ '& [data-slot="value"], & [data-slot="caption"], & [data-slot="meta"]': {
345
+ textDecoration: "line-through",
346
+ },
347
+ };
348
+ }
349
+ if (state === "disabled") {
350
+ return { opacity: 0.45, filter: "grayscale(1)", cursor: "not-allowed" };
351
+ }
352
+ return { opacity: 1 };
353
+ }
354
+
274
355
  export function cardSurfaceStyles(
275
356
  props: CardSurfaceProps & { selectable: boolean; shape: CardShape },
276
357
  theme: Theme,
@@ -283,8 +364,8 @@ export function cardSurfaceStyles(
283
364
  const showSelected = selectable && selected;
284
365
  return {
285
366
  transition: "border-color 120ms, box-shadow 120ms, background-color 120ms",
286
- opacity: state === "default" ? 1 : 0.6,
287
- ...variantSx(variant),
367
+ ...stateSx(state),
368
+ ...variantSx(variant, theme),
288
369
  ...(showSelected
289
370
  ? {
290
371
  borderColor: `${color}.main`,
@@ -32,8 +32,9 @@ export interface ListRails {
32
32
  /** Reserved even when nothing is selectable, for the same reason. */
33
33
  select: string;
34
34
  leading: string;
35
- /** The one column that absorbs spare width. */
35
+ /** Absorbs spare width, alongside `meta`. */
36
36
  caption: string;
37
+ /** Also absorbs spare width, so the middle columns spread rather than bunch. */
37
38
  meta: string;
38
39
  /** The value's own rail, with a fixed right edge. */
39
40
  value: string;
@@ -42,14 +43,56 @@ export interface ListRails {
42
43
  actions: string;
43
44
  }
44
45
 
45
- const DEFAULT_RAILS: ListRails = {
46
+ /**
47
+ * The meta rail, sized to sit its pairs on even intervals WITH the caption.
48
+ *
49
+ * ONE SHARE OF THE SPARE WIDTH PER PAIR, not one for the cluster. A single `1fr`
50
+ * handed the whole cluster as much room as the title and then split it between
51
+ * the pairs, so the title sat on a full share while `DATA` and `MÉTODO` had half
52
+ * a share each — which is why the gap after the title came out twice the gap
53
+ * between the pairs. Even columns need even shares, so the rail grows with the
54
+ * number of pairs it has to hold.
55
+ *
56
+ * `max-content` still floors it: the evenness is never paid for with a clipped
57
+ * label on a row too narrow to afford it.
58
+ */
59
+ export function metaRail(columns: number): string {
60
+ return `minmax(max-content, ${Math.max(columns, 1)}fr)`;
61
+ }
62
+
63
+ /**
64
+ * The caption and meta rails, resolved TOGETHER — because exactly one of them
65
+ * has to be the flexible track, and which one depends on whether the row has any
66
+ * middle columns at all.
67
+ *
68
+ * A grid distributes its free space to `fr` tracks; with no `fr` present it
69
+ * spreads it across every `auto` track instead — including the empty gutters,
70
+ * the empty meta rail and the empty status rail. So a row with a title and a
71
+ * value and nothing between them drifted its title into the middle of the row,
72
+ * each empty rail having claimed an equal share of the slack.
73
+ *
74
+ * With pairs to place, `meta` is the flexible one and the caption sizes to its
75
+ * title (the spare width belongs to the only rail with several things to space
76
+ * out). With no pairs, there is nothing to distribute and the caption takes the
77
+ * slack back, which is what it always did.
78
+ */
79
+ export function contentRails(metaColumns: number): Pick<ListRails, "caption" | "meta"> {
80
+ return metaColumns > 0
81
+ ? { caption: "minmax(0, auto)", meta: metaRail(metaColumns) }
82
+ : { caption: "minmax(0, 1fr)", meta: "auto" };
83
+ }
84
+
85
+ export const DEFAULT_RAILS: ListRails = {
46
86
  drag: "auto",
47
87
  select: "auto",
48
88
  leading: "auto",
49
- caption: "minmax(0, 1fr)",
50
- meta: "auto",
89
+ // Two is the canonical row (a date and a method); a list carrying a different
90
+ // number says so with `metaColumns`, and a standalone card counts its own.
91
+ ...contentRails(2),
51
92
  // `max-content` on the value, so the rail is as wide as the widest amount in
52
93
  // the list and no wider — a fixed px guess is either clipping or dead space.
94
+ // NOT an `fr`: the value's whole promise is a fixed right edge, and a rail
95
+ // that grows with the spare width moves that edge on every list.
53
96
  value: "max-content",
54
97
  status: "max-content",
55
98
  actions: "auto",
@@ -68,8 +111,16 @@ export function useListRails(): ListRailsValue | null {
68
111
  return useContext(ListRailsContext);
69
112
  }
70
113
 
71
- /** The column template, as one `grid-template-columns` value. */
72
- export function railsTemplate(rails: ListRails): string {
114
+ /**
115
+ * The column template, as one `grid-template-columns` value.
116
+ *
117
+ * A rail set to `null` is DROPPED rather than sized to zero. The two are not the
118
+ * same: a zero-width track still takes a `column-gap` beside it, so a gutter
119
+ * nobody is using still pushes the row's contents inward. Only a standalone card
120
+ * may drop one — inside a group the rails are subgrid over a shared template and
121
+ * every row must span the same count.
122
+ */
123
+ export function railsTemplate(rails: RailOverrides): string {
73
124
  return [
74
125
  rails.drag,
75
126
  rails.select,
@@ -79,7 +130,50 @@ export function railsTemplate(rails: ListRails): string {
79
130
  rails.value,
80
131
  rails.status,
81
132
  rails.actions,
82
- ].join(" ");
133
+ ]
134
+ .filter((rail): rail is string => rail != null)
135
+ .join(" ");
136
+ }
137
+
138
+ /** A rail set, where `null` means "this rail is not in the template at all". */
139
+ type RailOverrides = { [K in keyof ListRails]: ListRails[K] | null };
140
+
141
+ /** The gap between rails, in theme spacing units. */
142
+ export const RAIL_GAP = 3;
143
+
144
+ /** …the same gap in pixels, for anything drawing INTO it. */
145
+ export const RAIL_GAP_PX = RAIL_GAP * 8;
146
+
147
+ /**
148
+ * AN EMPTY GUTTER COSTS ITS OWN WIDTH *AND* THE RAIL GAP BESIDE IT.
149
+ *
150
+ * A zero-width grid track still gets a `column-gap`, and no margin on the item
151
+ * inside it can win that back — gaps sit between TRACKS, and a track's position
152
+ * is settled before any item's margin is applied. (Tried it; the box simply
153
+ * overhangs its own track and everything after it stays put.) So a row with
154
+ * nothing to drag was paying 24px for a rail holding nothing, which was the
155
+ * single biggest contributor to the left inset the checkbox appeared to have.
156
+ *
157
+ * The only way out is for the track NOT TO EXIST: the slot renders nothing and
158
+ * the rail leaves the template, which is safe because grid auto-placement fills
159
+ * the remaining tracks in DOM order. Standalone only — inside a
160
+ * {@link ListCardGroup} the rails are subgrid across a shared template and the
161
+ * count cannot vary per row, which is also where `reserveGutters` earns its
162
+ * keep: a selectable list and a read-only one still line up, and turning drag
163
+ * mode on does not shift every row sideways.
164
+ */
165
+ export function railsTemplateFor(
166
+ gutters: { drag: boolean; select: boolean },
167
+ metaColumns: number,
168
+ ): string {
169
+ return railsTemplate({
170
+ ...DEFAULT_RAILS,
171
+ // Standalone, the card can simply COUNT its pairs — no `metaColumns` to be
172
+ // told, and no guess.
173
+ ...contentRails(metaColumns),
174
+ ...(gutters.drag ? {} : { drag: null }),
175
+ ...(gutters.select ? {} : { select: null }),
176
+ });
83
177
  }
84
178
 
85
179
  /** How many rails there are — the span a card claims. */
@@ -97,6 +191,7 @@ export const RAIL_COUNT = 8;
97
191
  export function ListCardGroup({
98
192
  children,
99
193
  density = "cozy",
194
+ metaColumns,
100
195
  rails,
101
196
  reserveGutters = true,
102
197
  gap,
@@ -104,6 +199,15 @@ export function ListCardGroup({
104
199
  }: {
105
200
  children: ReactNode;
106
201
  density?: DataViewsDensity;
202
+ /**
203
+ * How many labelled pairs the rows in this list carry.
204
+ *
205
+ * The rail claims one share of the spare width per pair, so the title and
206
+ * every pair land on even intervals. A list is homogeneous — its rows show
207
+ * the same fields — but the group renders its children opaquely and cannot
208
+ * count them, so this is the one thing it has to be told.
209
+ */
210
+ metaColumns?: number;
107
211
  /** Override individual rails; anything omitted keeps its default. */
108
212
  rails?: Partial<ListRails>;
109
213
  /**
@@ -117,7 +221,11 @@ export function ListCardGroup({
117
221
  gap?: number;
118
222
  dataTestId?: string;
119
223
  }): React.JSX.Element {
120
- const resolved = { ...DEFAULT_RAILS, ...rails };
224
+ const resolved = {
225
+ ...DEFAULT_RAILS,
226
+ ...(metaColumns == null ? {} : contentRails(metaColumns)),
227
+ ...rails,
228
+ };
121
229
  return (
122
230
  <ListRailsContext.Provider value={{ density, reserveGutters }}>
123
231
  <Box
@@ -61,14 +61,49 @@ export const getColorFromTheme = (theme: Theme, color: string): ColorPalette =>
61
61
  };
62
62
  };
63
63
 
64
- export const SIZE_MAP: Record<string, CSSObject> = {
64
+ /** What an unrecognised size falls back to, named so indexing can never be undefined. */
65
+ const DEFAULT_SIZE: CSSObject = { padding: '8px 16px', fontSize: '1rem' };
66
+
67
+ const SIZE_MAP: Record<string, CSSObject> = {
65
68
  xs: { padding: '2px 8px', fontSize: '0.75rem' },
66
69
  sm: { padding: '6px 12px', fontSize: '0.875rem' },
67
- md: { padding: '8px 16px', fontSize: '1rem' },
70
+ md: DEFAULT_SIZE,
68
71
  lg: { padding: '10px 20px', fontSize: '1.125rem' },
69
72
  xl: { padding: '12px 24px', fontSize: '1.25rem' },
70
73
  };
71
74
 
75
+ /**
76
+ * A BUTTON THAT IS ONLY AN ICON IS SQUARE.
77
+ *
78
+ * MUI puts a 64px `min-width` on every button, which is right for a label and
79
+ * wrong for a glyph: three dots in a 64px slab reads as a button that failed to
80
+ * load its text, and at the end of a dense row it claims space the row needs.
81
+ * The horizontal padding goes too — a square of padding round a square glyph —
82
+ * and so does the gap MUI reserves beside a start/end icon, since there is
83
+ * nothing on the other side of it.
84
+ *
85
+ * Derived, not declared: a button with an `icon` and no children can only be an
86
+ * icon button, so no consumer has to opt in and none can forget to.
87
+ */
88
+ const ICON_ONLY_PADDING: Record<string, string> = {
89
+ xs: '2px',
90
+ sm: '5px',
91
+ md: '7px',
92
+ lg: '9px',
93
+ xl: '11px',
94
+ };
95
+
96
+ const iconOnlySize = (size: string): CSSObject => ({
97
+ minWidth: 0,
98
+ padding: ICON_ONLY_PADDING[size] ?? ICON_ONLY_PADDING.md,
99
+ fontSize: SIZE_MAP[size]?.fontSize ?? DEFAULT_SIZE.fontSize,
100
+ '& .MuiButton-startIcon, & .MuiButton-endIcon': { margin: 0 },
101
+ });
102
+
103
+ /** The size styles for a button, square when it carries nothing but an icon. */
104
+ export const buttonSize = (size: string, iconOnly: boolean): CSSObject =>
105
+ iconOnly ? iconOnlySize(size) : (SIZE_MAP[size] ?? DEFAULT_SIZE);
106
+
72
107
  /**
73
108
  * MUI centres icons with a negative margin that fights our own padding, so the
74
109
  * flex alignment is restated here rather than fought with `!important`.
@@ -8,7 +8,7 @@ import {
8
8
  buttonVariantStyles,
9
9
  getColorFromTheme,
10
10
  iconAlignmentStyles,
11
- SIZE_MAP,
11
+ buttonSize,
12
12
  } from './Button.styles';
13
13
  import type { ButtonProps } from './Button.types';
14
14
 
@@ -53,6 +53,22 @@ const muiColorFor = (color: NonNullable<ButtonProps['color']>): MuiButtonColor =
53
53
  return color === 'neutral' ? 'inherit' : color;
54
54
  };
55
55
 
56
+ /**
57
+ * An icon and nothing else — the shape that should render square.
58
+ *
59
+ * A separate predicate rather than three clauses inline: the component function
60
+ * is already at the complexity ceiling, and "what counts as an icon button" is
61
+ * a rule worth naming anyway. `loading` disqualifies, because a spinner
62
+ * replaces the children and would otherwise make every loading button square.
63
+ */
64
+ function isIconOnly({
65
+ loading,
66
+ icon,
67
+ children,
68
+ }: Pick<ButtonProps, 'loading' | 'icon' | 'children'>): boolean {
69
+ return !loading && icon != null && children == null;
70
+ }
71
+
56
72
  export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
57
73
  (rawProps, ref) => {
58
74
  const {
@@ -81,6 +97,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
81
97
  const iconWithTestId =
82
98
  !loading && icon ? <span data-testid={testId('icon')}>{icon}</span> : undefined;
83
99
 
100
+ // Only an icon, so the button is a square rather than a 64px slab.
101
+ const iconOnly = isIconOnly({ loading, icon, children });
102
+
84
103
  const mergedClassName =
85
104
  [active ? 'active' : '', props.className].filter(Boolean).join(' ') || undefined;
86
105
 
@@ -101,7 +120,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
101
120
  onClick={onClick}
102
121
  onFocus={onFocus}
103
122
  onBlur={onBlur}
104
- sx={SIZE_MAP[size]}
123
+ sx={buttonSize(size, iconOnly)}
105
124
  data-testid={dataTestId || 'button'}
106
125
  {...props}
107
126
  className={mergedClassName}