@charcuterie/ui 2.7.0 → 2.9.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.
Files changed (117) hide show
  1. package/README.md +11 -0
  2. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
  3. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
  4. package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
  5. package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
  6. package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
  7. package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
  8. package/dist/AdaptiveGrid/chooseColumns.js +156 -0
  9. package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
  10. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
  11. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
  12. package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
  13. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
  14. package/dist/Button/Button.d.ts.map +1 -1
  15. package/dist/Button/Button.js +11 -6
  16. package/dist/Button/Button.js.map +1 -1
  17. package/dist/ButtonLink/ButtonLink.d.ts +59 -0
  18. package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
  19. package/dist/ButtonLink/ButtonLink.js +69 -0
  20. package/dist/ButtonLink/ButtonLink.js.map +1 -0
  21. package/dist/Combobox/Combobox.d.ts.map +1 -1
  22. package/dist/Combobox/Combobox.js +1 -1
  23. package/dist/Combobox/Combobox.js.map +1 -1
  24. package/dist/Dialog/Dialog.d.ts.map +1 -1
  25. package/dist/Dialog/Dialog.js +3 -1
  26. package/dist/Dialog/Dialog.js.map +1 -1
  27. package/dist/Field/Field.d.ts.map +1 -1
  28. package/dist/Field/Field.js +1 -1
  29. package/dist/Field/Field.js.map +1 -1
  30. package/dist/Field/FieldGroup.d.ts.map +1 -1
  31. package/dist/Field/FieldGroup.js +1 -1
  32. package/dist/Field/FieldGroup.js.map +1 -1
  33. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  34. package/dist/LogViewer/LogViewer.js +33 -1
  35. package/dist/LogViewer/LogViewer.js.map +1 -1
  36. package/dist/RouterLink/AnchorLink.d.ts +17 -0
  37. package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
  38. package/dist/RouterLink/AnchorLink.js +16 -0
  39. package/dist/RouterLink/AnchorLink.js.map +1 -0
  40. package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
  41. package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
  42. package/dist/RouterLink/RouterLinkProvider.js +47 -0
  43. package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
  44. package/dist/RouterLink/routerLink.d.ts +45 -0
  45. package/dist/RouterLink/routerLink.d.ts.map +1 -0
  46. package/dist/RouterLink/routerLink.js +31 -0
  47. package/dist/RouterLink/routerLink.js.map +1 -0
  48. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  49. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  50. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  51. package/dist/TextLink/TextLink.d.ts +69 -0
  52. package/dist/TextLink/TextLink.d.ts.map +1 -0
  53. package/dist/TextLink/TextLink.js +79 -0
  54. package/dist/TextLink/TextLink.js.map +1 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  56. package/dist/Tooltip/Tooltip.js +5 -1
  57. package/dist/Tooltip/Tooltip.js.map +1 -1
  58. package/dist/controlStyles.d.ts +36 -1
  59. package/dist/controlStyles.d.ts.map +1 -1
  60. package/dist/controlStyles.js +30 -0
  61. package/dist/controlStyles.js.map +1 -1
  62. package/dist/index.d.ts +16 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +10 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/intentStyles.d.ts +17 -0
  67. package/dist/intentStyles.d.ts.map +1 -1
  68. package/dist/intentStyles.js +17 -0
  69. package/dist/intentStyles.js.map +1 -1
  70. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  71. package/dist/mountStory.testHelpers.js +56 -0
  72. package/dist/mountStory.testHelpers.js.map +1 -1
  73. package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
  74. package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
  75. package/dist/reactRouter/ReactRouterLink.js +23 -0
  76. package/dist/reactRouter/ReactRouterLink.js.map +1 -0
  77. package/dist/reactRouter/index.d.ts +29 -0
  78. package/dist/reactRouter/index.d.ts.map +1 -0
  79. package/dist/reactRouter/index.js +29 -0
  80. package/dist/reactRouter/index.js.map +1 -0
  81. package/package.json +15 -3
  82. package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
  83. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
  84. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
  85. package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
  86. package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
  87. package/src/AdaptiveGrid/chooseColumns.ts +270 -0
  88. package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
  89. package/src/Button/Button.tsx +10 -22
  90. package/src/ButtonLink/ButtonLink.mdx +75 -0
  91. package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
  92. package/src/ButtonLink/ButtonLink.test.tsx +203 -0
  93. package/src/ButtonLink/ButtonLink.tsx +149 -0
  94. package/src/Combobox/Combobox.tsx +4 -1
  95. package/src/Dialog/Dialog.tsx +18 -2
  96. package/src/Field/Field.tsx +10 -2
  97. package/src/Field/FieldGroup.tsx +4 -2
  98. package/src/LogViewer/LogViewer.test.tsx +47 -0
  99. package/src/LogViewer/LogViewer.tsx +35 -1
  100. package/src/RouterLink/AnchorLink.tsx +23 -0
  101. package/src/RouterLink/RouterLink.mdx +178 -0
  102. package/src/RouterLink/RouterLinkProvider.tsx +68 -0
  103. package/src/RouterLink/routerLink.ts +56 -0
  104. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  105. package/src/TextLink/TextLink.mdx +93 -0
  106. package/src/TextLink/TextLink.stories.tsx +260 -0
  107. package/src/TextLink/TextLink.test.tsx +230 -0
  108. package/src/TextLink/TextLink.tsx +194 -0
  109. package/src/Tooltip/Tooltip.tsx +5 -1
  110. package/src/controlStyles.ts +69 -1
  111. package/src/icons.storyHelpers.tsx +32 -0
  112. package/src/index.ts +41 -0
  113. package/src/intentStyles.ts +19 -0
  114. package/src/mountStory.testHelpers.ts +63 -0
  115. package/src/reactRouter/ReactRouterLink.tsx +30 -0
  116. package/src/reactRouter/index.ts +29 -0
  117. package/src/sourceRules.test.ts +41 -6
