@startsimpli/ui 0.4.115 → 0.4.117
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 +5 -5
- package/src/components/blog/MarkdownRenderer.tsx +8 -1
- package/src/components/collection/DraftReviewWorkspace.tsx +4 -4
- package/src/components/collection/__tests__/drafts.test.ts +99 -1
- package/src/components/collection/__tests__/translated-draft-word-chip.test.tsx +129 -0
- package/src/components/collection/drafts.ts +44 -1
- package/src/components/collection/index.ts +1 -0
- package/src/components/diff/DiffViewer.tsx +3 -2
- package/src/components/record-detail/RecordDetail.tsx +5 -1
- package/src/components/team/MembersTable.tsx +10 -1
- package/src/components/ui/table-scroll.ts +22 -86
- package/src/components/ui/table.tsx +4 -9
- package/src/components/workflows/ExecNodeDetails.tsx +8 -1
- package/src/components/workflows/NodeInspector.tsx +5 -1
- package/src/lib/__tests__/scroll-affordance.test.tsx +200 -0
- package/src/lib/scroll-affordance.ts +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.117",
|
|
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/api": "^0.5.58",
|
|
51
50
|
"@startsimpli/auth": "^0.4.35",
|
|
52
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
51
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
52
|
+
"@startsimpli/api": "^0.5.58"
|
|
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/
|
|
127
|
-
"@startsimpli/
|
|
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=
|
|
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>
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
isChosen,
|
|
28
28
|
gradeWords,
|
|
29
29
|
wordTargets,
|
|
30
|
+
draftWordCount,
|
|
30
31
|
sourceOverlapCheck,
|
|
31
32
|
pickChanges,
|
|
32
33
|
isRejectedTopicGroup,
|
|
@@ -332,8 +333,7 @@ function CandidateCard({
|
|
|
332
333
|
const chosen = isChosen(record);
|
|
333
334
|
const status = field(record, 'status');
|
|
334
335
|
const preview = field(record, 'blog').slice(0, 150);
|
|
335
|
-
const
|
|
336
|
-
const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0;
|
|
336
|
+
const blogWords = draftWordCount(record, 'blog');
|
|
337
337
|
const target = wordTargets(contentType).blog;
|
|
338
338
|
return (
|
|
339
339
|
<div className={`flex flex-col rounded-md border p-3 ${chosen ? 'border-emerald-400 bg-emerald-50/40' : 'border-border bg-white'}`}>
|
|
@@ -407,8 +407,8 @@ function ContentReview({
|
|
|
407
407
|
})();
|
|
408
408
|
|
|
409
409
|
const targets = wordTargets(contentType);
|
|
410
|
-
const blogWords =
|
|
411
|
-
const liWords =
|
|
410
|
+
const blogWords = draftWordCount(record, 'blog');
|
|
411
|
+
const liWords = draftWordCount(record, 'linkedin');
|
|
412
412
|
const hype = get(checks, 'hypeFlags', 'hype_flags');
|
|
413
413
|
const approvedSources = get(checks, 'approvedSources', 'approved_sources');
|
|
414
414
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, isRejectedTopicGroup } from '../drafts';
|
|
1
|
+
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, isRejectedTopicGroup, draftWordCount } from '../drafts';
|
|
2
|
+
import { wordCount } from '../../validation';
|
|
2
3
|
import type { EntityRecord } from '../types';
|
|
3
4
|
|
|
4
5
|
function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
|
|
@@ -151,3 +152,100 @@ describe('isRejectedTopicGroup (startsim-ay9l — collapse drafts of rejected to
|
|
|
151
152
|
expect(isRejectedTopicGroup(g, new Set(['']))).toBe(false);
|
|
152
153
|
});
|
|
153
154
|
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A draft with no stored auto-checks still has a LENGTH (bd startsim-8hgmq.15).
|
|
158
|
+
*
|
|
159
|
+
* OBSERVED on the live tenant, 2026-09-07, at /drafts: a Chinese candidate card
|
|
160
|
+
* read "blog 0w … unjudged". The draft was 1027 characters, 855 of them CJK.
|
|
161
|
+
* The workspace was reading the count out of the stored `auto_checks` blob with
|
|
162
|
+
* `Number(get(checks, 'blogWords', 'blog_words')) || 0`, and the blob is null on
|
|
163
|
+
* every translated draft — `auto_checks` is written by the generator, while a
|
|
164
|
+
* translation is made by the tenant's own translate-draft, which deliberately
|
|
165
|
+
* does not inherit checks that measured text the checker never saw. So
|
|
166
|
+
* `Number(undefined) || 0` announced every translation as empty.
|
|
167
|
+
*
|
|
168
|
+
* The three live rows, all `auto_checks: null` and `judge_verdict: null`:
|
|
169
|
+
* f6a2d629 zh 1027 chars (855 CJK)
|
|
170
|
+
* 147d0eb8 zh 1109 chars (972 CJK)
|
|
171
|
+
* d0ca457a ar 2998 chars
|
|
172
|
+
*
|
|
173
|
+
* "unjudged" was honest — no judge ran. "0w" was not: nothing measured this
|
|
174
|
+
* draft, and an absent check must never render as a zero measurement.
|
|
175
|
+
*/
|
|
176
|
+
describe('draftWordCount (startsim-8hgmq.15 — a missing check is not a zero)', () => {
|
|
177
|
+
// The opening of the live zh draft f6a2d629, not invented text — the same
|
|
178
|
+
// fixture the counter's own tests use (validation/__tests__/word-count-scripts).
|
|
179
|
+
const ZH_BLOG =
|
|
180
|
+
'来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
|
|
181
|
+
'中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
|
|
182
|
+
const EN_BLOG =
|
|
183
|
+
'The Gulf-Asia technology corridor is entering a new phase, with Chinese firms ' +
|
|
184
|
+
'moving from investment into deeper operational embedding across the GCC.';
|
|
185
|
+
|
|
186
|
+
it('counts the draft itself when the translation carries no auto_checks', () => {
|
|
187
|
+
// The reported card. Whitespace counting scores this text 2 and the old
|
|
188
|
+
// stored-only read scored it 0; either way the reviewer was told the draft
|
|
189
|
+
// was empty.
|
|
190
|
+
const zh = rec('f6a2d629', '中国—海湾科技扩张引发入局者对执行的疑问', {
|
|
191
|
+
candidate_index: 1,
|
|
192
|
+
lang: 'zh',
|
|
193
|
+
blog: ZH_BLOG,
|
|
194
|
+
auto_checks: null,
|
|
195
|
+
judge_verdict: null,
|
|
196
|
+
});
|
|
197
|
+
expect(draftWordCount(zh, 'blog')).toBeGreaterThan(20);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('falls back for linkedin the same way', () => {
|
|
201
|
+
const zh = rec('f6a2d629', 'zh', { lang: 'zh', linkedin: ZH_BLOG, auto_checks: null });
|
|
202
|
+
expect(draftWordCount(zh, 'linkedin')).toBeGreaterThan(20);
|
|
203
|
+
expect(draftWordCount(zh, 'blog')).toBe(0); // no blog field at all IS zero
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('is a FALLBACK, not a replacement — a stored count wins', () => {
|
|
207
|
+
// Without this, "always recompute" passes the test above while quietly
|
|
208
|
+
// dropping the pipeline's own measurement, which is the number the judge and
|
|
209
|
+
// the writer agreed on.
|
|
210
|
+
const en = rec('d1', 'EN', { blog: EN_BLOG, auto_checks: { blog_words: 431, linkedin_words: 118 } });
|
|
211
|
+
expect(draftWordCount(en, 'blog')).toBe(431);
|
|
212
|
+
expect(draftWordCount(en, 'linkedin')).toBe(118);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('reads the camelCased blob the API client returns', () => {
|
|
216
|
+
// The tenant client camelCases response keys, data blob included, so the
|
|
217
|
+
// same record arrives as autoChecks.blogWords over the wire.
|
|
218
|
+
const en = rec('d1', 'EN', { blog: EN_BLOG, autoChecks: { blogWords: 431 } });
|
|
219
|
+
expect(draftWordCount(en, 'blog')).toBe(431);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps a stored zero, which is a measurement rather than an absence', () => {
|
|
223
|
+
const empty = rec('d2', 'EN', { blog: '', auto_checks: { blog_words: 0 } });
|
|
224
|
+
expect(draftWordCount(empty, 'blog')).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('recomputes when the blob exists but lacks this field', () => {
|
|
228
|
+
// A checker that ran on the blog only must not make the LinkedIn post read 0.
|
|
229
|
+
const partial = rec('d3', 'EN', { blog: EN_BLOG, linkedin: EN_BLOG, auto_checks: { blog_words: 431 } });
|
|
230
|
+
expect(draftWordCount(partial, 'blog')).toBe(431);
|
|
231
|
+
expect(draftWordCount(partial, 'linkedin')).toBe(23);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('recomputes rather than trusting an unparseable stored value', () => {
|
|
235
|
+
const junk = rec('d4', 'EN', { blog: EN_BLOG, auto_checks: { blog_words: 'n/a' } });
|
|
236
|
+
expect(draftWordCount(junk, 'blog')).toBe(23);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('agrees with the shared counter, so the chip and the rail cannot contradict', () => {
|
|
240
|
+
// startsim-wn2p.28: the count was wrong in four places because it was
|
|
241
|
+
// written in four places, and the screen ended up saying "524 words" and
|
|
242
|
+
// "16w" about one draft. This must stay a call to that one function.
|
|
243
|
+
const en = rec('d5', 'EN', { blog: EN_BLOG, auto_checks: null });
|
|
244
|
+
expect(draftWordCount(en, 'blog')).toBe(wordCount(EN_BLOG));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('is 0 for a draft that really is empty', () => {
|
|
248
|
+
expect(draftWordCount(rec('d6', 'EN', { blog: '', auto_checks: null }), 'blog')).toBe(0);
|
|
249
|
+
expect(draftWordCount(rec('d7', 'EN', {}), 'blog')).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compare workspace must not tell a reviewer that a full draft is empty
|
|
3
|
+
* (bd startsim-8hgmq.15).
|
|
4
|
+
*
|
|
5
|
+
* OBSERVED on the live tenant, 2026-09-07, at /drafts. A Chinese candidate card
|
|
6
|
+
* in the "How Chinese Firms Are Anchoring Into the Gulf Tech Corridor" set read
|
|
7
|
+
*
|
|
8
|
+
* blog 0w unjudged
|
|
9
|
+
*
|
|
10
|
+
* and the draft behind it was 1027 characters, 855 of them CJK. Half of that
|
|
11
|
+
* card was honest: no judge ran on a translation, so "unjudged" is true. The
|
|
12
|
+
* other half was a MISSING measurement rendered as a real zero — the card read
|
|
13
|
+
* the count out of the stored `auto_checks` blob, which a translation never
|
|
14
|
+
* gets, and `Number(undefined) || 0` is 0.
|
|
15
|
+
*
|
|
16
|
+
* `drafts.test.ts` pins the counting rule. This pins the SCREEN, at the level
|
|
17
|
+
* the defect was reported at: mount the workspace over a stubbed tenant client
|
|
18
|
+
* holding the three real drafts and read the chips.
|
|
19
|
+
*/
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
22
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
23
|
+
|
|
24
|
+
// react-markdown is ESM and this suite is not; the workspace pulls it in only
|
|
25
|
+
// to render an OPEN draft's body, which no assertion here reads.
|
|
26
|
+
jest.mock('../../blog/MarkdownRenderer', () => ({
|
|
27
|
+
MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { DraftReviewWorkspace } from '../DraftReviewWorkspace';
|
|
31
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
32
|
+
|
|
33
|
+
const DRAFT_TYPE: EntityTypeDef = { id: 1, key: 'draft', label: 'Draft', attributes: [] };
|
|
34
|
+
|
|
35
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
36
|
+
return { count: results.length, next: null, previous: null, results };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function draft(id: number, name: string, data: Record<string, unknown>): EntityRecord {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
entityType: 'draft',
|
|
43
|
+
externalId: null,
|
|
44
|
+
name,
|
|
45
|
+
data: { content_type: 'weekly_brief', topic_ref: 'topic-1', topic_title: 'Gulf tech corridor', ...data },
|
|
46
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The opening of the live zh draft f6a2d629 — real text, not a repeat()ed glyph.
|
|
51
|
+
const ZH_BLOG =
|
|
52
|
+
'来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
|
|
53
|
+
'中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
|
|
54
|
+
const EN_BLOG =
|
|
55
|
+
'The Gulf-Asia technology corridor is entering a new phase, with Chinese firms ' +
|
|
56
|
+
'moving from investment into deeper operational embedding across the GCC.';
|
|
57
|
+
|
|
58
|
+
/** The English original, which the writer checked, and its zh translation, which nothing checked. */
|
|
59
|
+
const DRAFTS: EntityRecord[] = [
|
|
60
|
+
draft(1, 'Candidate 1 — Gulf tech corridor', {
|
|
61
|
+
candidate_index: 1,
|
|
62
|
+
story_title: 'China-Gulf Tech Expansion Raises Execution Questions for Entrants',
|
|
63
|
+
lang: 'en',
|
|
64
|
+
blog: EN_BLOG,
|
|
65
|
+
auto_checks: { blog_words: 431, linkedin_words: 180 },
|
|
66
|
+
judge_verdict: { verdict: 'accept' },
|
|
67
|
+
}),
|
|
68
|
+
draft(2, 'Candidate 2 — Gulf tech corridor', {
|
|
69
|
+
candidate_index: 2,
|
|
70
|
+
story_title: '中国—海湾科技扩张引发入局者对执行的疑问',
|
|
71
|
+
lang: 'zh',
|
|
72
|
+
blog: ZH_BLOG,
|
|
73
|
+
// Exactly how the live translations are stored: the generator's checks and
|
|
74
|
+
// verdict are NOT inherited, because they measured text neither one saw.
|
|
75
|
+
auto_checks: null,
|
|
76
|
+
judge_verdict: null,
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function stubClient(): CollectionClient {
|
|
81
|
+
return {
|
|
82
|
+
listTypes: jest.fn(async () => page([DRAFT_TYPE])),
|
|
83
|
+
listAllEntities: jest.fn(async (type: string) => (type === 'draft' ? DRAFTS : [])),
|
|
84
|
+
updateEntity: jest.fn(async () => DRAFTS[0]),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderWorkspace() {
|
|
89
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
90
|
+
return render(
|
|
91
|
+
<QueryClientProvider client={qc}>
|
|
92
|
+
<DraftReviewWorkspace client={stubClient()} />
|
|
93
|
+
</QueryClientProvider>,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The card for one candidate, found by the title the reviewer reads. */
|
|
98
|
+
async function card(title: string): Promise<HTMLElement> {
|
|
99
|
+
const heading = await screen.findByText(title);
|
|
100
|
+
const el = heading.closest('div.flex.flex-col');
|
|
101
|
+
if (!el) throw new Error(`no candidate card around "${title}"`);
|
|
102
|
+
return el as HTMLElement;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('a translated candidate card (startsim-8hgmq.15)', () => {
|
|
106
|
+
it('shows the draft’s real length instead of “blog 0w”', async () => {
|
|
107
|
+
renderWorkspace();
|
|
108
|
+
const zh = await card('中国—海湾科技扩张引发入局者对执行的疑问');
|
|
109
|
+
|
|
110
|
+
expect(within(zh).queryByText(/^blog 0w/)).toBeNull();
|
|
111
|
+
const chip = within(zh).getByText(/^blog \d+w/);
|
|
112
|
+
const words = Number(chip.textContent!.match(/blog (\d+)w/)![1]);
|
|
113
|
+
expect(words).toBeGreaterThan(20);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still says “unjudged”, which was the honest half of that card', async () => {
|
|
117
|
+
// No judge ran on the translation. The fix is to stop faking the count, not
|
|
118
|
+
// to fake a verdict alongside it.
|
|
119
|
+
renderWorkspace();
|
|
120
|
+
const zh = await card('中国—海湾科技扩张引发入局者对执行的疑问');
|
|
121
|
+
expect(within(zh).getByText('unjudged')).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('leaves a checked candidate showing the count the pipeline stored', async () => {
|
|
125
|
+
renderWorkspace();
|
|
126
|
+
const en = await card('China-Gulf Tech Expansion Raises Execution Questions for Entrants');
|
|
127
|
+
await waitFor(() => expect(within(en).getByText(/^blog 431w/)).toBeTruthy());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { EntityRecord } from './types';
|
|
11
11
|
import { readData } from './data';
|
|
12
|
-
import { runContentChecks, type ContentCheck } from '../validation';
|
|
12
|
+
import { runContentChecks, wordCount, type ContentCheck } from '../validation';
|
|
13
13
|
|
|
14
14
|
export interface DraftSource {
|
|
15
15
|
url: string;
|
|
@@ -160,6 +160,49 @@ export function wordTargets(contentType: string): { blog: [number, number]; link
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The word count to SHOW for one field of one draft (bd startsim-8hgmq.15).
|
|
165
|
+
*
|
|
166
|
+
* WHY THIS EXISTS. The workspace used to read the count straight out of the
|
|
167
|
+
* stored blob — `Number(get(checks, 'blogWords', 'blog_words')) || 0` — and on
|
|
168
|
+
* 2026-09-07 a live Chinese candidate at /drafts advertised itself as
|
|
169
|
+
* "blog 0w … unjudged". The draft was 1027 characters, 855 of them CJK.
|
|
170
|
+
*
|
|
171
|
+
* `auto_checks` is written by the pipeline that GENERATES a candidate. A
|
|
172
|
+
* TRANSLATION is created by the tenant's own translate-draft, which copies the
|
|
173
|
+
* prose and deliberately does not inherit the generator's checks or verdict —
|
|
174
|
+
* they measured text neither one ever saw. So `auto_checks` is null on every
|
|
175
|
+
* translation, `Number(undefined) || 0` is 0, and every translated draft in the
|
|
176
|
+
* compare workspace announced itself as empty. Structural, not a bad row: the
|
|
177
|
+
* three live translations (zh, zh, ar) all read 0w, and so would every future
|
|
178
|
+
* one. "unjudged" beside it was honest — no judge ran — but an ABSENT check must
|
|
179
|
+
* never render as a zero MEASUREMENT.
|
|
180
|
+
*
|
|
181
|
+
* ABSENCE FALLS BACK, FALSINESS DOES NOT. A stored `0` is a measurement the
|
|
182
|
+
* pipeline actually took and it survives; only a missing or unparseable value is
|
|
183
|
+
* recomputed. That is the whole bug: the old code tested truthiness where it
|
|
184
|
+
* meant presence.
|
|
185
|
+
*
|
|
186
|
+
* IT RECOUNTS RATHER THAN BLANKING because the reviewer's job here is comparing
|
|
187
|
+
* candidates, and "—" tells them nothing about whether this one runs long.
|
|
188
|
+
*
|
|
189
|
+
* IT CALLS THE SHARED `wordCount` WITH NO LOCALE, exactly as `runContentChecks`
|
|
190
|
+
* does, so the chip and the validation rail on the same screen cannot disagree
|
|
191
|
+
* about one draft. This is a FALLBACK TO that function, never a second
|
|
192
|
+
* implementation of it — bd startsim-wn2p.28 was one line of arithmetic that
|
|
193
|
+
* took four PRs because the count had been written in four places, and the
|
|
194
|
+
* half-fixed screen read "524 words" in the rail and "16w" on the tab above it.
|
|
195
|
+
*/
|
|
196
|
+
export function draftWordCount(r: EntityRecord, field: 'blog' | 'linkedin'): number {
|
|
197
|
+
const checks = readData(r.data, 'auto_checks');
|
|
198
|
+
const bag = checks && typeof checks === 'object' && !Array.isArray(checks) ? (checks as Record<string, unknown>) : null;
|
|
199
|
+
// Both spellings, because the tenant API client camelCases the data blob.
|
|
200
|
+
const stored = bag ? bag[`${field}Words`] ?? bag[`${field}_words`] : undefined;
|
|
201
|
+
const n = Number(stored);
|
|
202
|
+
if (stored != null && stored !== '' && Number.isFinite(n)) return n;
|
|
203
|
+
return wordCount(str(readData(r.data, field)));
|
|
204
|
+
}
|
|
205
|
+
|
|
163
206
|
export type CheckStatus = 'ok' | 'low' | 'high' | 'none';
|
|
164
207
|
|
|
165
208
|
/** Grade a word count against a [lo, hi] target. hi===0 means "no target". */
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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}>
|
|
@@ -2,90 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, type RefObject } from 'react'
|
|
4
4
|
|
|
5
|
-
export const TABLE_SCROLL_STYLE_ID = 'ss-table-scroll-affordance'
|
|
6
|
-
|
|
7
|
-
/** The element that actually scrolls: the wrapper `Table` puts around `<table>`. */
|
|
8
|
-
export const TABLE_SCROLLER_CLASS = 'ss-table-scroller'
|
|
9
|
-
/** A non-scrolling box around the scroller, so the edge hints can be pinned to it. */
|
|
10
|
-
export const TABLE_SHELL_CLASS = 'ss-table-shell'
|
|
11
|
-
export const TABLE_EDGE_CLASS = 'ss-table-edge'
|
|
12
|
-
export const TABLE_EDGE_LEFT_CLASS = 'ss-table-edge-left'
|
|
13
|
-
export const TABLE_EDGE_RIGHT_CLASS = 'ss-table-edge-right'
|
|
14
|
-
|
|
15
5
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* A reviewer reported "there is no sliding bar so it makes it difficult to see
|
|
19
|
-
* all the columns". The container did scroll the whole time; macOS renders an
|
|
20
|
-
* OVERLAY scrollbar that occupies no layout height and is invisible at rest, so
|
|
21
|
-
* the affordance was missing, not the scrolling.
|
|
22
|
-
*
|
|
23
|
-
* MEASURED 2026-09-07, headed Google Chrome and headed WebKit on macOS, on a
|
|
24
|
-
* 400px box holding 900px of content (`offsetHeight - clientHeight`, borders
|
|
25
|
-
* subtracted):
|
|
26
|
-
*
|
|
27
|
-
* no scrollbar CSS 0px (overlay)
|
|
28
|
-
* scrollbar-width: thin 0px
|
|
29
|
-
* scrollbar-color: <thumb> <track> 0px
|
|
30
|
-
* ::-webkit-scrollbar { height: 10px } 10px <-- this one
|
|
31
|
-
* ::-webkit-scrollbar + scrollbar-width/-color 0px <-- see below
|
|
32
|
-
* ::-webkit-scrollbar, content NARROWER than the box 0px (no regression)
|
|
33
|
-
*
|
|
34
|
-
* TWO RESULTS THAT ARE EASY TO UNDO BY ACCIDENT:
|
|
35
|
-
*
|
|
36
|
-
* 1. DO NOT ADD `scrollbar-width` OR `scrollbar-color` HERE. Every snippet on
|
|
37
|
-
* the web pairs them with the -webkit- rules "for Firefox". Measured: when
|
|
38
|
-
* the standard properties are set at the same specificity, Chromium honours
|
|
39
|
-
* them INSTEAD of the custom scrollbar and reverts the element to a
|
|
40
|
-
* zero-height overlay bar — i.e. adding them silently deletes this fix.
|
|
41
|
-
* Firefox on macOS gains nothing from them anyway (measured 0px with
|
|
42
|
-
* `scrollbar-width: thin` applied and confirmed in the computed style), so
|
|
43
|
-
* there is no Firefox branch to lose. Firefox users are served by the edge
|
|
44
|
-
* hint below instead.
|
|
45
|
-
*
|
|
46
|
-
* 2. The bar costs layout height ONLY while the element overflows — that is
|
|
47
|
-
* `overflow: auto`, not something this stylesheet decides — so it never
|
|
48
|
-
* appears under a table that fits. Keep the scroller on `auto`, never
|
|
49
|
-
* `scroll`, or every narrow table in the fleet grows a dead bar.
|
|
6
|
+
* The table's share of the scroll affordance (startsim-5pq7h).
|
|
50
7
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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.
|
|
54
14
|
*/
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
background-color: color-mix(in srgb, currentColor 22%, transparent);
|
|
66
|
-
}
|
|
67
|
-
.${TABLE_SCROLLER_CLASS}::-webkit-scrollbar-thumb:hover {
|
|
68
|
-
background-color: color-mix(in srgb, currentColor 45%, transparent);
|
|
69
|
-
}
|
|
70
|
-
.${TABLE_SHELL_CLASS} {
|
|
71
|
-
position: relative;
|
|
72
|
-
}
|
|
73
|
-
.${TABLE_EDGE_CLASS} {
|
|
74
|
-
position: absolute;
|
|
75
|
-
top: 0;
|
|
76
|
-
bottom: 0;
|
|
77
|
-
width: 24px;
|
|
78
|
-
pointer-events: none;
|
|
79
|
-
}
|
|
80
|
-
.${TABLE_EDGE_RIGHT_CLASS} {
|
|
81
|
-
right: 0;
|
|
82
|
-
background-image: linear-gradient(to left, hsl(var(--background)), hsl(var(--background) / 0));
|
|
83
|
-
}
|
|
84
|
-
.${TABLE_EDGE_LEFT_CLASS} {
|
|
85
|
-
left: 0;
|
|
86
|
-
background-image: linear-gradient(to right, hsl(var(--background)), hsl(var(--background) / 0));
|
|
87
|
-
}
|
|
88
|
-
`.trim()
|
|
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'
|
|
89
25
|
|
|
90
26
|
export interface ScrollEdges {
|
|
91
27
|
/** There is content scrolled off to the left. */
|
|
@@ -97,11 +33,11 @@ export interface ScrollEdges {
|
|
|
97
33
|
/**
|
|
98
34
|
* Which horizontal edges of a table's scroller still hide content.
|
|
99
35
|
*
|
|
100
|
-
* The persistent scrollbar
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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.
|
|
105
41
|
*
|
|
106
42
|
* Pass a ref to the `<table>`, not to the scroller: `Table` forwards its ref to
|
|
107
43
|
* the table element, and the scroller is its parent.
|
|
@@ -3,12 +3,7 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
5
|
import { cn } from "../../lib/utils"
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
TABLE_SCROLLER_CLASS,
|
|
9
|
-
TABLE_SCROLL_CSS,
|
|
10
|
-
TABLE_SCROLL_STYLE_ID,
|
|
11
|
-
} from "./table-scroll"
|
|
6
|
+
import { TABLE_SCROLLER_CLASS, useScrollAffordance } from "./table-scroll"
|
|
12
7
|
|
|
13
8
|
export interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
|
14
9
|
/**
|
|
@@ -23,9 +18,9 @@ export interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
|
|
23
18
|
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
|
24
19
|
({ className, containerClassName, ...props }, ref) => {
|
|
25
20
|
// Gives the scroller a scrollbar that is actually visible while it
|
|
26
|
-
// overflows. See
|
|
27
|
-
// `scrollbar-width` must NOT be added alongside it.
|
|
28
|
-
|
|
21
|
+
// overflows. See lib/scroll-affordance.ts for the measurements — in
|
|
22
|
+
// particular why `scrollbar-width` must NOT be added alongside it.
|
|
23
|
+
useScrollAffordance()
|
|
29
24
|
|
|
30
25
|
return (
|
|
31
26
|
<div
|
|
@@ -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=
|
|
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=
|
|
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,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
|
+
}
|