@duro-app/ui 0.32.0 → 0.33.0

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": "@duro-app/ui",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@duro-app/tokens": "^0.32.0"
56
+ "@duro-app/tokens": "^0.33.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -0,0 +1,52 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import {Menu} from '../Menu/Menu'
4
+
5
+ export interface SortValue {
6
+ readonly id: string
7
+ readonly desc: boolean
8
+ }
9
+
10
+ export interface SortChipProps {
11
+ readonly options: ReadonlyArray<{readonly id: string; readonly label: string}>
12
+ readonly value: SortValue | null
13
+ readonly onChange: (next: SortValue | null) => void
14
+ /** Visible label preceding the sort field, e.g. "Sort by". */
15
+ readonly label?: string
16
+ }
17
+
18
+ /**
19
+ * Sort selector for stack-mode Tables. Hidden in default/compact via
20
+ * `@container` query — see styles.sortChip. Consumers render this inside
21
+ * a `Table.Container` so the same query target reaches it.
22
+ *
23
+ * The chip is a simple controlled wrapper around `Menu`. It does NOT
24
+ * couple to @tanstack/react-table — the consumer wires `value` and
25
+ * `onChange` from whatever sort state they manage.
26
+ */
27
+ export function SortChip({options, value, onChange, label = 'Sort by'}: SortChipProps) {
28
+ const current = value ? options.find((o) => o.id === value.id) : null
29
+ const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
30
+
31
+ return (
32
+ <html.div style={styles.sortChip}>
33
+ <Menu.Root>
34
+ <Menu.Trigger>{triggerLabel}</Menu.Trigger>
35
+ <Menu.Popup>
36
+ {options.map((opt) => {
37
+ const isCurrent = value?.id === opt.id
38
+ const nextDesc = isCurrent ? !value!.desc : false
39
+ return (
40
+ <Menu.Item key={opt.id} onClick={() => onChange({id: opt.id, desc: nextDesc})}>
41
+ {opt.label}
42
+ {isCurrent ? ` ${value!.desc ? '↓' : '↑'}` : ''}
43
+ </Menu.Item>
44
+ )
45
+ })}
46
+ {value !== null && <Menu.Item onClick={() => onChange(null)}>Clear sort</Menu.Item>}
47
+ </Menu.Popup>
48
+ </Menu.Root>
49
+ </html.div>
50
+ )
51
+ }
52
+ SortChip.displayName = 'SortChip'
@@ -878,3 +878,93 @@ export const FullFeatured: Story = {
878
878
  await expect(canvas.getByText('Previous')).toBeInTheDocument()
879
879
  },
880
880
  }