@@ -0,0 +1,150 @@
1
+ import type { ReactNode } from "react"
2
+ import { Children } from "react"
3
+
4
+ import { toClassName } from "../toClassName.ts"
5
+ import { getContentMaxInlineSize } from "./chooseColumns.ts"
6
+ import type { BlockSizeResolver } from "./useAdaptiveColumns.ts"
7
+ import { useAdaptiveColumns } from "./useAdaptiveColumns.ts"
8
+
9
+ export type AdaptiveGridProps = {
10
+ /**
11
+ * Where the available block size is read from. Defaults to the
12
+ * viewport, which is the only thing that can answer "will this
13
+ * scroll".
14
+ */
15
+ blockSizeResolver?: BlockSizeResolver
16
+ children: ReactNode
17
+ /**
18
+ * Everything down the page that is not this grid, in CSS px —
19
+ * header, rails, filter rows. Subtracted before asking how many
20
+ * items stack.
21
+ */
22
+ chromeBlockSize?: number
23
+ className?: string
24
+ /**
25
+ * Draw this many columns instead of the automatic answer — the
26
+ * manual override, for a picker or a page that hoisted
27
+ * `useAdaptiveColumns` itself.
28
+ */
29
+ columns?: number
30
+ /**
31
+ * One item's block size in CSS px. **The one genuinely
32
+ * app-specific number.** Measure it off a running page and err
33
+ * high: a page that scrolls when it did not have to is the
34
+ * complaint this component exists to answer.
35
+ */
36
+ itemBlockSize: number
37
+ /**
38
+ * How many items to lay out. Defaults to the number of children,
39
+ * and is worth passing explicitly when the rendered count churns
40
+ * — a grid that reflowed every time one item finished loading
41
+ * would be worse than one that is an item out.
42
+ */
43
+ itemCount?: number
44
+ /** The most columns the automatic answer will take. */
45
+ maxColumns?: number
46
+ /** The narrowest a column may be, in CSS px. */
47
+ minColumnInlineSize?: number
48
+ /**
49
+ * The `localStorage` key a manual choice is remembered under.
50
+ * Namespace it to the app; omit it and nothing is written.
51
+ */
52
+ storageKey?: string
53
+ }
54
+
55
+ /**
56
+ * A wrapping grid that spends height before it spends width.
57
+ *
58
+ * The fleet's grids are `auto-fill, minmax()`: they take every
59
+ * column the window allows and land on seven items strung across an
60
+ * ultrawide. This one adds a column only when the items will not
61
+ * stack inside the viewport, and caps the content at a reading
62
+ * measure the rest of the time —
63
+ *
64
+ * > "The idea is that, if you're going to vertically scroll, it's
65
+ * > best to widen the displayed items. If not, then keep them in a
66
+ * > smaller grid, so they all still fit on screen."
67
+ *
68
+ * The rule itself is `chooseColumns`; the measuring is
69
+ * `useAdaptiveColumns`. This is the markup, and it exists because
70
+ * three separate things about that markup are easy to get wrong and
71
+ * silent when you do.
72
+ *
73
+ * ### Two boxes, and why it is not one
74
+ *
75
+ * The outer box is measured and **never capped**; the inner box
76
+ * carries the cap and the tracks. Collapsing them is a real bug
77
+ * with no error message: the cap narrows the content when the item
78
+ * count is low, so an element that is both measured and capped
79
+ * feeds the fold its own output and can never widen again. One
80
+ * column, forever.
81
+ *
82
+ * ### `min-w-0` on every child
83
+ *
84
+ * `[&>*]:min-w-0`, because a grid item's automatic minimum size is
85
+ * `min-content` — so one long unbroken string (a file path, a URL)
86
+ * pushes its track wider than its share and shoves the whole grid
87
+ * out of the viewport. The rule that stops it has to be on the
88
+ * children, and they belong to the caller, so it is applied from
89
+ * here rather than asked for.
90
+ *
91
+ * ### The tracks are an inline style
92
+ *
93
+ * `repeat(N, minmax(0, 1fr))` is interpolated, which a className
94
+ * may not be: Tailwind v4 scans source *text* for complete class
95
+ * strings, so `grid-cols-${columns}` generates nothing at all — no
96
+ * error, no warning, an unstyled element. A class-per-count would
97
+ * need every count to survive the scan; an inline style needs
98
+ * none.
99
+ */
100
+ export const AdaptiveGrid = ({
101
+ blockSizeResolver,
102
+ children,
103
+ chromeBlockSize,
104
+ className,
105
+ columns,
106
+ itemBlockSize,
107
+ itemCount,
108
+ maxColumns,
109
+ minColumnInlineSize,
110
+ storageKey,
111
+ }: AdaptiveGridProps): ReactNode => {
112
+ const layout = useAdaptiveColumns({
113
+ blockSizeResolver,
114
+ chromeBlockSize,
115
+ itemBlockSize,
116
+ itemCount: itemCount ?? Children.count(children),
117
+ maxAutoColumns: maxColumns,
118
+ minColumnInlineSize,
119
+ storageKey,
120
+ })
121
+
122
+ const drawnColumns = columns ?? layout.columns
123
+
124
+ // Folded from what is DRAWN rather than read off the hook, so an
125
+ // override widens the page cap with it. A four-column grid inside
126
+ // a one-column cap is the same bug as no cap at all, upside down.
127
+ const contentMaxInlineSize = getContentMaxInlineSize({
128
+ columns: drawnColumns,
129
+ })
130
+
131
+ return (
132
+ <div
133
+ className={toClassName(
134
+ "flex justify-center",
135
+ className,
136
+ )}
137
+ ref={layout.containerRef}
138
+ >
139
+ <div
140
+ className="grid w-full gap-4 [&>*]:min-w-0"
141
+ style={{
142
+ gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
143
+ maxInlineSize: contentMaxInlineSize,
144
+ }}
145
+ >
146
+ {children}
147
+ </div>
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,253 @@
1
+ import { expect, test } from "vitest"
2
+
3
+ import { containerQuery } from "../tokens.ts"
4
+ import {
5
+ chooseColumns,
6
+ DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
7
+ getColumnChoices,
8
+ getContentMaxInlineSize,
9
+ } from "./chooseColumns.ts"
10
+
11
+ /**
12
+ * The heuristic, written down at eleven sizes.
13
+ *
14
+ * **This table is the spec**, ported from the app the rule came out
15
+ * of. The one instruction attached to this feature is "do not
16
+ * hand-tune breakpoints against one screenshot", and a table of
17
+ * sizes with the answers beside them is the only form in which that
18
+ * instruction can be checked — a browser sweep proves what happens
19
+ * at the four sizes somebody thought to open, and this proves the
20
+ * shape of the rule between them.
21
+ *
22
+ * Node, not the browser: there is nothing to render. The DOM half —
23
+ * measuring a container, resizing it, remembering a choice — is
24
+ * `AdaptiveGrid.test.tsx`.
25
+ */
26
+
27
+ /** The rack the rule was tuned against. */
28
+ const NINE_ITEMS = 9
29
+
30
+ /** rip-deck's header, host row, drive rail, and bucket labels. */
31
+ const CHROME_BLOCK_SIZE = 260
32
+
33
+ /** A bay card, measured off a running page. */
34
+ const ITEM_BLOCK_SIZE = 150
35
+
36
+ const columnsAt = ({
37
+ blockSize,
38
+ inlineSize,
39
+ itemCount = NINE_ITEMS,
40
+ minColumnInlineSize,
41
+ }: {
42
+ blockSize: number
43
+ inlineSize: number
44
+ itemCount?: number
45
+ minColumnInlineSize?: number
46
+ }): number =>
47
+ chooseColumns({
48
+ availableBlockSize: blockSize,
49
+ availableInlineSize: inlineSize,
50
+ chromeBlockSize: CHROME_BLOCK_SIZE,
51
+ itemBlockSize: ITEM_BLOCK_SIZE,
52
+ itemCount,
53
+ minColumnInlineSize,
54
+ })
55
+
56
+ test("the column floor is Charcuterie's own cq-sm step", () => {
57
+ // The threshold reconciliation, asserted rather than described.
58
+ // A bare `380` in this file would drift the moment somebody
59
+ // touched the scale; deriving it from `containerQuery.sm` means
60
+ // the grid's floor and the library's container-query steps move
61
+ // together or not at all.
62
+ expect(DEFAULT_MIN_COLUMN_INLINE_SIZE_PX).toBe(
63
+ Number.parseFloat(containerQuery.sm) * 16,
64
+ )
65
+
66
+ // And it stays a step BELOW the size at which this library's
67
+ // container-query components change shape. The denser card that
68
+ // appears under `cq-md` is a real density, not a failure, so a
69
+ // column narrower than it is still a column worth having.
70
+ expect(DEFAULT_MIN_COLUMN_INLINE_SIZE_PX).toBeLessThan(
71
+ Number.parseFloat(containerQuery.md) * 16,
72
+ )
73
+ })
74
+
75
+ test("moving the floor from the measured 380 to cq-sm changes no answer", () => {
76
+ // The empirical half of that decision. The source app's floor was
77
+ // hand-measured at 380px; `cq-sm` is 384. Every row of the table
78
+ // below gives the same answer at either number, which is what
79
+ // makes swapping a magic number for a token a rename rather than
80
+ // a behaviour change.
81
+ for (const size of [
82
+ { blockSize: 1440, inlineSize: 3440 },
83
+ { blockSize: 700, inlineSize: 3440 },
84
+ { blockSize: 400, inlineSize: 5120 },
85
+ { blockSize: 844, inlineSize: 390 },
86
+ { blockSize: 390, inlineSize: 844 },
87
+ { blockSize: 1180, inlineSize: 820 },
88
+ { blockSize: 800, inlineSize: 1280 },
89
+ { blockSize: 900, inlineSize: 1440 },
90
+ { blockSize: 1080, inlineSize: 1920 },
91
+ { blockSize: 600, inlineSize: 1920 },
92
+ { blockSize: 1440, inlineSize: 2560 },
93
+ ]) {
94
+ expect(
95
+ columnsAt({ ...size, minColumnInlineSize: 380 }),
96
+ ).toBe(columnsAt(size))
97
+ }
98
+ })
99
+
100
+ test("it stays narrow on an ultrawide that is also tall", () => {
101
+ // The sentence the whole feature exists for:
102
+ //
103
+ // > "That way, you don't get 9-wide just because you're on an
104
+ // > ultrawide even if you have more height available."
105
+ expect(
106
+ columnsAt({ blockSize: 1440, inlineSize: 3440 }),
107
+ ).toBe(2)
108
+ })
109
+
110
+ test("it goes as wide as it may when the height runs out", () => {
111
+ // The same monitor, a short window. Nine items cannot stack in
112
+ // 700px, so inline size is all that is left.
113
+ expect(
114
+ columnsAt({ blockSize: 700, inlineSize: 3440 }),
115
+ ).toBe(3)
116
+ })
117
+
118
+ test("it never reaches the 9-wide the owner called weird", () => {
119
+ // Every item, no height at all, and a monitor wide enough to hold
120
+ // nine of them side by side.
121
+ expect(
122
+ columnsAt({ blockSize: 400, inlineSize: 5120 }),
123
+ ).toBeLessThanOrEqual(3)
124
+ })
125
+
126
+ test("one item gets one column however big the window", () => {
127
+ expect(
128
+ columnsAt({
129
+ blockSize: 400,
130
+ inlineSize: 3440,
131
+ itemCount: 1,
132
+ }),
133
+ ).toBe(1)
134
+ })
135
+
136
+ test("a phone stays at one column", () => {
137
+ // One column is the owner's own answer, and it falls out of the
138
+ // inline-size cap rather than out of a device check.
139
+ expect(
140
+ columnsAt({ blockSize: 844, inlineSize: 390 }),
141
+ ).toBe(1)
142
+ })
143
+
144
+ test("a phone held sideways takes two", () => {
145
+ // Short AND narrow: the height rule wants more columns and cannot
146
+ // have them.
147
+ expect(
148
+ columnsAt({ blockSize: 390, inlineSize: 844 }),
149
+ ).toBe(2)
150
+ })
151
+
152
+ test("a tablet takes two", () => {
153
+ expect(
154
+ columnsAt({ blockSize: 1180, inlineSize: 820 }),
155
+ ).toBe(2)
156
+ })
157
+
158
+ /**
159
+ * Note 1440x900 taking THREE columns while the larger 1920x1080
160
+ * takes two. That reads backwards and it is the rule working: the
161
+ * taller window stacks nine items in fewer stacks, so it needs
162
+ * fewer. Inline size is only ever the cap.
163
+ */
164
+ test.each([
165
+ { blockSize: 800, expected: 3, inlineSize: 1280 },
166
+ { blockSize: 900, expected: 3, inlineSize: 1440 },
167
+ { blockSize: 1080, expected: 2, inlineSize: 1920 },
168
+ { blockSize: 600, expected: 3, inlineSize: 1920 },
169
+ { blockSize: 1440, expected: 2, inlineSize: 2560 },
170
+ ])(
171
+ "$inlineSize x $blockSize renders $expected columns",
172
+ ({ blockSize, expected, inlineSize }) => {
173
+ expect(columnsAt({ blockSize, inlineSize })).toBe(
174
+ expected,
175
+ )
176
+ },
177
+ )
178
+
179
+ test("the cap is a default, not a constant", () => {
180
+ // The taste half. An app of squarer items raises it and gets the
181
+ // fourth column the source app deliberately refused.
182
+ expect(
183
+ chooseColumns({
184
+ availableBlockSize: 400,
185
+ availableInlineSize: 5120,
186
+ chromeBlockSize: CHROME_BLOCK_SIZE,
187
+ itemBlockSize: ITEM_BLOCK_SIZE,
188
+ itemCount: NINE_ITEMS,
189
+ maxColumns: 6,
190
+ }),
191
+ ).toBe(6)
192
+ })
193
+
194
+ test("chrome is a parameter, and it costs columns", () => {
195
+ // rip-deck's 260px of header and rails was a module-private
196
+ // constant. An app with no chrome stacks more items in the same
197
+ // window, so it needs fewer stacks.
198
+ const withChrome = chooseColumns({
199
+ availableBlockSize: 900,
200
+ availableInlineSize: 1440,
201
+ chromeBlockSize: 260,
202
+ itemBlockSize: ITEM_BLOCK_SIZE,
203
+ itemCount: NINE_ITEMS,
204
+ })
205
+
206
+ const withoutChrome = chooseColumns({
207
+ availableBlockSize: 900,
208
+ availableInlineSize: 1440,
209
+ itemBlockSize: ITEM_BLOCK_SIZE,
210
+ itemCount: NINE_ITEMS,
211
+ })
212
+
213
+ expect(withChrome).toBe(3)
214
+
215
+ expect(withoutChrome).toBe(2)
216
+ })
217
+
218
+ test("the content cap does not stretch one column across a monitor", () => {
219
+ expect(getContentMaxInlineSize({ columns: 1 })).toBe(
220
+ "56rem",
221
+ )
222
+ })
223
+
224
+ test("the content cap widens as columns are added", () => {
225
+ // "…the wrapping grids are all full-width only when you have too
226
+ // many items."
227
+ expect(
228
+ [1, 2, 3, 4].map((columns) =>
229
+ Number.parseFloat(
230
+ getContentMaxInlineSize({ columns }),
231
+ ),
232
+ ),
233
+ ).toEqual([56, 72, 106, 140])
234
+ })
235
+
236
+ test("the content cap's parts are overridable", () => {
237
+ expect(
238
+ getContentMaxInlineSize({
239
+ columnInlineSize: "20rem",
240
+ columns: 2,
241
+ gutterInlineSize: "2rem",
242
+ }),
243
+ ).toBe("42rem")
244
+ })
245
+
246
+ test("a picker never offers a count the cap will refuse", () => {
247
+ expect(getColumnChoices({ maxColumns: 3 })).toEqual([
248
+ "auto",
249
+ 1,
250
+ 2,
251
+ 3,
252
+ ])
253
+ })
@@ -0,0 +1,270 @@
1
+ import {
2
+ containerQuery,
3
+ contentInlineSize,
4
+ } from "../tokens.ts"
5
+
6
+ /**
7
+ * How many columns a wrapping grid should draw, and how wide the
8
+ * page around it may get.
9
+ *
10
+ * The arithmetic, lifted out of rip-deck's `useLayoutColumns` and
11
+ * made generic. It stays a pure fold — no `window`, no ref, no
12
+ * React — because a heuristic nobody can unit-test at nine sizes is
13
+ * a heuristic hand-tuned against one screenshot, and the table in
14
+ * `chooseColumns.test.ts` is the only form in which the rule can be
15
+ * checked at all.
16
+ *
17
+ * ## Height is spent first
18
+ *
19
+ * The owner's ask, which is the whole design:
20
+ *
21
+ * > "All these apps seem to be really narrow with a very large
22
+ * > max-width — 1 column, but waaaaaaay too wide. What I really
23
+ * > want is something like Rip-Deck where the app has a narrower
24
+ * > main column but the wrapping grids are all full-width only when
25
+ * > you have too many items. […] The idea is that, if you're going
26
+ * > to vertically scroll, it's best to widen the displayed items.
27
+ * > If not, then keep them in a smaller grid, so they all still fit
28
+ * > on screen. Ideally, we wouldn't require users to scroll."
29
+ *
30
+ * And the sentence it replaced, from the app this came out of:
31
+ *
32
+ * > "Like if you have enough height, try to fill it. If you don't,
33
+ * > then abuse as much width side-by-side as possible. That way,
34
+ * > you don't get 9-wide just because you're on an ultrawide even
35
+ * > if you have more height available."
36
+ *
37
+ * Stated plainly: **a column is something the layout is FORCED
38
+ * into** when the items will not stack inside the space available —
39
+ * never something it takes merely because a monitor is wide. That
40
+ * inversion is why `availableInlineSize` only ever *caps* the
41
+ * answer here rather than producing it, and it is the opposite of
42
+ * every `auto-fill, minmax()` grid in the fleet, which takes every
43
+ * column the width allows and lands on seven items across an
44
+ * ultrawide.
45
+ *
46
+ * The visible consequence is deliberately non-monotonic: 1440x900
47
+ * takes three columns while the *larger* 1920x1080 takes two. That
48
+ * reads backwards and is the rule working — the taller window
49
+ * stacks the same items in fewer stacks, so it needs fewer.
50
+ */
51
+
52
+ /**
53
+ * The most columns the automatic answer will ever choose.
54
+ *
55
+ * > "It would look weird to do 9 side-by-side."
56
+ *
57
+ * Three is the number the owner said he could "easily fit". A
58
+ * caller with squarer content raises it; a caller with wide content
59
+ * lowers it. It is taste about one app's content, which is exactly
60
+ * why it is a default rather than a constant.
61
+ */
62
+ export const DEFAULT_MAX_AUTO_COLUMNS = 3
63
+
64
+ /**
65
+ * The widest a manual picker offers, one above the automatic cap —
66
+ * because a cap is a default and not a ceiling on what a person is
67
+ * allowed to ask for.
68
+ */
69
+ export const DEFAULT_MAX_MANUAL_COLUMNS = 4
70
+
71
+ /**
72
+ * The narrowest a column may be, in CSS px, before the grid stops
73
+ * adding them. `24rem` at a 16px root — `containerQuery.sm`.
74
+ *
75
+ * ## Which threshold system this is, and which it is not
76
+ *
77
+ * Two live in the fleet today and they answer different questions,
78
+ * so this reconciles them by *keeping them apart*:
79
+ *
80
+ * - A `cq-*` step is read **by the item, inside the track**: "how
81
+ * should this card lay itself out at the width it was handed."
82
+ * Charcuterie's are `--cq-xs` … `--cq-xl`, deliberately not
83
+ * Tailwind's `--container-*` (see
84
+ * `docs/decisions/2026-07-29-container-query-scale-is-cq-not-container.md`).
85
+ * - This number is read **by the grid, outside the item**: "is
86
+ * another track worth having at all."
87
+ *
88
+ * Nothing forces them to agree, and the source app's did not: its
89
+ * cards restyle at Tailwind's own `28rem` container variant while
90
+ * its grid floor sat at a hand-measured `380px`. It set the floor
91
+ * *below* the restyle threshold on purpose, because the denser card
92
+ * that appears under it is a real density rather than a failure.
93
+ *
94
+ * That relationship is what survives here, expressed in
95
+ * Charcuterie's own scale instead of a magic number: the floor
96
+ * defaults to `cq-sm` (24rem = 384px), which is within 4px of the
97
+ * measured 380 — every row of the ported spec table gives the same
98
+ * answer at either value — and stays a step below `cq-md` (32rem),
99
+ * where the library's own container-query components change shape.
100
+ *
101
+ * The link is asserted in `chooseColumns.test.ts` so it cannot
102
+ * drift back into a bare number.
103
+ */
104
+ export const DEFAULT_MIN_COLUMN_INLINE_SIZE_PX =
105
+ Number.parseFloat(containerQuery.sm) * 16
106
+
107
+ /**
108
+ * `"auto"`, or the number of columns a person explicitly asked for.
109
+ */
110
+ export type ColumnChoice = "auto" | number
111
+
112
+ /**
113
+ * The column count the automatic rule wants, for one measured space
114
+ * and one set of items.
115
+ */
116
+ export const chooseColumns = ({
117
+ availableBlockSize,
118
+ availableInlineSize,
119
+ chromeBlockSize = 0,
120
+ itemBlockSize,
121
+ itemCount,
122
+ maxColumns = DEFAULT_MAX_AUTO_COLUMNS,
123
+ minColumnInlineSize = DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
124
+ }: {
125
+ /**
126
+ * The block size the layout has to spend, in CSS px — the
127
+ * viewport's, not the grid's.
128
+ *
129
+ * It has to come from the viewport, because the question being
130
+ * asked is "will this scroll", and an element's own block size
131
+ * cannot answer it: a grid in normal flow is exactly as tall as
132
+ * its contents, so measuring it always says "it fits."
133
+ */
134
+ availableBlockSize: number
135
+ /**
136
+ * The inline size the grid has to spend, in CSS px — the
137
+ * container's, not the viewport's.
138
+ *
139
+ * The container is the honest source here: a grid sitting beside
140
+ * a rail has less room than the window suggests, and this library
141
+ * is built around the case where "the component's container is
142
+ * what changed, not the window."
143
+ *
144
+ * It must be measured on an element that is **not itself capped
145
+ * by the answer**, or the fold feeds on its own output and can
146
+ * never widen. `AdaptiveGrid` guarantees that structurally.
147
+ */
148
+ availableInlineSize: number
149
+ /**
150
+ * Everything down the page that is not this grid, in CSS px —
151
+ * headers, rails, filter rows, labels. Subtracted from
152
+ * `availableBlockSize` before asking how many items stack.
153
+ */
154
+ chromeBlockSize?: number
155
+ /**
156
+ * One item's block size, in CSS px. **The one genuinely
157
+ * app-specific number**, and the only required one.
158
+ *
159
+ * Measure it off a running page rather than deriving it, and err
160
+ * high: over-estimating means the grid reaches for a column
161
+ * slightly early, and a page that scrolls when it did not have to
162
+ * is the complaint this exists to answer. It only has to be right
163
+ * to within about an item — being one out shifts a borderline
164
+ * size by one column, which is taste.
165
+ */
166
+ itemBlockSize: number
167
+ /**
168
+ * How many items the grid is being asked to fit.
169
+ *
170
+ * The stable count rather than the rendered one where those
171
+ * differ: a column count that reflowed every time one item
172
+ * finished loading would be worse than one that is an item out.
173
+ */
174
+ itemCount: number
175
+ /** @see DEFAULT_MAX_AUTO_COLUMNS */
176
+ maxColumns?: number
177
+ /** @see DEFAULT_MIN_COLUMN_INLINE_SIZE_PX */
178
+ minColumnInlineSize?: number
179
+ }): number => {
180
+ // Inline size is a CAP, never a reason. This is the "don't go
181
+ // 9-wide just because you're on an ultrawide" half of the rule.
182
+ const inlineSizeCap = Math.max(
183
+ 1,
184
+ Math.min(
185
+ maxColumns,
186
+ Math.floor(availableInlineSize / minColumnInlineSize),
187
+ ),
188
+ )
189
+
190
+ if (itemCount <= 1) {
191
+ return 1
192
+ }
193
+
194
+ // Block size is the reason. How many items stack in the space,
195
+ // then how many stacks that many items need.
196
+ const rowsThatFit = Math.max(
197
+ 1,
198
+ Math.floor(
199
+ (availableBlockSize - chromeBlockSize) /
200
+ itemBlockSize,
201
+ ),
202
+ )
203
+
204
+ const columnsNeeded = Math.ceil(itemCount / rowsThatFit)
205
+
206
+ return Math.max(1, Math.min(inlineSizeCap, columnsNeeded))
207
+ }
208
+
209
+ /**
210
+ * How wide the content around the grid may grow, as a CSS length.
211
+ *
212
+ * The other half of the ask — "the app has a narrower main column
213
+ * but the wrapping grids are all full-width only when you have too
214
+ * many items." One column stays at a reading measure, because a
215
+ * 2000px-wide card is not a better card, it is one line of text the
216
+ * eye has to track across a monitor. Past that the cap **grows with
217
+ * the columns**, so three columns get room to be three readable
218
+ * things rather than three slivers.
219
+ *
220
+ * ### Why the numbers live in `@charcuterie/tokens`
221
+ *
222
+ * They are `contentInlineSize`, not constants in this file. A
223
+ * readable measure is a structural fact about the fleet in exactly
224
+ * the sense `screen` and `containerQuery` are — a variant may
225
+ * change how a card *looks*, not how far the eye tracks — so it
226
+ * belongs in the one package a Satori renderer or a plain-CSS
227
+ * consumer can read without a React tree. The *arithmetic* is not a
228
+ * token, and stays here.
229
+ *
230
+ * The three parts are still overridable per call, because an app of
231
+ * dense tables and an app of prose disagree about a measure and
232
+ * neither is wrong.
233
+ */
234
+ export const getContentMaxInlineSize = ({
235
+ columnInlineSize = contentInlineSize.column,
236
+ columns,
237
+ gutterInlineSize = contentInlineSize.gutter,
238
+ singleInlineSize = contentInlineSize.single,
239
+ }: {
240
+ /** What one column is worth, once there are several. */
241
+ columnInlineSize?: string
242
+ columns: number
243
+ /** Slack added on top of a multi-column cap, for the gaps. */
244
+ gutterInlineSize?: string
245
+ /** The cap when there is only one column. */
246
+ singleInlineSize?: string
247
+ }): string =>
248
+ columns <= 1
249
+ ? singleInlineSize
250
+ : `${Number.parseFloat(columnInlineSize) * columns + Number.parseFloat(gutterInlineSize)}rem`
251
+
252
+ /**
253
+ * The choices a manual picker offers: `"auto"`, then every count up
254
+ * to `maxColumns`.
255
+ *
256
+ * A function rather than a constant so the picker and the cap
257
+ * cannot disagree — an app that lowers its cap gets a shorter list
258
+ * for free, instead of offering a count it will then refuse.
259
+ */
260
+ export const getColumnChoices = ({
261
+ maxColumns = DEFAULT_MAX_MANUAL_COLUMNS,
262
+ }: {
263
+ maxColumns?: number
264
+ }): ColumnChoice[] => [
265
+ "auto",
266
+ ...Array.from(
267
+ { length: Math.max(1, maxColumns) },
268
+ (_, index) => index + 1,
269
+ ),
270
+ ]