@startsimpli/ui 0.4.117 → 0.4.119

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": "@startsimpli/ui",
3
- "version": "0.4.117",
3
+ "version": "0.4.119",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,9 +47,9 @@
47
47
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
- "@startsimpli/auth": "^0.4.35",
50
+ "@startsimpli/api": "^0.5.58",
51
51
  "@startsimpli/i18n": "^0.1.3",
52
- "@startsimpli/api": "^0.5.58"
52
+ "@startsimpli/auth": "^0.4.35"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -55,6 +55,29 @@ describe('TABLE_SCROLL_CSS', () => {
55
55
  it('leaves the scroller on overflow:auto so the bar is conditional on real overflow', () => {
56
56
  expect(TABLE_SCROLL_CSS).not.toMatch(/overflow\s*:\s*scroll/)
57
57
  })
58
+
59
+ /**
60
+ * startsim-xe1uo. The edge hint shipped fading to `hsl(var(--background))`,
61
+ * which made it INVISIBLE: it is absolutely positioned over rows whose
62
+ * background-color is `rgba(0,0,0,0)` all the way up, so what is actually
63
+ * behind it is whatever the page paints — `bg-gray-50` = rgb(249,250,251) on
64
+ * the marketing-agents dashboard, against a token resolving to
65
+ * rgb(250,250,250). Sampled from the live page: delta (1,0,1).
66
+ *
67
+ * The hint cannot know its surface, so it must SHADE rather than MATCH. Every
68
+ * "fade the edge" snippet reaches for the background token, so guard the
69
+ * shape here; the visible-shading claim itself is carried by the pixel diff
70
+ * in examples/workflow-studio/scripts/measure-scrollers.mjs (delta 3 -> 35).
71
+ */
72
+ it('shades the edge hint instead of trying to match an unknowable background', () => {
73
+ const edgeRules = TABLE_SCROLL_CSS.slice(TABLE_SCROLL_CSS.indexOf('.ss-table-edge-right'))
74
+ expect(edgeRules).toMatch(/linear-gradient/)
75
+ expect(edgeRules).not.toMatch(/--background/)
76
+ expect(edgeRules).toMatch(/--foreground/)
77
+ // A fully transparent stop at the inner end, so it reads as a fade and not
78
+ // as a hard 24px band.
79
+ expect(edgeRules).toMatch(/--foreground\)\s*\/\s*0\)/)
80
+ })
58
81
  })
59
82
 
