@startsimpli/ui 0.4.114 → 0.4.116

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.114",
3
+ "version": "0.4.116",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -48,8 +48,8 @@
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
50
  "@startsimpli/api": "^0.5.58",
51
- "@startsimpli/auth": "^0.4.35",
52
- "@startsimpli/i18n": "^0.1.3"
51
+ "@startsimpli/i18n": "^0.1.3",
52
+ "@startsimpli/auth": "^0.4.35"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -123,8 +123,8 @@
123
123
  "tailwindcss": "^3.4.19",
124
124
  "tsup": "^8.5.1",
125
125
  "typescript": "^6.0.3",
126
- "@startsimpli/i18n": "0.1.3",
127
- "@startsimpli/realtime": "0.0.2"
126
+ "@startsimpli/realtime": "0.0.2",
127
+ "@startsimpli/i18n": "0.1.3"
128
128
  },
129
129
  "keywords": [
130
130
  "ui",
@@ -7,6 +7,7 @@ import remarkGfm from 'remark-gfm'
7
7
  import React, { useState } from 'react'
8
8
  import { Copy, Check } from 'lucide-react'
9
9
  import { cn } from '../../utils/cn'
10
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance'
10
11
 
11
12
  /**
12
13
  * Themed markdown renderer for blog/article content.
@@ -31,6 +32,9 @@ function slugify(text: string): string {
31
32
  }
32
33
 
33
34
  export function MarkdownRenderer({ content, theme = 'light', className }: MarkdownRendererProps) {
35
+ // Wide GFM tables and long code lines both scroll sideways, and on macOS
36
+ // neither said so (startsim-5pq7h.1).
37
+ useScrollAffordance()
34
38
  const [copiedCode, setCopiedCode] = useState<string | null>(null)
35
39
 
36
40
  const copyToClipboard = async (code: string) => {
@@ -98,6 +102,9 @@ export function MarkdownRenderer({ content, theme = 'light', className }: Markdo
98
102
  style={syntaxStyle as Record<string, React.CSSProperties>}
99
103
  language={language}
100
104
  PreTag="div"
105
+ // Lands on the PreTag element — the box the theme puts
106
+ // `overflow: auto` on, and the one that actually scrolls.
107
+ className={SCROLLER_CLASS}
101
108
  customStyle={{ margin: 0, padding: '1rem', background: 'transparent', fontSize: '0.875rem', lineHeight: '1.5' }}
102
109
  codeTagProps={{
103
110
  style: {
@@ -120,7 +127,7 @@ export function MarkdownRenderer({ content, theme = 'light', className }: Markdo
120
127
  )
121
128
  },
122
129
  table: ({ children, ...props }) => (
123
- <div className="overflow-x-auto my-8">
130
+ <div className={cn('overflow-x-auto my-8', SCROLLER_CLASS)}>
124
131
  <table className="w-full border-collapse bg-card rounded-lg border border-border shadow-sm" {...props}>
125
132
  {children}
126
133
  </table>
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react'
5
5
  import { cn } from '../../lib/utils'
6
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance'
6
7
 
7
8
  export interface DiffSummary {
8
9
  totalFiles: number
@@ -202,7 +203,7 @@ export function DiffViewer({
202
203
  <summary className="select-none text-sm text-muted-foreground transition-colors hover:text-foreground">
203
204
  Working tree status
204
205
  </summary>
205
- <pre className="mt-2 overflow-x-auto font-mono text-xs text-muted-foreground">{status}</pre>
206
+ <pre className={cn('mt-2 overflow-x-auto font-mono text-xs text-muted-foreground', SCROLLER_CLASS)}>{status}</pre>
206
207
  </details>
207
208
  </div>
208
209
  )}
@@ -218,7 +219,7 @@ export function DiffViewer({
218
219
  <FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
219
220
  <span className="font-mono text-sm text-foreground">{file.filePath}</span>
220
221
  </div>
221
- <pre className="overflow-x-auto p-4 font-mono text-sm">
222
+ <pre className={cn('overflow-x-auto p-4 font-mono text-sm', SCROLLER_CLASS)}>
222
223
  {file.lines.map((line, index) =>
223
224
  isMetadataLine(line) ? null : (
224
225
  <div key={index} className={diffLineClass(line)}>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { cn } from '../../lib/utils'
5
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance'
5
6
 
6
7
  export type RecordFieldKind = 'text' | 'longtext' | 'url' | 'boolean' | 'json'
7
8
 
@@ -39,6 +40,9 @@ function hasValue(f: RecordField): boolean {
39
40
  * the same without duplicating the view.
40
41
  */
41
42
  export function RecordDetail({ fields, emptyMessage = 'No details.', className, showEmpty = false }: RecordDetailProps) {
43
+ // A json field is a code block that overflows sideways; on macOS it did so
44
+ // behind an invisible overlay bar (startsim-5pq7h.1).
45
+ useScrollAffordance()
42
46
  const visible = showEmpty ? fields : fields.filter(hasValue)
43
47
  const shorts = visible.filter((f) => f.kind !== 'longtext' && f.kind !== 'json')
44
48
  const longs = visible.filter((f) => f.kind === 'longtext')
@@ -101,7 +105,7 @@ export function RecordDetail({ fields, emptyMessage = 'No details.', className,
101
105
  {f.label}
102
106
  </h4>
103
107
  {hasValue(f) ? (
104
- <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
108
+ <pre className={cn('overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed', SCROLLER_CLASS)}>
105
109
  {f.value}
106
110
  </pre>
107
111
  ) : (
@@ -20,6 +20,8 @@
20
20
  */
21
21
 
22
22
  import * as React from 'react'
23
+ import { cn } from '../../lib/utils'
24
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance'
23
25
  import { RoleSelector } from './RoleSelector'
24
26
  import {
25
27
  userDisplayName,
@@ -160,6 +162,13 @@ export function MembersTable({
160
162
  availableRoles,
161
163
  currentUserId,
162
164
  }: MembersTableProps) {
165
+ // The root is the element that scrolls (`overflow-x-auto`), and on macOS its
166
+ // bar is a zero-height overlay — the same defect the shadcn Table had
167
+ // (startsim-5pq7h.1). The class goes on beside `cls.root` rather than inside
168
+ // MEMBERS_TABLE_DEFAULTS so a consumer overriding `classNames.root` cannot
169
+ // drop it, and the sheet is injected here because a class name in a
170
+ // constants file emits no rules on its own.
171
+ useScrollAffordance()
163
172
  const cls = { ...MEMBERS_TABLE_DEFAULTS, ...(classNames ?? {}) }
164
173
  const withStatus = showStatus ?? members.some((m) => rowStatus(m) !== null)
165
174
  const withLastActive = showLastActive ?? members.some((m) => m.lastLoginAt !== undefined)
@@ -184,7 +193,7 @@ export function MembersTable({
184
193
  const tight = (base: string, extra: string) => `${base} ${extra}`.trim()
185
194
 
186
195
  return (
187
- <div className={cls.root}>
196
+ <div className={cn(cls.root, SCROLLER_CLASS)}>
188
197
  <table className={cls.table}>
189
198
  <thead className={cls.thead}>
190
199
  <tr className={cls.headerRow}>
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Guards for the table's horizontal-scroll affordance (startsim-5pq7h).
3
+ *
4
+ * The defect these come from is PURELY VISUAL — "there is no sliding bar so it
5
+ * makes it difficult to see all the columns" — and jsdom has no layout, so
6
+ * NOTHING here proves a scrollbar is visible on screen. That claim is carried
7
+ * by browser measurement (headed Chrome + headed WebKit on macOS: 0px of
8
+ * scrollbar layout height before, 10px after, 0px when the table fits).
9
+ *
10
+ * What these DO guard is the two things a later edit can silently break:
11
+ * - the CSS that produces the bar, including the `scrollbar-width` rule that
12
+ * measurably DELETES it, and
13
+ * - the "no decoration when nothing overflows" contract.
14
+ */
15
+ import { render, screen } from '@testing-library/react'
16
+ import { renderHook, act } from '@testing-library/react'
17
+ import { useRef } from 'react'
18
+ import { Table, TableBody, TableCell, TableRow } from '../table'
19
+ import {
20
+ TABLE_SCROLL_CSS,
21
+ TABLE_SCROLL_STYLE_ID,
22
+ TABLE_SCROLLER_CLASS,
23
+ useScrollEdges,
24
+ } from '../table-scroll'
25
+
26
+ describe('TABLE_SCROLL_CSS', () => {
27
+ it('styles the webkit scrollbar, which is what gives the bar layout height', () => {
28
+ expect(TABLE_SCROLL_CSS).toContain(`.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar`)
29
+ expect(TABLE_SCROLL_CSS).toMatch(/height:\s*10px/)
30
+ })
31
+
32
+ /**
33
+ * The finding this test exists for. MEASURED in headed Chrome on macOS:
34
+ *
35
+ * ::-webkit-scrollbar { height: 10px } -> 10px of layout height
36
+ * the same rule PLUS scrollbar-width / scrollbar-color -> 0px (overlay again)
37
+ *
38
+ * Chromium honours the standard properties instead of the custom scrollbar
39
+ * and reverts to a zero-height overlay bar. Every "cross-browser scrollbar"
40
+ * snippet on the web tells you to add them, and doing so here would silently
41
+ * undo the fix with no visible error anywhere. Firefox on macOS gains nothing
42
+ * from them either (measured 0px with the property confirmed applied), so
43
+ * there is no browser to trade away.
44
+ */
45
+ it('does NOT set scrollbar-width or scrollbar-color, which would revert Chromium to an overlay bar', () => {
46
+ expect(TABLE_SCROLL_CSS).not.toMatch(/scrollbar-width/)
47
+ expect(TABLE_SCROLL_CSS).not.toMatch(/scrollbar-color/)
48
+ })
49
+
50
+ /**
51
+ * `overflow: auto` is what makes the bar conditional on real overflow. Under
52
+ * `overflow: scroll` every narrow table in the fleet would grow a dead 10px
53
+ * bar — a regression, not a fix.
54
+ */
55
+ it('leaves the scroller on overflow:auto so the bar is conditional on real overflow', () => {
56
+ expect(TABLE_SCROLL_CSS).not.toMatch(/overflow\s*:\s*scroll/)
57
+ })
58
+ })
59
+
60
+ describe('Table', () => {
61
+ const renderTable = (props: Record<string, unknown> = {}) =>
62
+ render(
63
+ <Table {...props}>
64
+ <TableBody>
65
+ <TableRow>
66
+ <TableCell>cell</TableCell>
67
+ </TableRow>
68
+ </TableBody>
69
+ </Table>
70
+ )
71
+
72
+ it('marks its scroll container so the scrollbar rules can find it', () => {
73
+ renderTable()
74
+ const table = screen.getByRole('table')
75
+ expect(table.parentElement).toHaveClass(TABLE_SCROLLER_CLASS)
76
+ expect(table.parentElement).toHaveClass('overflow-auto')
77
+ })
78
+
79
+ it('injects the stylesheet once, however many tables are on the page', () => {
80
+ render(
81
+ <>
82
+ <Table />
83
+ <Table />
84
+ <Table />
85
+ </>
86
+ )
87
+ expect(document.querySelectorAll(`#${TABLE_SCROLL_STYLE_ID}`)).toHaveLength(1)
88
+ expect(document.getElementById(TABLE_SCROLL_STYLE_ID)?.textContent).toBe(
89
+ TABLE_SCROLL_CSS
90
+ )
91
+ })
92
+
93
+ it('sends containerClassName to the scrolling box and className to the table', () => {
94
+ renderTable({ containerClassName: 'max-h-96', className: 'my-table' })
95
+ const table = screen.getByRole('table')
96
+ expect(table).toHaveClass('my-table')
97
+ expect(table.parentElement).toHaveClass('max-h-96')
98
+ expect(table).not.toHaveClass('max-h-96')
99
+ })
100
+ })
101
+
102
+ describe('useScrollEdges', () => {
103
+ /** jsdom has no layout, so geometry has to be stated outright. */
104
+ const harness = (clientWidth: number, scrollWidth: number, scrollLeft: number) => {
105
+ const scroller = document.createElement('div')
106
+ const table = document.createElement('table')
107
+ scroller.appendChild(table)
108
+ document.body.appendChild(scroller)
109
+ Object.defineProperty(scroller, 'clientWidth', { value: clientWidth, configurable: true })
110
+ Object.defineProperty(scroller, 'scrollWidth', { value: scrollWidth, configurable: true })
111
+ scroller.scrollLeft = scrollLeft
112
+ return { scroller, table }
113
+ }
114
+
115
+ const run = (clientWidth: number, scrollWidth: number, scrollLeft = 0) => {
116
+ const { scroller, table } = harness(clientWidth, scrollWidth, scrollLeft)
117
+ const { result } = renderHook(() => {
118
+ const ref = useRef<HTMLTableElement | null>(table)
119
+ return useScrollEdges(ref)
120
+ })
121
+ return { edges: result.current, scroller, result }
122
+ }
123
+
124
+ it('reports no edge at all when the table fits — a hint under a table that fits is the same defect as a bar under one', () => {
125
+ expect(run(846, 846).edges).toEqual({ left: false, right: false })
126
+ })
127
+
128
+ it('ignores a sub-pixel overhang rather than decorating a table that visually fits', () => {
129
+ expect(run(846, 847).edges).toEqual({ left: false, right: false })
130
+ })
131
+
132
+ it('reports the right edge when columns are unreached to the right', () => {
133
+ expect(run(846, 1014).edges).toEqual({ left: false, right: true })
134
+ })
135
+
136
+ it('reports the left edge once scrolled away from the start', () => {
137
+ expect(run(846, 1014, 84).edges).toEqual({ left: true, right: true })
138
+ })
139
+
140
+ it('drops the right edge at the end of the scroll range', () => {
141
+ expect(run(846, 1014, 168).edges).toEqual({ left: true, right: false })
142
+ })
143
+
144
+ it('re-reads geometry when the scroller is scrolled', () => {
145
+ const { scroller, result } = run(846, 1014)
146
+ expect(result.current).toEqual({ left: false, right: true })
147
+ act(() => {
148
+ scroller.scrollLeft = 168
149
+ scroller.dispatchEvent(new Event('scroll'))
150
+ })
151
+ expect(result.current).toEqual({ left: true, right: false })
152
+ })
153
+ })
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type RefObject } from 'react'
4
+
5
+ /**
6
+ * The table's share of the scroll affordance (startsim-5pq7h).
7
+ *
8
+ * The CSS and the class names moved to `lib/scroll-affordance.ts` when the same
9
+ * fix was applied to the package's other scrollers (startsim-5pq7h.1) — one
10
+ * stylesheet, one id, one string, so no two call sites can overwrite each
11
+ * other's rules. The measurements and the two rules that silently delete the
12
+ * fix are documented there. What stays here is the part that is genuinely about
13
+ * tables: the edge hint.
14
+ */
15
+ export {
16
+ SCROLL_AFFORDANCE_STYLE_ID as TABLE_SCROLL_STYLE_ID,
17
+ SCROLL_AFFORDANCE_CSS as TABLE_SCROLL_CSS,
18
+ TABLE_SCROLLER_CLASS,
19
+ TABLE_SHELL_CLASS,
20
+ TABLE_EDGE_CLASS,
21
+ TABLE_EDGE_LEFT_CLASS,
22
+ TABLE_EDGE_RIGHT_CLASS,
23
+ useScrollAffordance,
24
+ } from '../../lib/scroll-affordance'
25
+
26
+ export interface ScrollEdges {
27
+ /** There is content scrolled off to the left. */
28
+ left: boolean
29
+ /** There is content still unreached to the right. */
30
+ right: boolean
31
+ }
32
+
33
+ /**
34
+ * Which horizontal edges of a table's scroller still hide content.
35
+ *
36
+ * The persistent scrollbar sits at the bottom of the scroller, and the scroller
37
+ * is the FULL HEIGHT of the table — measured 3061-5698px below the fold on
38
+ * every content view of the marketing-agents tenant at 900px tall. So a reader
39
+ * looking at the header row never sees it, and needs a hint that travels with
40
+ * them. This drives that hint.
41
+ *
42
+ * Pass a ref to the `<table>`, not to the scroller: `Table` forwards its ref to
43
+ * the table element, and the scroller is its parent.
44
+ */
45
+ export function useScrollEdges(
46
+ tableRef: RefObject<HTMLTableElement | null>
47
+ ): ScrollEdges {
48
+ const [edges, setEdges] = useState<ScrollEdges>({ left: false, right: false })
49
+
50
+ useEffect(() => {
51
+ const table = tableRef.current
52
+ const scroller = table?.parentElement
53
+ if (!table || !scroller) return
54
+
55
+ const update = () => {
56
+ // 1px of slack: sub-pixel column widths leave scrollWidth a hair above
57
+ // clientWidth on tables that visually fit, and a hint under a table that
58
+ // fits is the same defect as a scrollbar under one.
59
+ const max = scroller.scrollWidth - scroller.clientWidth
60
+ const next: ScrollEdges = {
61
+ left: max > 1 && scroller.scrollLeft > 1,
62
+ right: max > 1 && scroller.scrollLeft < max - 1,
63
+ }
64
+ setEdges((prev) =>
65
+ prev.left === next.left && prev.right === next.right ? prev : next
66
+ )
67
+ }
68
+
69
+ update()
70
+ scroller.addEventListener('scroll', update, { passive: true })
71
+
72
+ let observer: ResizeObserver | undefined
73
+ if (typeof ResizeObserver !== 'undefined') {
74
+ // Observe the TABLE, not just the scroller. The scroller is `w-full`, so
75
+ // its own box does NOT change when a column is toggled, a filter narrows
76
+ // the data, or a page of rows re-sizes the auto-laid-out columns — only
77
+ // the table's box does. Watching the scroller alone misses every one of
78
+ // those and leaves the hint stale.
79
+ observer = new ResizeObserver(update)
80
+ observer.observe(table)
81
+ observer.observe(scroller)
82
+ }
83
+ window.addEventListener('resize', update)
84
+
85
+ return () => {
86
+ scroller.removeEventListener('scroll', update)
87
+ window.removeEventListener('resize', update)
88
+ observer?.disconnect()
89
+ }
90
+ }, [tableRef])
91
+
92
+ return edges
93
+ }
@@ -1,19 +1,44 @@
1
+ 'use client'
2
+
1
3
  import * as React from "react"
2
4
 
3
5
  import { cn } from "../../lib/utils"
6
+ import { TABLE_SCROLLER_CLASS, useScrollAffordance } from "./table-scroll"
4
7
 
5
- const Table = React.forwardRef<
6
- HTMLTableElement,
7
- React.HTMLAttributes<HTMLTableElement>
8
- >(({ className, ...props }, ref) => (
9
- <div className="relative w-full overflow-auto">
10
- <table
11
- ref={ref}
12
- className={cn("w-full caption-bottom text-sm", className)}
13
- {...props}
14
- />
15
- </div>
16
- ))
8
+ export interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
9
+ /**
10
+ * Classes for the scroll container that wraps the `<table>`. This div — not
11
+ * the `<table>` — is the element that actually scrolls horizontally, so
12
+ * anything that needs to change the scrolling box (a max-height, a different
13
+ * overflow) belongs here rather than in `className`.
14
+ */
15
+ containerClassName?: string
16
+ }
17
+
18
+ const Table = React.forwardRef<HTMLTableElement, TableProps>(
19
+ ({ className, containerClassName, ...props }, ref) => {
20
+ // Gives the scroller a scrollbar that is actually visible while it
21
+ // overflows. See lib/scroll-affordance.ts for the measurements — in
22
+ // particular why `scrollbar-width` must NOT be added alongside it.
23
+ useScrollAffordance()
24
+
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "relative w-full overflow-auto",
29
+ TABLE_SCROLLER_CLASS,
30
+ containerClassName
31
+ )}
32
+ >
33
+ <table
34
+ ref={ref}
35
+ className={cn("w-full caption-bottom text-sm", className)}
36
+ {...props}
37
+ />
38
+ </div>
39
+ )
40
+ }
41
+ )
17
42
  Table.displayName = "Table"
18
43
 
19
44
  const TableHeader = React.forwardRef<
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DataTableCore's edge hints (startsim-5pq7h).
3
+ *
4
+ * The persistent scrollbar restored on the `Table` primitive sits at the bottom
5
+ * of the scroller, and the scroller is as tall as the whole table — measured
6
+ * 3061-5698px BELOW THE FOLD on every content view of the marketing-agents
7
+ * tenant at a 900px-tall window. A reader at the header row never sees it, so
8
+ * the table also fades whichever edge still hides content.
9
+ *
10
+ * jsdom has no layout, so the geometry is stated outright below. These tests
11
+ * guard the WIRING and the "nothing overflows -> no decoration" contract; the
12
+ * visual claim is carried by browser measurement, not by them.
13
+ */
14
+ import { render, screen } from '@testing-library/react'
15
+ import { DataTableCore } from '../../components/DataTableCore'
16
+ import { ColumnConfig } from '../../types'
17
+ import { TABLE_EDGE_CLASS, TABLE_SHELL_CLASS } from '../../../ui/table-scroll'
18
+
19
+ interface Row {
20
+ id: string
21
+ name: string
22
+ }
23
+
24
+ const data: Row[] = [
25
+ { id: '1', name: 'Abu Dhabi Off-Plan Mortgage Guide' },
26
+ { id: '2', name: 'UAE Capital Markets Fee Changes' },
27
+ ]
28
+
29
+ const columns: ColumnConfig<Row>[] = [
30
+ { id: 'name', header: 'Name', accessorKey: 'name' },
31
+ ]
32
+
33
+ /** Force the scroll container's geometry, which jsdom otherwise reports as 0/0. */
34
+ function stubGeometry(clientWidth: number, scrollWidth: number) {
35
+ const original = {
36
+ clientWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'),
37
+ scrollWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth'),
38
+ }
39
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
40
+ configurable: true,
41
+ get() {
42
+ return this.tagName === 'DIV' ? clientWidth : 0
43
+ },
44
+ })
45
+ Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
46
+ configurable: true,
47
+ get() {
48
+ return this.tagName === 'DIV' ? scrollWidth : 0
49
+ },
50
+ })
51
+ return () => {
52
+ if (original.clientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', original.clientWidth)
53
+ if (original.scrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', original.scrollWidth)
54
+ }
55
+ }
56
+
57
+ const edges = (container: HTMLElement) =>
58
+ container.querySelectorAll(`.${TABLE_EDGE_CLASS}`)
59
+
60
+ describe('DataTableCore horizontal-scroll affordance', () => {
61
+ let restore: () => void = () => {}
62
+ afterEach(() => restore())
63
+
64
+ it('gives the table a non-scrolling shell for the hints to pin to', () => {
65
+ restore = stubGeometry(846, 846)
66
+ const { container } = render(
67
+ <DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
68
+ )
69
+ const table = screen.getByRole('table')
70
+ // The shell is the OUTER box; the element that scrolls is Table's own
71
+ // wrapper inside it. Pinning the hints to the scroller would make them
72
+ // scroll away with the content.
73
+ expect(container.querySelector(`.${TABLE_SHELL_CLASS}`)).toBe(
74
+ table.parentElement?.parentElement
75
+ )
76
+ })
77
+
78
+ it('renders no edge hint at all when every column fits', () => {
79
+ restore = stubGeometry(846, 846)
80
+ const { container } = render(
81
+ <DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
82
+ )
83
+ expect(edges(container)).toHaveLength(0)
84
+ })
85
+
86
+ it('fades the right edge when columns are unreached to the right', () => {
87
+ // /t/topic at a 1152px window: 846 visible, 1014 needed.
88
+ restore = stubGeometry(846, 1014)
89
+ const { container } = render(
90
+ <DataTableCore data={data} columns={columns} getRowId={(r) => r.id} />
91
+ )
92
+ const found = edges(container)
93
+ expect(found).toHaveLength(1)
94
+ expect(found[0]).toHaveClass('ss-table-edge-right')
95
+ // Decoration only — it must never intercept a click meant for the cell or
96
+ // the row-action menu underneath.
97
+ expect(found[0]).toHaveAttribute('aria-hidden', 'true')
98
+ })
99
+ })
@@ -1,7 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { ReactNode, useState, useCallback } from 'react'
3
+ import { ReactNode, useState, useCallback, useRef } from 'react'
4
4
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
5
+ import {
6
+ useScrollEdges,
7
+ TABLE_SHELL_CLASS,
8
+ TABLE_EDGE_CLASS,
9
+ TABLE_EDGE_LEFT_CLASS,
10
+ TABLE_EDGE_RIGHT_CLASS,
11
+ } from '../../../ui/table-scroll'
5
12
  import { Checkbox } from '../../../ui/checkbox'
6
13
  import { Button } from '../../../ui/button'
7
14
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '../../../ui/dropdown-menu'
@@ -91,6 +98,14 @@ export function DataTableCore<TData>({
91
98
  // State for tracking if a column is being dragged (to disable sorting during drag)
92
99
  const [isDraggingColumn, setIsDraggingColumn] = useState(false)
93
100
 
101
+ // The scrollbar restored in `Table` sits at the bottom of the scroller, and
102
+ // the scroller is as tall as the whole table — thousands of pixels below the
103
+ // fold on a full page of rows. These flags drive a fade at whichever edge
104
+ // still hides content, so the reader is told at the header row, not only at
105
+ // the very bottom of the page (startsim-5pq7h).
106
+ const tableRef = useRef<HTMLTableElement>(null)
107
+ const scrollEdges = useScrollEdges(tableRef)
108
+
94
109
  // Drag handlers
95
110
  const handleDragStart = useCallback((_: DragStart) => {
96
111
  setIsDraggingColumn(true)
@@ -334,8 +349,13 @@ export function DataTableCore<TData>({
334
349
  return (
335
350
  // overflow-x-auto: a wide table scrolls horizontally inside its own border
336
351
  // instead of overflowing the page to the right (startsim column-overflow).
337
- <div className={cn('rounded-md border border-border overflow-x-auto', className)}>
338
- <Table>
352
+ // NOTE: this div is not the element that ends up scrolling — `Table`'s own
353
+ // `overflow-auto` wrapper inside it is, because that wrapper is `w-full`
354
+ // and so never makes this one overflow. That is why the scrollbar styling
355
+ // and the ref below both target the inner wrapper. This div stays as the
356
+ // non-scrolling shell the edge hints are pinned to.
357
+ <div className={cn('rounded-md border border-border overflow-x-auto', TABLE_SHELL_CLASS, className)}>
358
+ <Table ref={tableRef}>
339
359
  {renderColumnHeaders()}
340
360
 
341
361
  <TableBody>
@@ -474,6 +494,17 @@ export function DataTableCore<TData>({
474
494
  )}
475
495
  </TableBody>
476
496
  </Table>
497
+
498
+ {/* Edge hints. Rendered only while that side actually hides something, so
499
+ a table that fits gets no decoration at all. `pointer-events: none`
500
+ keeps the rightmost column's cells and the row-action menu clickable
501
+ underneath. */}
502
+ {scrollEdges.left && (
503
+ <div aria-hidden="true" className={cn(TABLE_EDGE_CLASS, TABLE_EDGE_LEFT_CLASS)} />
504
+ )}
505
+ {scrollEdges.right && (
506
+ <div aria-hidden="true" className={cn(TABLE_EDGE_CLASS, TABLE_EDGE_RIGHT_CLASS)} />
507
+ )}
477
508
  </div>
478
509
  )
479
510
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { cn } from '../../lib/utils';
5
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance';
5
6
  import { StatusBadge } from '../badge/StatusBadge';
6
7
  import { EmptyState } from '../states/EmptyState';
7
8
  import type { NodeExecView } from './types';
@@ -20,6 +21,12 @@ function JsonBlock({
20
21
  label: string;
21
22
  value: Record<string, unknown> | null | undefined;
22
23
  }) {
24
+ // Pretty-printed JSON runs past the right edge of a narrow inspector rail,
25
+ // and on macOS said so with a zero-height overlay bar (startsim-5pq7h.1).
26
+ // This block also caps its height, so it takes a 10px vertical gutter too —
27
+ // there is no CSS route to a custom bar on one axis only (measured; see
28
+ // lib/scroll-affordance.ts).
29
+ useScrollAffordance();
23
30
  const isEmpty = value == null || Object.keys(value).length === 0;
24
31
  return (
25
32
  <div className="flex min-h-0 flex-col">
@@ -29,7 +36,7 @@ function JsonBlock({
29
36
  {isEmpty ? (
30
37
  <p className="text-xs italic text-muted-foreground">No {label.toLowerCase()}.</p>
31
38
  ) : (
32
- <pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
39
+ <pre className={cn('overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed', SCROLLER_CLASS)}>
33
40
  {JSON.stringify(value, null, 2)}
34
41
  </pre>
35
42
  )}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { cn } from '../../lib/utils';
5
+ import { SCROLLER_CLASS, useScrollAffordance } from '../../lib/scroll-affordance';
5
6
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
6
7
  import { StatusBadge } from '../badge/StatusBadge';
7
8
  import { EmptyState } from '../states/EmptyState';
@@ -139,12 +140,15 @@ function SchemaField({
139
140
  }
140
141
 
141
142
  function JsonBlock({ value }: { value: Record<string, unknown> | null | undefined }) {
143
+ // Pretty-printed JSON runs past the right edge of a narrow inspector rail,
144
+ // and on macOS said so with a zero-height overlay bar (startsim-5pq7h.1).
145
+ useScrollAffordance();
142
146
  const isEmpty = value == null || Object.keys(value).length === 0;
143
147
  if (isEmpty) {
144
148
  return <p className="text-xs italic text-muted-foreground">No data.</p>;
145
149
  }
146
150
  return (
147
- <pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
151
+ <pre className={cn('overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed', SCROLLER_CLASS)}>
148
152
  {JSON.stringify(value, null, 2)}
149
153
  </pre>
150
154
  );
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Guards for the package-wide scroll affordance (startsim-5pq7h.1).
3
+ *
4
+ * The defect is PURELY VISUAL — content continues past an edge and macOS draws
5
+ * a zero-height overlay bar that says so to nobody — and jsdom has no layout, so
6
+ * NOTHING here proves a scrollbar is visible on screen. That claim is carried by
7
+ * browser measurement (headless Chromium with `--hide-scrollbars` REMOVED, real
8
+ * components rendered in examples/workflow-studio/src/app/scrollers, borders
9
+ * subtracted): every surface below went from 0px of scrollbar to 10px, and the
10
+ * negative controls stayed at 0px.
11
+ *
12
+ * What these DO guard is what a later edit can silently break:
13
+ * - the two CSS properties that measurably DELETE the bar,
14
+ * - the `width` declaration, whose absence measurably makes the vertical bar
15
+ * WORSE (16px) rather than leaving it alone,
16
+ * - the "one sheet, one id, one string" rule — two different strings under one
17
+ * id overwrite each other on every mount,
18
+ * - and that each surface still marks the element that actually scrolls.
19
+ *
20
+ * SEVEN of the eight changed elements are covered below. MarkdownRenderer's two
21
+ * — its GFM table wrapper and its fenced code block — are NOT, and cannot be
22
+ * under this jest config: react-markdown@10 ships ESM, `transformIgnorePatterns`
23
+ * does not cover node_modules here, and the suite dies with
24
+ * `SyntaxError: Unexpected token 'export'` before the component renders. That is
25
+ * why this package has no blog tests at all. Those two are carried by the browser
26
+ * run only, and the code-block one is the most fragile thing in this change:
27
+ * `className={SCROLLER_CLASS}` is handed to react-syntax-highlighter, which
28
+ * forwards it to its PreTag element — an upgrade could stop doing that, and
29
+ * nothing here would notice.
30
+ */
31
+ import { render, screen } from '@testing-library/react'
32
+ import userEvent from '@testing-library/user-event'
33
+ import {
34
+ SCROLL_AFFORDANCE_CSS,
35
+ SCROLL_AFFORDANCE_STYLE_ID,
36
+ SCROLLER_CLASS,
37
+ TABLE_SCROLLER_CLASS,
38
+ } from '../scroll-affordance'
39
+ import { DiffViewer } from '../../components/diff/DiffViewer'
40
+ import { RecordDetail } from '../../components/record-detail/RecordDetail'
41
+ import { MembersTable } from '../../components/team/MembersTable'
42
+ import { ExecNodeDetails } from '../../components/workflows/ExecNodeDetails'
43
+ import { NodeInspector } from '../../components/workflows/NodeInspector'
44
+ import { Table, TableBody, TableCell, TableRow } from '../../components/ui/table'
45
+ import type { MemberRow } from '../../components/team/types'
46
+
47
+ describe('SCROLL_AFFORDANCE_CSS', () => {
48
+ it('styles the webkit scrollbar for both the table scroller and the general one', () => {
49
+ expect(SCROLL_AFFORDANCE_CSS).toContain(`.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar`)
50
+ expect(SCROLL_AFFORDANCE_CSS).toContain(`.${SCROLLER_CLASS}::-webkit-scrollbar`)
51
+ expect(SCROLL_AFFORDANCE_CSS).toMatch(/height:\s*10px/)
52
+ })
53
+
54
+ /**
55
+ * MEASURED 2026-09-07, headless Chromium with --hide-scrollbars removed, on a
56
+ * box that overflows VERTICALLY (borders subtracted):
57
+ *
58
+ * height: 10px; width: 10px -> 10px vertical bar
59
+ * height: 10px (no width) -> 16px vertical bar
60
+ * ::-webkit-scrollbar:horizontal { height:10px } -> 0px on BOTH axes
61
+ *
62
+ * Declaring any ::-webkit-scrollbar rule opts the element out of overlay
63
+ * scrollbars on both axes, so dropping `width` as a "we only care about
64
+ * horizontal" tidy-up does not leave the vertical axis alone — it makes it
65
+ * fatter than the platform default this replaces.
66
+ */
67
+ it('declares width alongside height, because a height-only rule leaves a 16px vertical bar', () => {
68
+ expect(SCROLL_AFFORDANCE_CSS).toMatch(/width:\s*10px/)
69
+ })
70
+
71
+ /**
72
+ * The finding this test exists for. MEASURED in headed Chrome on macOS:
73
+ *
74
+ * ::-webkit-scrollbar { height: 10px } -> 10px of layout height
75
+ * the same rule PLUS scrollbar-width / scrollbar-color -> 0px (overlay again)
76
+ *
77
+ * Chromium honours the standard properties instead of the custom scrollbar
78
+ * and reverts to a zero-height overlay bar. Every "cross-browser scrollbar"
79
+ * snippet on the web tells you to add them, and doing so here would silently
80
+ * undo the fix with no visible error anywhere. Firefox on macOS gains nothing
81
+ * from them either (measured 0px with the property confirmed applied), so
82
+ * there is no browser to trade away.
83
+ */
84
+ it('does NOT set scrollbar-width or scrollbar-color, which would revert Chromium to an overlay bar', () => {
85
+ expect(SCROLL_AFFORDANCE_CSS).not.toMatch(/scrollbar-width/)
86
+ expect(SCROLL_AFFORDANCE_CSS).not.toMatch(/scrollbar-color/)
87
+ })
88
+
89
+ /**
90
+ * `overflow: auto` is what makes the bar conditional on real overflow. Under
91
+ * `overflow: scroll` every narrow table and every short code block in the
92
+ * fleet would grow a dead 10px bar — a regression, not a fix.
93
+ */
94
+ it('leaves scrollers on overflow:auto so the bar is conditional on real overflow', () => {
95
+ expect(SCROLL_AFFORDANCE_CSS).not.toMatch(/overflow\s*:\s*scroll/)
96
+ })
97
+ })
98
+
99
+ describe('the surfaces that opted in', () => {
100
+ const diff = [
101
+ 'diff --git a/a.ts b/a.ts',
102
+ 'index 1111111..2222222 100644',
103
+ '@@ -1 +1 @@',
104
+ '-const a = 1',
105
+ '+const a = 2',
106
+ ].join('\n')
107
+
108
+ const member: MemberRow = {
109
+ id: '1',
110
+ userId: 'u1',
111
+ teamId: 't1',
112
+ role: 'owner',
113
+ joinedAt: '2026-01-04T00:00:00Z',
114
+ user: { id: 'u1', email: 'someone@example.com' },
115
+ } as MemberRow
116
+
117
+ it("marks DiffViewer's per-file block and its working-tree status block", () => {
118
+ const { container } = render(<DiffViewer diff={diff} status="On branch main" />)
119
+ const pres = container.querySelectorAll('pre')
120
+ expect(pres.length).toBeGreaterThan(0)
121
+ pres.forEach((pre) => expect(pre).toHaveClass(SCROLLER_CLASS))
122
+ })
123
+
124
+ it("marks RecordDetail's json block", () => {
125
+ const { container } = render(
126
+ <RecordDetail fields={[{ label: 'payload', value: '{ "a": 1 }', kind: 'json' }]} />
127
+ )
128
+ expect(container.querySelector('pre')).toHaveClass(SCROLLER_CLASS)
129
+ })
130
+
131
+ /**
132
+ * The class goes on beside `cls.root` rather than inside MEMBERS_TABLE_DEFAULTS
133
+ * so that a consumer passing its own `classNames.root` — which replaces the
134
+ * default outright — cannot silently drop the affordance.
135
+ */
136
+ it("marks MembersTable's scrolling root even when the consumer overrides its class", () => {
137
+ const { container } = render(
138
+ <MembersTable members={[member]} classNames={{ root: 'my-own-root' }} />
139
+ )
140
+ const root = container.querySelector('.my-own-root')
141
+ expect(root).toHaveClass(SCROLLER_CLASS)
142
+ })
143
+
144
+ it("marks ExecNodeDetails' json blocks", () => {
145
+ const { container } = render(
146
+ <ExecNodeDetails
147
+ node={{ nodeId: 'n1', status: 'success', name: 'n', inputData: { a: 1 }, outputData: { b: 2 } }}
148
+ />
149
+ )
150
+ const pres = container.querySelectorAll('pre')
151
+ expect(pres).toHaveLength(2)
152
+ pres.forEach((pre) => expect(pre).toHaveClass(SCROLLER_CLASS))
153
+ })
154
+
155
+ it("marks NodeInspector's json block, which only exists once an exec tab is open", async () => {
156
+ const user = userEvent.setup()
157
+ render(
158
+ <NodeInspector
159
+ node={{ id: 'n1', name: 'n', type: 'llm.classify', position: { x: 0, y: 0 }, parameters: {} } as never}
160
+ execution={{ nodeId: 'n1', status: 'success', inputData: { a: 1 } } as never}
161
+ />
162
+ )
163
+ await user.click(screen.getByRole('tab', { name: /input/i }))
164
+ const pre = document.querySelector('pre')
165
+ expect(pre).not.toBeNull()
166
+ expect(pre).toHaveClass(SCROLLER_CLASS)
167
+ })
168
+
169
+ it('leaves the table primitive on its own class, so the table fix is not renamed out from under it', () => {
170
+ render(
171
+ <Table>
172
+ <TableBody>
173
+ <TableRow>
174
+ <TableCell>cell</TableCell>
175
+ </TableRow>
176
+ </TableBody>
177
+ </Table>
178
+ )
179
+ expect(screen.getByRole('table').parentElement).toHaveClass(TABLE_SCROLLER_CLASS)
180
+ })
181
+
182
+ /**
183
+ * One sheet, one id, one string. `ensureInjectedStyle` REWRITES an existing
184
+ * sheet whose text differs, so two surfaces shipping different CSS under this
185
+ * id would overwrite each other on every mount, last effect wins.
186
+ */
187
+ it('injects exactly one stylesheet however many different surfaces are on the page', () => {
188
+ render(
189
+ <>
190
+ <Table />
191
+ <DiffViewer diff={diff} />
192
+ <RecordDetail fields={[{ label: 'p', value: '{}', kind: 'json' }]} />
193
+ <MembersTable members={[member]} />
194
+ </>
195
+ )
196
+ const sheets = document.querySelectorAll(`#${SCROLL_AFFORDANCE_STYLE_ID}`)
197
+ expect(sheets).toHaveLength(1)
198
+ expect(sheets[0].textContent).toBe(SCROLL_AFFORDANCE_CSS)
199
+ })
200
+ })
@@ -0,0 +1,41 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * Append a stylesheet to <head> exactly once per document, keyed by `id`.
5
+ *
6
+ * WHY THIS EXISTS instead of a .css file or Tailwind classes (startsim-5pq7h):
7
+ *
8
+ * - @startsimpli/ui ships SOURCE (`main: ./src/index.ts`), and each consuming
9
+ * app decides for itself whether Tailwind scans it. Four of the eleven apps
10
+ * — crochet, recipe, workflows and raise — have no
11
+ * `@source ".../@startsimpli/ui/src/**"` line in their globals.css, so a
12
+ * Tailwind class written in this package emits no rule at all over there.
13
+ * - The subpath CSS exports (`./gantt/styles`, `./workflows/styles`) only take
14
+ * effect once the app adds an `@import`, which is a change in eleven repos.
15
+ * - Pseudo-element rules (`::-webkit-scrollbar`) cannot be expressed as an
16
+ * inline `style` prop.
17
+ *
18
+ * Injecting from the component is the only delivery that reaches every place
19
+ * the component actually renders.
20
+ */
21
+ export function ensureInjectedStyle(id: string, css: string): void {
22
+ if (typeof document === 'undefined') return
23
+
24
+ const existing = document.getElementById(id)
25
+ if (existing) {
26
+ if (existing.textContent !== css) existing.textContent = css
27
+ return
28
+ }
29
+
30
+ const el = document.createElement('style')
31
+ el.id = id
32
+ el.textContent = css
33
+ document.head.appendChild(el)
34
+ }
35
+
36
+ /** React wrapper for {@link ensureInjectedStyle}. Safe to call from every instance. */
37
+ export function useInjectedStyle(id: string, css: string): void {
38
+ useEffect(() => {
39
+ ensureInjectedStyle(id, css)
40
+ }, [id, css])
41
+ }
@@ -0,0 +1,135 @@
1
+ 'use client'
2
+
3
+ import { useInjectedStyle } from './injected-style'
4
+
5
+ export const SCROLL_AFFORDANCE_STYLE_ID = 'ss-scroll-affordance'
6
+
7
+ /**
8
+ * A scroller that should show a bar while it overflows.
9
+ *
10
+ * Put this on the element that ACTUALLY scrolls — the one carrying
11
+ * `overflow-auto` / `overflow-x-auto` — and call {@link useScrollAffordance}
12
+ * from the component that renders it, or the rules never reach the page.
13
+ */
14
+ export const SCROLLER_CLASS = 'ss-scroller'
15
+
16
+ /** The element that actually scrolls: the wrapper `Table` puts around `<table>`. */
17
+ export const TABLE_SCROLLER_CLASS = 'ss-table-scroller'
18
+ /** A non-scrolling box around the scroller, so the edge hints can be pinned to it. */
19
+ export const TABLE_SHELL_CLASS = 'ss-table-shell'
20
+ export const TABLE_EDGE_CLASS = 'ss-table-edge'
21
+ export const TABLE_EDGE_LEFT_CLASS = 'ss-table-edge-left'
22
+ export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
23
+
24
+ /**
25
+ * Content scrolls, but on macOS nothing says so (startsim-5pq7h, 5pq7h.1).
26
+ *
27
+ * A reviewer reported "there is no sliding bar so it makes it difficult to see
28
+ * all the columns". The container did scroll the whole time; macOS renders an
29
+ * OVERLAY scrollbar that occupies no layout height and is invisible at rest, so
30
+ * the affordance was missing, not the scrolling. The same is true of every
31
+ * other scroller in this package.
32
+ *
33
+ * MEASURED 2026-09-07, headed Google Chrome and headed WebKit on macOS, on a
34
+ * 400px box holding 900px of content (`offsetHeight - clientHeight`, borders
35
+ * subtracted):
36
+ *
37
+ * no scrollbar CSS 0px (overlay)
38
+ * scrollbar-width: thin 0px
39
+ * scrollbar-color: <thumb> <track> 0px
40
+ * ::-webkit-scrollbar { height: 10px } 10px <-- this one
41
+ * ::-webkit-scrollbar + scrollbar-width/-color 0px <-- see below
42
+ * ::-webkit-scrollbar, content NARROWER than the box 0px (no regression)
43
+ *
44
+ * THREE RESULTS THAT ARE EASY TO UNDO BY ACCIDENT:
45
+ *
46
+ * 1. DO NOT ADD `scrollbar-width` OR `scrollbar-color` HERE. Every snippet on
47
+ * the web pairs them with the -webkit- rules "for Firefox". Measured: when
48
+ * the standard properties are set at the same specificity, Chromium honours
49
+ * them INSTEAD of the custom scrollbar and reverts the element to a
50
+ * zero-height overlay bar — i.e. adding them silently deletes this fix.
51
+ * Firefox on macOS gains nothing from them anyway (measured 0px with
52
+ * `scrollbar-width: thin` applied and confirmed in the computed style), so
53
+ * there is no Firefox branch to lose. Firefox users are served by the edge
54
+ * hint below instead.
55
+ *
56
+ * 2. `width` IS LOAD-BEARING even though the defect that started this is
57
+ * horizontal. Declaring any `::-webkit-scrollbar` rule opts the element out
58
+ * of overlay scrollbars on BOTH axes, so a height-only rule leaves the
59
+ * vertical bar at the platform default. Measured 2026-09-07 on a box that
60
+ * overflows vertically, borders subtracted:
61
+ *
62
+ * height: 10px; width: 10px -> 10px vertical bar
63
+ * height: 10px (no width) -> 16px vertical bar <-- worse than before
64
+ * ::-webkit-scrollbar:horizontal { height: 10px } -> 0px on BOTH axes
65
+ *
66
+ * The last one is the tempting "only touch the horizontal axis" spelling and
67
+ * it does nothing at all in Chromium. There is no CSS route to a custom
68
+ * horizontal bar that leaves the vertical axis as an overlay — so an element
69
+ * given {@link SCROLLER_CLASS} that also overflows vertically gains a 10px
70
+ * vertical gutter, and that has to be an acceptable trade for that surface.
71
+ *
72
+ * 3. The bar costs layout height ONLY while the element overflows — that is
73
+ * `overflow: auto`, not something this stylesheet decides — so it never
74
+ * appears under content that fits. Keep scrollers on `auto`, never `scroll`,
75
+ * or every narrow table in the fleet grows a dead bar.
76
+ *
77
+ * 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.
80
+ *
81
+ * ONE stylesheet, ONE id, ONE string: `ensureInjectedStyle` rewrites the sheet
82
+ * when an existing id carries different text, so two call sites shipping
83
+ * different CSS under this id would overwrite each other on every mount. Add
84
+ * selectors to the lists below rather than injecting a second sheet.
85
+ */
86
+ export const SCROLL_AFFORDANCE_CSS = `
87
+ .${TABLE_SCROLLER_CLASS}::-webkit-scrollbar,
88
+ .${SCROLLER_CLASS}::-webkit-scrollbar {
89
+ height: 10px;
90
+ width: 10px;
91
+ }
92
+ .${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-track,
93
+ .${SCROLLER_CLASS}::-webkit-scrollbar-track {
94
+ background: transparent;
95
+ }
96
+ .${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb,
97
+ .${SCROLLER_CLASS}::-webkit-scrollbar-thumb {
98
+ border-radius: 9999px;
99
+ background-color: color-mix(in srgb, currentColor 22%, transparent);
100
+ }
101
+ .${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover,
102
+ .${SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover {
103
+ background-color: color-mix(in srgb, currentColor 45%, transparent);
104
+ }
105
+ .${TABLE_SHELL_CLASS} {
106
+ position: relative;
107
+ }
108
+ .${TABLE_EDGE_CLASS} {
109
+ position: absolute;
110
+ top: 0;
111
+ bottom: 0;
112
+ width: 24px;
113
+ pointer-events: none;
114
+ }
115
+ .${TABLE_EDGE_RIGHT_CLASS} {
116
+ right: 0;
117
+ background-image: linear-gradient(to left, hsl(var(--background)), hsl(var(--background) / 0));
118
+ }
119
+ .${TABLE_EDGE_LEFT_CLASS} {
120
+ left: 0;
121
+ background-image: linear-gradient(to right, hsl(var(--background)), hsl(var(--background) / 0));
122
+ }
123
+ `.trim()
124
+
125
+ /**
126
+ * Put {@link SCROLL_AFFORDANCE_CSS} on the page, once per document.
127
+ *
128
+ * A stylesheet rather than a Tailwind class because four of the eleven apps
129
+ * have no `@source` line for this package, so a class written here emits no
130
+ * rule over there — see `injected-style.ts`. Safe to call from every component
131
+ * that marks a scroller; the sheet is keyed by id.
132
+ */
133
+ export function useScrollAffordance(): void {
134
+ useInjectedStyle(SCROLL_AFFORDANCE_STYLE_ID, SCROLL_AFFORDANCE_CSS)
135
+ }