881
+
882
+ // Demonstrates the three responsive modes. Resize the canvas (or wrap in
883
+ // a narrower container) to see Compact (~720px) and Stack (~440px).
884
+ const responsiveStyles = css.create({
885
+ resizable: {
886
+ resize: 'horizontal',
887
+ overflow: 'auto',
888
+ minWidth: '320px',
889
+ maxWidth: '100%',
890
+ width: '900px',
891
+ padding: spacing.sm,
892
+ borderWidth: 1,
893
+ borderStyle: 'dashed',
894
+ borderColor: colors.border,
895
+ },
896
+ })
897
+
898
+ export const Responsive: Story = {
899
+ name: 'Responsive (Container Queries)',
900
+ render: () => {
901
+ const grants = [
902
+ {principal: 'daddy', role: 'Admin', resource: 'all', expires: 'Never', granted_by: 'system'},
903
+ {
904
+ principal: 'alice',
905
+ role: 'Editor',
906
+ resource: 'documents/foo.md',
907
+ expires: '2026-12-31',
908
+ granted_by: 'daddy',
909
+ },
910
+ {principal: 'bob', role: 'Viewer', resource: 'all', expires: 'Never', granted_by: 'daddy'},
911
+ ]
912
+ const [sort, setSort] = useState<{id: string; desc: boolean} | null>(null)
913
+ const sortable = [
914
+ {id: 'principal', label: 'Principal'},
915
+ {id: 'role', label: 'Role'},
916
+ {id: 'expires', label: 'Expires'},
917
+ ]
918
+ return (
919
+ <html.div style={responsiveStyles.resizable}>
920
+ <Table.Container>
921
+ <Table.SortChip options={sortable} value={sort} onChange={setSort} />
922
+ <Table.Root>
923
+ <Table.Header>
924
+ <Table.Row>
925
+ <Table.HeaderCell label="Principal">Principal</Table.HeaderCell>
926
+ <Table.HeaderCell label="Role">Role</Table.HeaderCell>
927
+ <Table.HeaderCell label="Resource">Resource</Table.HeaderCell>
928
+ <Table.HeaderCell label="Expires">Expires</Table.HeaderCell>
929
+ <Table.HeaderCell label="Granted by">Granted by</Table.HeaderCell>
930
+ <Table.HeaderCell label="Actions" isActions>
931
+ Actions
932
+ </Table.HeaderCell>
933
+ </Table.Row>
934
+ </Table.Header>
935
+ <Table.Body>
936
+ {grants.map((g) => (
937
+ <Table.Row key={g.principal}>
938
+ <Table.Cell>{g.principal}</Table.Cell>
939
+ <Table.Cell>{g.role}</Table.Cell>
940
+ <Table.Cell>{g.resource}</Table.Cell>
941
+ <Table.Cell>{g.expires}</Table.Cell>
942
+ <Table.Cell>{g.granted_by}</Table.Cell>
943
+ <Table.Cell isActions>
944
+ <Button size="small" variant="secondary">
945
+ Revoke
946
+ </Button>
947
+ </Table.Cell>
948
+ </Table.Row>
949
+ ))}
950
+ </Table.Body>
951
+ </Table.Root>
952
+ </Table.Container>
953
+ </html.div>
954
+ )
955
+ },
956
+ play: async ({canvas}) => {
957
+ // At default canvas width (900px) we're in compact, headers nowrap,
958
+ // sort chip hidden. Just sanity-check the table renders the expected
959
+ // shape — visual regression is covered by Storybook + Chromatic.
960
+ const headers = canvas.getAllByRole('columnheader')
961
+ await expect(headers.length).toBe(6)
962
+ const cells = canvas.getAllByRole('cell')
963
+ // 3 rows × 6 columns = 18 cells
964
+ await expect(cells.length).toBe(18)
965
+ // Each non-actions body cell carries its label as a hidden span — we
966
+ // can't visually assert display:none in jsdom, but the text node
967
+ // exists in the DOM tree.
968
+ await expect(canvas.getAllByText('Principal').length).toBeGreaterThanOrEqual(2)
969
+ },
970
+ }
@@ -12,6 +12,7 @@ import {styles} from './styles.css'
12
12
  import {Pagination} from './Pagination'
13
13
  import {SortIndicator} from './SortIndicator'
14
14
  import {ColumnFilter} from './ColumnFilter'
15
+ import {SortChip} from './SortChip'
15
16
 
16
17
  // --- Types ---
17
18
 
@@ -23,6 +24,10 @@ export type TableSize = 'sm' | 'md'
23
24
  interface TableContextValue {
24
25
  variant: TableVariant
25
26
  size: TableSize
27
+ responsive: boolean
28
+ /** Header labels indexed by column position. Populated synchronously by
29
+ * extractColumnMeta() during Root render so SSR HTML carries them. */
30
+ labels: ReadonlyArray<string>
26
31
  /** Mutable ref: header Row writes inferred template, Root reads it */
27
32
  inferredTemplateRef: MutableRefObject<string | null>
28
33
  }