60
83
  describe('Table', () => {
@@ -257,6 +257,9 @@ export function UnifiedTable<TData>({
257
257
  initialPage: paginationConfig?.currentPage || (initialURLState?.page ?? 1),
258
258
  serverSide: paginationConfig?.serverSide || false,
259
259
  onPageChange: paginationConfig?.onPageChange,
260
+ // `sortedData` IS the body — every row of it is rendered below, nothing is
261
+ // sliced here — so it is the only honest source for "Showing X-Y".
262
+ renderedCount: sortedData.length,
260
263
  })
261
264
 
262
265
  // Selection hook - supports both controlled and uncontrolled modes
@@ -1,7 +1,40 @@
1
1
  import { render, screen, fireEvent } from '@testing-library/react'
2
2
  import { TablePagination } from '../../components/TablePagination'
3
+ import { usePagination } from '../../hooks/usePagination'
3
4
  import { UsePaginationReturn } from '../../types'
4
5
 
6
+ /**
7
+ * Drives the REAL hook.
8
+ *
9
+ * The count line is the one thing in this component that is arithmetic rather
10
+ * than markup, so a mocked `getDisplayRange` turns its test into a test of the
11
+ * mock — which is how "Showing 1-20" over a body of 50 rows survived on every
12
+ * table in the fleet (bd startsim-b9v6p). Mocking stays legitimate for the
13
+ * button/page-number blocks below, where the hook's answers are inputs to the
14
+ * markup rather than the thing under test.
15
+ */
16
+ function RealPagination({
17
+ totalCount,
18
+ pageSize,
19
+ page = 1,
20
+ renderedCount,
21
+ totalFilteredCount,
22
+ }: {
23
+ totalCount: number
24
+ pageSize: number
25
+ page?: number
26
+ renderedCount?: number
27
+ totalFilteredCount?: number
28
+ }) {
29
+ const pagination = usePagination({
30
+ totalCount,
31
+ initialPageSize: pageSize,
32
+ initialPage: page,
33
+ renderedCount,
34
+ })
35
+ return <TablePagination pagination={pagination} totalFilteredCount={totalFilteredCount} />
36
+ }
37
+
5
38
  describe('TablePagination', () => {
6
39
  const createMockPagination = (overrides?: Partial<UsePaginationReturn>): UsePaginationReturn => ({
7
40
  currentPage: 1,
@@ -22,8 +55,9 @@ describe('TablePagination', () => {
22
55
 
23
56
  describe('Display', () => {
24
57
  it('should render item count display', () => {
25
- const pagination = createMockPagination()
26
- const { container } = render(<TablePagination pagination={pagination} />)
58
+ const { container } = render(
59
+ <RealPagination totalCount={100} pageSize={25} renderedCount={25} />
60
+ )
27
61
 
28
62
  // Find the item count display div by its specific class and verify content
29
63
  const itemCountDiv = container.querySelector('.text-muted-foreground')
@@ -34,23 +68,52 @@ describe('TablePagination', () => {
34
68
  expect(itemCountDiv?.textContent).toContain('100')
35
69
  })
36
70
 
71
+ it('should count the rows on screen, not the declared page size', () => {
72
+ // A server page of 50 under a paginationConfig that declares 20 — the
73
+ // shape measured on every content view of the marketing-agents tenant.
74
+ const { container } = render(
75
+ <RealPagination totalCount={5485} pageSize={20} renderedCount={50} />
76
+ )
77
+
78
+ expect(container.querySelector('.text-muted-foreground')?.textContent).toContain('1-50')
79
+ })
80
+
81
+ it('should count a whole client-rendered corpus', () => {
82
+ const { container } = render(
83
+ <RealPagination totalCount={68} pageSize={20} renderedCount={68} />
84
+ )
85
+
86
+ expect(container.querySelector('.text-muted-foreground')?.textContent).toContain('1-68')
87
+ })
88
+
37
89
  it('should display current page and total pages', () => {
38
- const pagination = createMockPagination({ currentPage: 2, totalPages: 4 })
39
- render(<TablePagination pagination={pagination} />)
90
+ render(<RealPagination totalCount={100} pageSize={25} page={2} renderedCount={25} />)
40
91
 
41
92
  expect(screen.getByText(/Page 2 of 4/)).toBeInTheDocument()
42
93
  })
43
94
 
44
95
  it('should show filtered items indicator when totalFilteredCount differs from totalCount', () => {
45
- const pagination = createMockPagination()
46
- render(<TablePagination pagination={pagination} totalFilteredCount={50} />)
96
+ render(
97
+ <RealPagination
98
+ totalCount={100}
99
+ pageSize={25}
100
+ renderedCount={25}
101
+ totalFilteredCount={50}
102
+ />
103
+ )
47
104
 
48
105
  expect(screen.getByText(/filtered items/)).toBeInTheDocument()
49
106
  })
50
107
 
51
108
  it('should not show filtered indicator when counts match', () => {
52
- const pagination = createMockPagination()
53
- render(<TablePagination pagination={pagination} totalFilteredCount={100} />)
109
+ render(
110
+ <RealPagination
111
+ totalCount={100}
112
+ pageSize={25}
113
+ renderedCount={25}
114
+ totalFilteredCount={100}
115
+ />
116
+ )
54
117
 
55
118
  expect(screen.queryByText(/filtered items/)).not.toBeInTheDocument()
56
119
  })
@@ -354,6 +354,43 @@ describe('usePagination', () => {
354
354
  expect(range.start).not.toBeGreaterThan(range.end)
355
355
  })
356
356
 
357
+ it('should end the range at the last row rendered, not the last row of a page size', () => {
358
+ // The consuming table fetched 50 while declaring 20 (bd startsim-b9v6p).
359
+ const { result } = renderHook(() =>
360
+ usePagination({ totalCount: 5485, initialPageSize: 20, renderedCount: 50 })
361
+ )
362
+
363
+ expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 50 })
364
+ })
365
+
366
+ it('should span the whole corpus when the table rendered all of it', () => {
367
+ const { result } = renderHook(() =>
368
+ usePagination({ totalCount: 68, initialPageSize: 20, renderedCount: 68 })
369
+ )
370
+
371
+ expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 68 })
372
+ })
373
+
374
+ it('should never run the range past the reported total', () => {
375
+ // A caller whose total is below what it rendered is its own defect; the
376
+ // range still has to read forwards.
377
+ const { result } = renderHook(() =>
378
+ usePagination({ totalCount: 50, initialPageSize: 20, renderedCount: 68 })
379
+ )
380
+
381
+ expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 50 })
382
+ })
383
+
384
+ it('should fall back to the page size while the body is empty', () => {
385
+ // A refetch that clears `data` between pages must not flash "No items"
386
+ // over a table that has thousands.
387
+ const { result } = renderHook(() =>
388
+ usePagination({ ...defaultProps, renderedCount: 0 })
389
+ )
390
+
391
+ expect(result.current.getDisplayRange()).toEqual({ start: 1, end: 25 })
392
+ })
393
+
357
394
  it('should report an empty range when the page sits past the last row', () => {
358
395
  // Server-side counts shrink under a page that is already open (rule 8:
359
396
  // the count comes from the server, the client never recounts).
@@ -0,0 +1,183 @@
1
+ /**
2
+ * "Showing X-Y of N" must describe the rows that are actually on screen
3
+ * (bd startsim-b9v6p).
4
+ *
5
+ * MEASURED LIVE on the marketing-agents tenant: every content view said
6
+ * "Showing 1-20 of N" no matter how many rows the body held — 50 for a
7
+ * server-paged view, 68 for one that renders its whole corpus. The end of the
8
+ * range was arithmetic over the paginator's OWN page size, a number the table
9
+ * body never consults, because `UnifiedTable` renders exactly the rows its
10
+ * caller hands it and never slices them.
11
+ *
12
+ * These tests drive the REAL `usePagination` through a real `<UnifiedTable>`,
13
+ * which is the only way to prove the wiring: `TablePagination`'s own suite
14
+ * mocks `getDisplayRange`, so it asserts the label renders whatever the mock
15
+ * said and can never see the range being wrong.
16
+ *
17
+ * THEY ASSERT AN INVARIANT, NOT A ROW COUNT. Every expectation below is
18
+ * "the label counts the rows that are on screen", read from the DOM both
19
+ * times. That is deliberate: whether `UnifiedTable` should also SLICE its data
20
+ * is a live question elsewhere, and pinning "50 rows render" here would turn
21
+ * this suite into an obstacle to that decision while proving nothing extra —
22
+ * the invariant is already red against the old arithmetic, which computed the
23
+ * end of the range from a page size the body never consulted.
24
+ */
25
+ import { render } from '@testing-library/react'
26
+ import { UnifiedTable } from '../../index'
27
+ import { ColumnConfig } from '../../types'
28
+
29
+ interface Row {
30
+ id: string
31
+ name: string
32
+ }
33
+
34
+ const columns: ColumnConfig<Row>[] = [{ id: 'name', header: 'Name', accessorKey: 'name' }]
35
+
36
+ /** `count` rows whose ids start at `startAt`, as a server page would arrive. */
37
+ function rows(count: number, startAt = 1): Row[] {
38
+ return Array.from({ length: count }, (_, i) => ({
39
+ id: String(startAt + i),
40
+ name: `Row ${startAt + i}`,
41
+ }))
42
+ }
43
+
44
+ /** The "Showing …" line, read out of the DOM the way a user reads it. */
45
+ function rangeLabel(container: HTMLElement): string {
46
+ const matches = Array.from(container.querySelectorAll('div')).filter((d) =>
47
+ /^Showing \d+-\d+ of \d+$/.test((d.textContent ?? '').trim()),
48
+ )
49
+ if (matches.length === 0) throw new Error('no "Showing X-Y of N" label rendered')
50
+ // Deepest match: an ancestor carrying only this text is the same label.
51
+ return (matches[matches.length - 1].textContent ?? '').trim()
52
+ }
53
+
54
+ /** How many body rows the table actually put on screen. */
55
+ function renderedRowCount(container: HTMLElement): number {
56
+ return container.querySelectorAll('tbody tr').length
57
+ }
58
+
59
+ /**
60
+ * Asserts the one thing the label owes the user: its span is the rows in the
61
+ * body. Both numbers are read out of the same DOM, so the assertion holds
62
+ * whatever the table decides to render.
63
+ */
64
+ function expectRangeToCountTheBody(container: HTMLElement, opts: { from: number; of: number }) {
65
+ const rows = renderedRowCount(container)
66
+ expect(rows).toBeGreaterThan(0)
67
+ expect(rangeLabel(container)).toBe(
68
+ `Showing ${opts.from}-${opts.from + rows - 1} of ${opts.of}`,
69
+ )
70
+ }
71
+
72
+ describe('pagination range vs the rows on screen', () => {
73
+ it('counts a server page of 50 as 50, not as the declared page size', () => {
74
+ // news_item on the marketing-agents tenant: 5485 rows, 50 fetched per page,
75
+ // a paginationConfig that declares 20. The body holds 50.
76
+ const { container } = render(
77
+ <UnifiedTable<Row>
78
+ data={rows(50)}
79
+ columns={columns}
80
+ tableId="server-paged"
81
+ getRowId={(r) => r.id}
82
+ pagination={{
83
+ enabled: true,
84
+ pageSize: 20,
85
+ totalCount: 5485,
86
+ currentPage: 1,
87
+ serverSide: true,
88
+ onPageChange: jest.fn(),
89
+ }}
90
+ />,
91
+ )
92
+
93
+ // Pre-fix this read "Showing 1-20 of 5485" over a body of 50 rows.
94
+ expectRangeToCountTheBody(container, { from: 1, of: 5485 })
95
+ })
96
+
97
+ it('counts a fully client-rendered table as all of it', () => {
98
+ // topic: the page asks for every row, so all 68 render at once and the
99
+ // range is the whole corpus.
100
+ const { container } = render(
101
+ <UnifiedTable<Row>
102
+ data={rows(68)}
103
+ columns={columns}
104
+ tableId="client-rendered"
105
+ getRowId={(r) => r.id}
106
+ pagination={{
107
+ enabled: true,
108
+ pageSize: 20,
109
+ totalCount: 68,
110
+ currentPage: 1,
111
+ serverSide: false,
112
+ }}
113
+ />,
114
+ )
115
+
116
+ // Pre-fix this read "Showing 1-20 of 68" over a body of 68 rows.
117
+ expectRangeToCountTheBody(container, { from: 1, of: 68 })
118
+ })
119
+
120
+ it('leaves a table whose page size matches its fetch exactly as it was', () => {
121
+ // The overwhelming majority of the fleet: 25 declared, 25 rendered, page 3.
122
+ const { container } = render(
123
+ <UnifiedTable<Row>
124
+ data={rows(25, 51)}
125
+ columns={columns}
126
+ tableId="well-behaved"
127
+ getRowId={(r) => r.id}
128
+ pagination={{
129
+ enabled: true,
130
+ pageSize: 25,
131
+ totalCount: 100,
132
+ currentPage: 3,
133
+ serverSide: true,
134
+ onPageChange: jest.fn(),
135
+ }}
136
+ />,
137
+ )
138
+
139
+ expectRangeToCountTheBody(container, { from: 51, of: 100 })
140
+ })
141
+
142
+ it('stops a short last page at the last row', () => {
143
+ const { container } = render(
144
+ <UnifiedTable<Row>
145
+ data={rows(15, 76)}
146
+ columns={columns}
147
+ tableId="short-last-page"
148
+ getRowId={(r) => r.id}
149
+ pagination={{
150
+ enabled: true,
151
+ pageSize: 25,
152
+ totalCount: 90,
153
+ currentPage: 4,
154
+ serverSide: true,
155
+ onPageChange: jest.fn(),
156
+ }}
157
+ />,
158
+ )
159
+
160
+ expectRangeToCountTheBody(container, { from: 76, of: 90 })
161
+ })
162
+
163
+ it('says "No items" rather than a range when the body is empty', () => {
164
+ const { container } = render(
165
+ <UnifiedTable<Row>
166
+ data={[]}
167
+ columns={columns}
168
+ tableId="empty"
169
+ getRowId={(r) => r.id}
170
+ pagination={{
171
+ enabled: true,
172
+ pageSize: 20,
173
+ totalCount: 0,
174
+ currentPage: 1,
175
+ serverSide: true,
176
+ }}
177
+ />,
178
+ )
179
+
180
+ expect(() => rangeLabel(container)).toThrow()
181
+ expect(container.textContent).toContain('No items')
182
+ })
183
+ })
@@ -8,6 +8,15 @@ interface UsePaginationProps {
8
8
  initialPage?: number
9
9
  serverSide?: boolean
10
10
  onPageChange?: (page: number) => void
11
+ /**
12
+ * How many rows the table actually put on screen for this page.
13
+ *
14
+ * Optional because the hook is public API and a caller may drive it without a
15
+ * table attached; when it is absent the page size is the only number
16
+ * available. When it is present it WINS for the width of the display range,
17
+ * because the body is the thing the label is describing.
18
+ */
19
+ renderedCount?: number
11
20
  }
12
21
 
13
22
  export function usePagination({
@@ -16,6 +25,7 @@ export function usePagination({
16
25
  initialPage = 1,
17
26
  serverSide = false,
18
27
  onPageChange,
28
+ renderedCount,
19
29
  }: UsePaginationProps): UsePaginationReturn {
20
30
  const [currentPage, setCurrentPage] = useState(initialPage)
21
31
  const [pageSize, setPageSize] = useState(initialPageSize)
@@ -102,18 +112,40 @@ export function usePagination({
102
112
  * no span to describe. `totalCount` is whatever the server reported, so it can
103
113
  * legitimately be 0, or drop below the page the user already has open — both
104
114
  * cases used to produce a backwards range like `1-0`.
115
+ *
116
+ * The WIDTH of the span comes from `renderedCount` — the rows the table
117
+ * actually drew — and not from `pageSize`. `UnifiedTable` renders exactly the
118
+ * rows it is handed and never slices them, so a view that fetches 50 per page,
119
+ * or fetches its whole corpus at once, holds a different number of rows than
120
+ * the page size its caller declared. Deriving the end from `pageSize` made the
121
+ * label read "Showing 1-20" over a body of 50 or 68 rows on every table in the
122
+ * fleet (bd startsim-b9v6p).
123
+ *
124
+ * The OFFSET still comes from `currentPage * pageSize`: how many rows precede
125
+ * this page is a fact only the caller's page size can supply, and no count of
126
+ * what is on screen can recover it. A caller that declares one page size and
127
+ * fetches another therefore still gets a wrong `start` past page 1 — but its
128
+ * pager is already wrong in exactly the same way, and the fix for that is for
129
+ * the caller to declare the size it uses.
105
130
  */
106
131
  const getDisplayRange = useCallback(() => {
107
132
  if (totalCount <= 0) return { start: 0, end: 0 }
108
133
 
134
+ // An empty body is left to the page-size arithmetic rather than reported as
135
+ // an empty range: a refetch that clears `data` between pages would otherwise
136
+ // flash "No items" over a table that has thousands.
137
+ const rowsOnScreen = renderedCount != null && renderedCount > 0 ? renderedCount : pageSize
138
+
109
139
  const start = (currentPage - 1) * pageSize + 1
110
- const end = Math.min(currentPage * pageSize, totalCount)
140
+ // Clamped to `totalCount`: a caller reporting a total BELOW what it rendered
141
+ // is a different defect, and "1-68 of 50" would only make it unreadable.
142
+ const end = Math.min(start + rowsOnScreen - 1, totalCount)
111
143
 
112
144
  // The page begins past the last row: nothing is shown here.
113
145
  if (start > end) return { start: 0, end: 0 }
114
146
 
115
147
  return { start, end }
116
- }, [currentPage, pageSize, totalCount])
148
+ }, [currentPage, pageSize, totalCount, renderedCount])
117
149
 
118
150
  return {
119
151
  currentPage,
@@ -21,6 +21,48 @@ export const TABLE_EDGE_CLASS = 'ss-table-edge'
21
21
  export const TABLE_EDGE_LEFT_CLASS = 'ss-table-edge-left'
22
22
  export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
23
23
 
24
+ /**
25
+ * The edge hint SHADES its surface; it must never try to MATCH it.
26
+ *
27
+ * It shipped as a fade to `hsl(var(--background))` and that is structurally
28
+ * unable to work: the hint is absolutely positioned over table rows whose
29
+ * `background-color` is `rgba(0,0,0,0)` for every level from the cell up to the
30
+ * shell, so it cannot know what is behind it. On the marketing-agents tenant
31
+ * what is behind it is the dashboard's `bg-gray-50` = rgb(249,250,251), while
32
+ * the token resolves to rgb(250,250,250) — MEASURED 2026-09-09 by sampling the
33
+ * live page: delta (1,0,1), a contrast ratio of 1.00:1. The hint rendered at
34
+ * the right size, in the right place, and mounted only while a side hid
35
+ * content — and was invisible (bd startsim-xe1uo). Its only visible effect was
36
+ * erasing whatever glyphs sat under it, so the affordance appeared or not
37
+ * depending on whether that row had text at the right, and never on the header
38
+ * band, a blank cell, or the gap between rows.
39
+ *
40
+ * That matters more than it sounds, because the hint is the ONLY affordance a
41
+ * reader at the header row can see: the scrollbar is at the bottom of a
42
+ * scroller as tall as the whole table, measured 2,741-5,994px below the
43
+ * truncation on the live tenant.
44
+ *
45
+ * `--foreground` at a low alpha inverts with the theme, so one rule shades
46
+ * both. Measured with the pixel diff in
47
+ * examples/workflow-studio/scripts/measure-scrollers.mjs, which scores only the
48
+ * pixels that were bare surface before the hint was drawn:
49
+ *
50
+ * hsl(var(--background)) on bg-gray-50 -> delta 3 (the old rule)
51
+ * hsl(var(--foreground) / 0.14) on bg-gray-50 -> delta 35
52
+ * hsl(var(--foreground) / 0.14) on a dark page -> delta 33
53
+ *
54
+ * Confirmed against the deployed tenant rather than assumed from the harness:
55
+ * its `--foreground` resolves to rgba(2,8,23,0.14) at this alpha, which over
56
+ * the surface sampled there (rgb(249,250,251)) composites to rgb(214,216,219)
57
+ * — per-channel 35/34/32.
58
+ *
59
+ * If this needs re-tuning, move the alpha — do NOT reintroduce a token that
60
+ * names a background, or the hint silently disappears again wherever a page
61
+ * paints itself a shade the token does not know about.
62
+ */
63
+ const EDGE_SHADE = 'hsl(var(--foreground) / 0.14)'
64
+ const EDGE_SHADE_NONE = 'hsl(var(--foreground) / 0)'
65
+
24
66
  /**
25
67
  * Content scrolls, but on macOS nothing says so (startsim-5pq7h, 5pq7h.1).
26
68
  *
@@ -51,7 +93,11 @@ export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
51
93
  * Firefox on macOS gains nothing from them anyway (measured 0px with
52
94
  * `scrollbar-width: thin` applied and confirmed in the computed style), so
53
95
  * there is no Firefox branch to lose. Firefox users are served by the edge
54
- * hint below instead.
96
+ * hint below instead — a claim that was FALSE as originally shipped, because
97
+ * the hint faded to the background token and was invisible (startsim-xe1uo,
98
+ * measured delta 3). It holds because {@link EDGE_SHADE} shades instead. If
99
+ * that ever goes back to matching a background, this paragraph goes with it
100
+ * and Firefox is left with nothing at all.
55
101
  *
56
102
  * 2. `width` IS LOAD-BEARING even though the defect that started this is
57
103
  * horizontal. Declaring any `::-webkit-scrollbar` rule opts the element out
@@ -75,8 +121,14 @@ export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
75
121
  * or every narrow table in the fleet grows a dead bar.
76
122
  *
77
123
  * On Windows/Linux this REPLACES the ~15px classic bar with a 10px one; it does
78
- * not reserve a second gutter, so no platform guard is needed. Nothing here
79
- * animates or transitions, so `prefers-reduced-motion` has nothing to honour.
124
+ * not reserve a second gutter, so no platform guard is needed. That was the one
125
+ * unmeasured line in this file until startsim-xe1uo; MEASURED 2026-09-09 in
126
+ * headful Linux Chromium (classic scrollbars, negative control 15px with this
127
+ * sheet disabled) on the live tenant, it holds — no double gutter — at the cost
128
+ * of a bar 5px thinner and, at `currentColor` 22%, fainter than the platform's
129
+ * own. Weigh that if this ever gets tuned; it is not what sent the bead back.
130
+ * Nothing here animates or transitions, so `prefers-reduced-motion` has nothing
131
+ * to honour.
80
132
  *
81
133
  * ONE stylesheet, ONE id, ONE string: `ensureInjectedStyle` rewrites the sheet
82
134
  * when an existing id carries different text, so two call sites shipping
@@ -114,11 +166,11 @@ export const SCROLL_AFFORDANCE_CSS = `
114
166
  }
115
167
  .${TABLE_EDGE_RIGHT_CLASS} {
116
168
  right: 0;
117
- background-image: linear-gradient(to left, hsl(var(--background)), hsl(var(--background) / 0));
169
+ background-image: linear-gradient(to left, ${EDGE_SHADE}, ${EDGE_SHADE_NONE});
118
170
  }
119
171
  .${TABLE_EDGE_LEFT_CLASS} {
120
172
  left: 0;
121
- background-image: linear-gradient(to right, hsl(var(--background)), hsl(var(--background) / 0));
173
+ background-image: linear-gradient(to right, ${EDGE_SHADE}, ${EDGE_SHADE_NONE});
122
174
  }
123
175
  `.trim()
124
176