@@ -39,33 +44,50 @@ function useTable() {
39
44
 
40
45
  const HeaderContext = createContext(false)
41
46
 
42
- // --- Template context: set after first render pass ---
47
+ // --- Container — owns the @container query target ---
48
+ //
49
+ // Wraps SortChip + Root + Pagination so they all participate in the same
50
+ // container query. Setting `containerType: inline-size` on Root would not
51
+ // reach siblings that consumers render above the table.
43
52
 
44
- const TemplateContext = createContext<string | null>(null)
45
-
46
- // --- Root ---
53
+ function Container({children}: {children: ReactNode}) {
54
+ return <html.div style={styles.container}>{children}</html.div>
55
+ }
47
56
 
48
- interface RootProps {
49
- children: ReactNode
50
- variant?: TableVariant
51
- size?: TableSize
57
+ // --- Synchronous tree walk: widths + labels in document order ---
58
+ //
59
+ // Mirrors the pattern that was previously called extractTemplate(). Walks
60
+ // the JSX tree, finds HeaderCell elements, captures their `width` and
61
+ // `label` props (or extractText(children) fallback). Runs on every Root
62
+ // render — cheap (small N, no DOM) — but worth memoising on `children`
63
+ // reference if profiling flags it.
64
+
65
+ function extractText(node: ReactNode): string {
66
+ let out = ''
67
+ Children.forEach(node, (child) => {
68
+ if (typeof child === 'string' || typeof child === 'number') {
69
+ out += String(child)
70
+ } else if (isValidElement(child)) {
71
+ const props = child.props as {children?: ReactNode}
72
+ if (props.children) out += extractText(props.children)
73
+ }
74
+ })
75
+ return out
52
76
  }
53
77
 
54
- /**
55
- * Scans the React tree for HeaderCell elements to extract width props.
56
- * Returns a grid-template-columns string.
57
- */
58
- function extractTemplate(children: ReactNode): string {
78
+ function extractColumnMeta(children: ReactNode): {template: string; labels: string[]} {
59
79
  const widths: string[] = []
80
+ const labels: string[] = []
60
81
 
61
82
  function walk(node: ReactNode) {
62
83
  Children.forEach(node, (child) => {
63
84
  if (!isValidElement(child)) return
64
85
  const props = child.props as Record<string, any>
65
- // Check if this looks like a HeaderCell (has width prop or is inside Header)
66
86
  const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
67
87
  if (displayName === 'HeaderCell' || child.type === HeaderCell) {
68
88
  widths.push(props.width || '1fr')
89
+ const label = props.label ?? extractText(props.children).trim()
90
+ labels.push(label)
69
91
  } else if (props.children) {
70
92
  walk(props.children)
71
93
  }
@@ -73,23 +95,35 @@ function extractTemplate(children: ReactNode): string {
73
95
  }
74
96
 
75
97
  walk(children)
76
- return widths.length > 0 ? widths.join(' ') : ''
98
+ return {template: widths.join(' '), labels}
77
99
  }
78
100
 
79
- function Root({children, variant = 'default', size = 'md'}: RootProps) {
80
- const inferredTemplateRef = useRef<string | null>(null)
101
+ // --- Root ---
81
102
 
82
- // Extract template from HeaderCell children synchronously
83
- const template = extractTemplate(children)
103
+ interface RootProps {
104
+ children: ReactNode
105
+ variant?: TableVariant
106
+ size?: TableSize
107
+ /** Opt out of responsive container-query behavior. Default true. */
108
+ responsive?: boolean
109
+ }
110
+
111
+ function Root({children, variant = 'default', size = 'md', responsive = true}: RootProps) {
112
+ const inferredTemplateRef = useRef<string | null>(null)
113
+ const {template, labels} = extractColumnMeta(children)
84
114
  if (template) {
85
115
  inferredTemplateRef.current = template
86
116
  }
87
117
 
88
118
  return (
89
- <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
119
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
90
120
  <html.div
91
121
  role="table"
92
- style={[styles.root, template ? styles.gridColumns(template) : undefined]}
122
+ style={[
123
+ styles.root,
124
+ template ? styles.gridColumns(template) : undefined,
125
+ responsive && styles.rootResponsive,
126
+ ]}
93
127
  >
94
128
  {children}
95
129
  </html.div>
@@ -136,6 +170,11 @@ function Body({children}: {children: ReactNode}) {
136
170
  const RowIndexContext = createContext<number>(-1)
137
171
 
138
172
  // --- Row ---
173
+ //
174
+ // Always provides CellIndexContext, regardless of variant. Previously this
175
+ // was only set up for `variant === 'bordered'`, which meant non-bordered
176
+ // tables had every cell reading {index: 0} from the default — fine until
177
+ // we needed to look up labels by column index.
139
178
 
140
179
  function Row({children}: {children: ReactNode}) {
141
180
  const {variant} = useTable()
@@ -153,13 +192,11 @@ function Row({children}: {children: ReactNode}) {
153
192
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
154
193
  ]}
155
194
  >
156
- {variant === 'bordered'
157
- ? childArray.map((child, index) => (
158
- <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
159
- {child}
160
- </CellIndexContext.Provider>
161
- ))
162
- : children}
195
+ {childArray.map((child, index) => (
196
+ <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
197
+ {child}
198
+ </CellIndexContext.Provider>
199
+ ))}
163
200
  </html.div>
164
201
  )
165
202
  }
@@ -171,11 +208,19 @@ const CellIndexContext = createContext<{index: number; total: number}>({index: 0
171
208
  function HeaderCell({
172
209
  children,
173
210
  width: _width,
211
+ label: _label,
212
+ isActions: _isActions,
174
213
  'aria-label': ariaLabel,
175
214
  }: {
176
215
  children?: ReactNode
177
216
  /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
178
217
  width?: string
218
+ /** Stack-mode label string. Required when responsive=true; falls back to
219
+ * text-content of children with a dev-only console.warn. */
220
+ label?: string
221
+ /** Marks this column as the actions column — its body cells render as a
222
+ * full-width footer in stack mode. */
223
+ isActions?: boolean
179
224
  'aria-label'?: string
180
225
  }) {
181
226
  const {size, variant} = useTable()
@@ -197,13 +242,19 @@ function HeaderCell({
197
242
  </html.div>
198
243
  )
199
244
  }
245
+ HeaderCell.displayName = 'HeaderCell'
200
246
 
201
247
  // --- Cell ---
248
+ //
249
+ // Renders the column-header label as a real <html.span>, hidden via
250
+ // container query in non-stack modes. The span is omitted entirely when
251
+ // isActions=true — actions footers don't show a label.
202
252
 
203
- function Cell({children}: {children: ReactNode}) {
204
- const {size, variant} = useTable()
253
+ function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
254
+ const {size, variant, labels, responsive} = useTable()
205
255
  const {index, total} = useContext(CellIndexContext)
206
256
  const isLast = variant === 'bordered' && index === total - 1
257
+ const label = labels[index] ?? ''
207
258
 
208
259
  return (
209
260
  <html.div
@@ -213,8 +264,12 @@ function Cell({children}: {children: ReactNode}) {
213
264
  size === 'sm' ? styles.cellSm : styles.cellMd,
214
265
  variant === 'bordered' && styles.borderedCell,
215
266
  isLast && styles.borderedCellLast,
267
+ isActions && styles.cellActions,
216
268
  ]}
217
269
  >
270
+ {responsive && !isActions && label !== '' ? (
271
+ <html.span style={styles.cellLabel}>{label}</html.span>
272
+ ) : null}
218
273
  {children}
219
274
  </html.div>
220
275
  )
@@ -223,6 +278,7 @@ function Cell({children}: {children: ReactNode}) {
223
278
  // --- Export ---
224
279
 
225
280
  export const Table = {
281
+ Container,
226
282
  Root,
227
283
  Header,
228
284
  Body,
@@ -232,4 +288,5 @@ export const Table = {
232
288
  Pagination,
233
289
  SortIndicator,
234
290
  ColumnFilter,
291
+ SortChip,
235
292
  }
@@ -3,8 +3,25 @@ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
 
6
+ // Container-query thresholds. Picked for typical 5-7 column admin tables —
7
+ // see Table.stories.tsx "Responsive" for the visual reasoning. Defined
8
+ // once here so it's clear they're a design-system constant, not a magic
9
+ // number sprinkled across files.
10
+ const COMPACT_BP = '720px'
11
+ const STACK_BP = '440px'
12
+
6
13
  export const styles = css.create({
7
- // Root — the single grid container for the entire table
14
+ // Outer wrapper that hosts the @container query. Wraps SortChip + Root
15
+ // + Pagination so all three react to the same width. Root itself does
16
+ // NOT carry containerType — keeping a single query target per Table.
17
+ container: {
18
+ containerType: 'inline-size',
19
+ display: 'flex',
20
+ flexDirection: 'column',
21
+ gap: spacing.sm,
22
+ },
23
+
24
+ // Root — the single grid container for the table itself
8
25
  root: {
9
26
  display: 'grid',
10
27
  borderWidth: 1,
@@ -17,9 +34,40 @@ export const styles = css.create({
17
34
  color: colors.text,
18
35
  },
19
36
 
20
- // Header group
37
+ // Stack-mode override: the Root's grid collapses to a single column so
38
+ // each Row spans full width and stacks vertically. Applied AFTER the
39
+ // dynamic gridColumns(template) so this rule wins inside the @container.
40
+ rootResponsive: {
41
+ gridTemplateColumns: {
42
+ default: null,
43
+ [`@container (max-width: ${STACK_BP})`]: '1fr',
44
+ },
45
+ // The card-list look needs the outer border to disappear in stack
46
+ // mode — each row paints its own border.
47
+ borderWidth: {
48
+ default: 1,
49
+ [`@container (max-width: ${STACK_BP})`]: 0,
50
+ },
51
+ backgroundColor: {
52
+ default: colors.bgCard,
53
+ [`@container (max-width: ${STACK_BP})`]: 'transparent',
54
+ },
55
+ overflow: {
56
+ default: 'hidden',
57
+ [`@container (max-width: ${STACK_BP})`]: 'visible',
58
+ },
59
+ rowGap: {
60
+ default: 0,
61
+ [`@container (max-width: ${STACK_BP})`]: spacing.sm,
62
+ },
63
+ },
64
+
65
+ // Header group — hidden entirely in stack mode
21
66
  header: {
22
- display: 'grid',
67
+ display: {
68
+ default: 'grid',
69
+ [`@container (max-width: ${STACK_BP})`]: 'none',
70
+ },
23
71
  gridColumn: '1 / -1',
24
72
  gridTemplateColumns: 'subgrid',
25
73
  backgroundColor: colors.bgCard,
@@ -32,43 +80,148 @@ export const styles = css.create({
32
80
  gridTemplateColumns: 'subgrid',
33
81
  },
34
82
 
35
- // Row — spans all columns, uses subgrid to share parent tracks
83
+ // Row — spans all columns. In stack mode its template flips to 1fr so
84
+ // children stack as grid items in a single column (we keep grid context
85
+ // so cells with `gridColumn` overrides still compute).
36
86
  row: {
37
87
  display: 'grid',
38
88
  gridColumn: '1 / -1',
39
- gridTemplateColumns: 'subgrid',
40
- borderBottomWidth: 1,
89
+ gridTemplateColumns: {
90
+ default: 'subgrid',
91
+ [`@container (max-width: ${STACK_BP})`]: '1fr',
92
+ },
93
+ borderBottomWidth: {
94
+ default: 1,
95
+ [`@container (max-width: ${STACK_BP})`]: 0,
96
+ },
41
97
  borderBottomStyle: 'solid',
42
98
  borderBottomColor: colors.border,
43
99
  },
44
100
  bodyRow: {
45
101
  backgroundColor: {
46
102
  default: 'transparent',
103
+ [`@container (max-width: ${STACK_BP})`]: colors.bgCard,
47
104
  ':hover': colors.bgCardHover,
48
105
  },
49
106
  transitionProperty: 'background-color',
50
107
  transitionDuration: '150ms',
51
108
  transitionTimingFunction: 'ease',
109
+ // Stack-mode card chrome
110
+ padding: {
111
+ default: 0,
112
+ [`@container (max-width: ${STACK_BP})`]: spacing.sm,
113
+ },
114
+ borderRadius: {
115
+ default: 0,
116
+ [`@container (max-width: ${STACK_BP})`]: radii.sm,
117
+ },
118
+ borderWidth: {
119
+ default: 0,
120
+ [`@container (max-width: ${STACK_BP})`]: 1,
121
+ },
122
+ borderStyle: 'solid',
123
+ borderColor: colors.border,
52
124
  },
53
125
 
54
- // Header cell
126
+ // Header cell — keeps headers on a single line in compact mode
55
127
  headerCell: {
56
128
  fontWeight: typography.fontWeightSemibold,
57
129
  color: colors.textMuted,
58
130
  textAlign: 'start',
131
+ whiteSpace: {
132
+ default: 'normal',
133
+ [`@container (max-width: ${COMPACT_BP})`]: 'nowrap',
134
+ },
59
135
  },
60
136
 
61
- // Body cell
137
+ // Body cell. In compact mode: nowrap + truncate. In stack mode:
138
+ // becomes a 2-column grid for label / value.
62
139
  cell: {
63
140
  color: colors.text,
64
- display: 'flex',
141
+ display: {
142
+ default: 'flex',
143
+ [`@container (max-width: ${STACK_BP})`]: 'grid',
144
+ },
65
145
  alignItems: 'center',
146
+ minWidth: {
147
+ default: 'auto',
148
+ [`@container (max-width: ${COMPACT_BP})`]: 0,
149
+ },
150
+ overflow: {
151
+ default: 'visible',
152
+ [`@container (max-width: ${COMPACT_BP})`]: 'hidden',
153
+ },
154
+ textOverflow: {
155
+ default: 'clip',
156
+ [`@container (max-width: ${COMPACT_BP})`]: 'ellipsis',
157
+ },
158
+ whiteSpace: {
159
+ default: 'normal',
160
+ [`@container (max-width: ${COMPACT_BP})`]: 'nowrap',
161
+ [`@container (max-width: ${STACK_BP})`]: 'normal',
162
+ },
163
+ gridTemplateColumns: {
164
+ default: null,
165
+ [`@container (max-width: ${STACK_BP})`]: '1fr 2fr',
166
+ },
167
+ gap: {
168
+ default: 0,
169
+ [`@container (max-width: ${STACK_BP})`]: spacing.sm,
170
+ },
171
+ },
172
+
173
+ // Label rendered as a real <span> inside each cell. Display:none in
174
+ // default/compact, shown only in stack — matches StyleX guidance to
175
+ // prefer real elements over ::before/::after for non-shadow-DOM cases.
176
+ cellLabel: {
177
+ display: {
178
+ default: 'none',
179
+ [`@container (max-width: ${STACK_BP})`]: 'block',
180
+ },
181
+ color: colors.textMuted,
182
+ fontWeight: typography.fontWeightMedium,
183
+ fontSize: typography.fontSizeXs,
184
+ },
185
+
186
+ // Actions cell. In default/compact behaves like any cell. In stack
187
+ // mode: full-width footer of the card, right-aligned, with a top
188
+ // separator. Label span is suppressed in JSX (Cell does not render it
189
+ // when isActions=true).
190
+ cellActions: {
191
+ gridTemplateColumns: {
192
+ default: null,
193
+ [`@container (max-width: ${STACK_BP})`]: '1fr',
194
+ },
195
+ justifyContent: {
196
+ default: null,
197
+ [`@container (max-width: ${STACK_BP})`]: 'flex-end',
198
+ },
199
+ marginTop: {
200
+ default: 0,
201
+ [`@container (max-width: ${STACK_BP})`]: spacing.sm,
202
+ },
203
+ paddingTop: {
204
+ default: null,
205
+ [`@container (max-width: ${STACK_BP})`]: spacing.sm,
206
+ },
207
+ borderTopWidth: {
208
+ default: 0,
209
+ [`@container (max-width: ${STACK_BP})`]: 1,
210
+ },
211
+ borderTopStyle: 'solid',
212
+ borderTopColor: colors.border,
66
213
  },
67
214
 
68
215
  // Size: sm
69
216
  cellSm: {
70
- paddingTop: spacing.xs,
71
- paddingBottom: spacing.xs,
217
+ paddingTop: {
218
+ default: spacing.xs,
219
+ [`@container (max-width: ${COMPACT_BP})`]: spacing.xs,
220
+ },
221
+ paddingBottom: {
222
+ default: spacing.xs,
223
+ [`@container (max-width: ${COMPACT_BP})`]: spacing.xs,
224
+ },
72
225
  paddingLeft: spacing.sm,
73
226
  paddingRight: spacing.sm,
74
227
  fontSize: typography.fontSizeXs,
@@ -77,22 +230,33 @@ export const styles = css.create({
77
230
  cellMd: {
78
231
  paddingTop: spacing.sm,
79
232
  paddingBottom: spacing.sm,
80
- paddingLeft: spacing.md,
81
- paddingRight: spacing.md,
233
+ paddingLeft: {
234
+ default: spacing.md,
235
+ [`@container (max-width: ${COMPACT_BP})`]: spacing.sm,
236
+ },
237
+ paddingRight: {
238
+ default: spacing.md,
239
+ [`@container (max-width: ${COMPACT_BP})`]: spacing.sm,
240
+ },
82
241
  fontSize: typography.fontSizeSm,
83
242
  },
84
243
 
85
- // Variant: striped (even body rows)
244
+ // Variant: striped (even body rows). Suppressed in stack — cards have
245
+ // their own surface and zebra-striping reads as a bug there.
86
246
  stripedEven: {
87
247
  backgroundColor: {
88
248
  default: colors.bgCardHover,
249
+ [`@container (max-width: ${STACK_BP})`]: colors.bgCard,
89
250
  ':hover': colors.bgCardHover,
90
251
  },
91
252
  },
92
253
 
93
- // Variant: bordered (cells get side borders)
254
+ // Variant: bordered (cells get side borders). Suppressed in stack.
94
255
  borderedCell: {
95
- borderRightWidth: 1,
256
+ borderRightWidth: {
257
+ default: 1,
258
+ [`@container (max-width: ${STACK_BP})`]: 0,
259
+ },
96
260
  borderRightStyle: 'solid',
97
261
  borderRightColor: colors.border,
98
262
  },
@@ -100,6 +264,15 @@ export const styles = css.create({
100
264
  borderRightWidth: 0,
101
265
  },
102
266
 
267
+ // SortChip — visible only in stack mode
268
+ sortChip: {
269
+ display: {
270
+ default: 'none',
271
+ [`@container (max-width: ${STACK_BP})`]: 'inline-flex',
272
+ },
273
+ alignSelf: 'flex-start',
274
+ },
275
+
103
276
  // Dynamic: grid columns applied on Root
104
277
  gridColumns: (template: string) => ({
105
278
  gridTemplateColumns: template,