@startsimpli/ui 0.4.101 → 0.4.103
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.
|
|
3
|
+
"version": "0.4.103",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -120,8 +120,8 @@
|
|
|
120
120
|
"tailwindcss": "^3.4.19",
|
|
121
121
|
"tsup": "^8.5.1",
|
|
122
122
|
"typescript": "^6.0.3",
|
|
123
|
-
"@startsimpli/
|
|
124
|
-
"@startsimpli/
|
|
123
|
+
"@startsimpli/realtime": "0.0.2",
|
|
124
|
+
"@startsimpli/i18n": "0.1.3"
|
|
125
125
|
},
|
|
126
126
|
"keywords": [
|
|
127
127
|
"ui",
|
|
@@ -20,6 +20,18 @@ export interface SourceFreshnessProps {
|
|
|
20
20
|
staleAfterHours?: number
|
|
21
21
|
/** Compare against this instant (injectable for tests); defaults to Date.now(). */
|
|
22
22
|
now?: number
|
|
23
|
+
/**
|
|
24
|
+
* Cap how many rows are LISTED. The roll-up (tone, "N stale") still counts every
|
|
25
|
+
* source, so capping hides rows, never facts.
|
|
26
|
+
*
|
|
27
|
+
* This card is the one health block that used to have no cap, and it got handed
|
|
28
|
+
* the one unbounded list: a tenant with 55 declared sources rendered 55 rows,
|
|
29
|
+
* and in a `DashboardGrid` (CSS grid, `align-items: stretch` by default) that one
|
|
30
|
+
* very tall card silently set the row height for every card beside it — the
|
|
31
|
+
* short neighbour then padded the difference with blank space. Capping the list
|
|
32
|
+
* is the cheap half of that fix; a grid that does not stretch is the other half.
|
|
33
|
+
*/
|
|
34
|
+
limit?: number
|
|
23
35
|
emptyMessage?: string
|
|
24
36
|
className?: string
|
|
25
37
|
}
|
|
@@ -46,6 +58,7 @@ export function SourceFreshness({
|
|
|
46
58
|
sources,
|
|
47
59
|
staleAfterHours = 48,
|
|
48
60
|
now,
|
|
61
|
+
limit,
|
|
49
62
|
emptyMessage = 'No sources configured.',
|
|
50
63
|
className,
|
|
51
64
|
}: SourceFreshnessProps) {
|
|
@@ -54,6 +67,10 @@ export function SourceFreshness({
|
|
|
54
67
|
const staleCount = rows.filter((r) => r.resolved === 'warn' || r.resolved === 'critical').length
|
|
55
68
|
const status = worstStatus(rows.map((r) => r.resolved))
|
|
56
69
|
const statusLabel = sources.length === 0 ? undefined : staleCount > 0 ? `${staleCount} stale` : 'All fresh'
|
|
70
|
+
// Cap the LIST only — staleCount/status above are computed over every source, so
|
|
71
|
+
// a stale feed below the cut still pulls the card's tone and its badge count.
|
|
72
|
+
const listed = limit ? rows.slice(0, limit) : rows
|
|
73
|
+
const hidden = rows.length - listed.length
|
|
57
74
|
|
|
58
75
|
return (
|
|
59
76
|
<HealthCard
|
|
@@ -66,7 +83,7 @@ export function SourceFreshness({
|
|
|
66
83
|
className={className}
|
|
67
84
|
>
|
|
68
85
|
<ul className="space-y-2" aria-label={`${title} sources`}>
|
|
69
|
-
{
|
|
86
|
+
{listed.map((row) => (
|
|
70
87
|
<li key={row.name} className="flex items-center justify-between gap-3 text-sm">
|
|
71
88
|
<span className="flex items-center gap-2 min-w-0">
|
|
72
89
|
<HealthDot status={row.resolved} />
|
|
@@ -83,6 +100,11 @@ export function SourceFreshness({
|
|
|
83
100
|
</li>
|
|
84
101
|
))}
|
|
85
102
|
</ul>
|
|
103
|
+
{hidden > 0 && (
|
|
104
|
+
<p className="text-xs text-gray-500">
|
|
105
|
+
+{hidden} more source{hidden === 1 ? '' : 's'} not shown
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
86
108
|
</HealthCard>
|
|
87
109
|
)
|
|
88
110
|
}
|
|
@@ -96,6 +96,68 @@ describe('SourceFreshness', () => {
|
|
|
96
96
|
expect(screen.getByText('2 stale')).toBeInTheDocument()
|
|
97
97
|
expect(screen.getByText('never')).toBeInTheDocument()
|
|
98
98
|
})
|
|
99
|
+
it('caps the listed rows at `limit` and says how many are hidden', () => {
|
|
100
|
+
render(
|
|
101
|
+
<SourceFreshness
|
|
102
|
+
now={NOW}
|
|
103
|
+
limit={2}
|
|
104
|
+
sources={[
|
|
105
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
106
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
107
|
+
{ name: 'RSS C', lastUpdated: hoursAgo(3) },
|
|
108
|
+
{ name: 'RSS D', lastUpdated: hoursAgo(4) },
|
|
109
|
+
]}
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
expect(screen.getByText('RSS A')).toBeInTheDocument()
|
|
113
|
+
expect(screen.getByText('RSS B')).toBeInTheDocument()
|
|
114
|
+
expect(screen.queryByText('RSS C')).not.toBeInTheDocument()
|
|
115
|
+
expect(screen.getByText('+2 more sources not shown')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
it('rolls up tone and the stale count over EVERY source, not just the listed ones', () => {
|
|
118
|
+
render(
|
|
119
|
+
<SourceFreshness
|
|
120
|
+
now={NOW}
|
|
121
|
+
staleAfterHours={48}
|
|
122
|
+
limit={1}
|
|
123
|
+
sources={[
|
|
124
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
125
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(200) },
|
|
126
|
+
{ name: 'RSS C', lastUpdated: null },
|
|
127
|
+
]}
|
|
128
|
+
/>,
|
|
129
|
+
)
|
|
130
|
+
// B and C are below the cut; hiding a row must never hide the fact.
|
|
131
|
+
expect(screen.getByText('2 stale')).toBeInTheDocument()
|
|
132
|
+
expect(screen.queryByText('RSS B')).not.toBeInTheDocument()
|
|
133
|
+
expect(screen.getByText('+2 more sources not shown')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
it('lists everything, with no hidden-count line, when no limit is given', () => {
|
|
136
|
+
render(
|
|
137
|
+
<SourceFreshness
|
|
138
|
+
now={NOW}
|
|
139
|
+
sources={[
|
|
140
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
141
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
142
|
+
]}
|
|
143
|
+
/>,
|
|
144
|
+
)
|
|
145
|
+
expect(screen.getByText('RSS B')).toBeInTheDocument()
|
|
146
|
+
expect(screen.queryByText(/more sources? not shown/)).not.toBeInTheDocument()
|
|
147
|
+
})
|
|
148
|
+
it('says "source" (singular) when exactly one row is hidden', () => {
|
|
149
|
+
render(
|
|
150
|
+
<SourceFreshness
|
|
151
|
+
now={NOW}
|
|
152
|
+
limit={1}
|
|
153
|
+
sources={[
|
|
154
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
155
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
156
|
+
]}
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
expect(screen.getByText('+1 more source not shown')).toBeInTheDocument()
|
|
160
|
+
})
|
|
99
161
|
})
|
|
100
162
|
|
|
101
163
|
describe('DeliveryHealth', () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting words in a language that does not use spaces (bd startsim-wn2p.28).
|
|
3
|
+
*
|
|
4
|
+
* OBSERVED in the browser on the live OGMC Chinese draft, 2026-08-24. The review
|
|
5
|
+
* workspace greeted its reviewer with "4 ISSUES TO LOOK AT" and CHECKS 4/8, and
|
|
6
|
+
* three of the four were arithmetic, not editing:
|
|
7
|
+
*
|
|
8
|
+
* Headline length 1 words (the headline is 19 Chinese characters)
|
|
9
|
+
* Blog word count 16 words (the blog is 855 Chinese characters)
|
|
10
|
+
* LinkedIn word count 7 words (the post is 364 Chinese characters)
|
|
11
|
+
*
|
|
12
|
+
* `split(/\s+/)` counts whitespace runs. Chinese does not put spaces between
|
|
13
|
+
* words, so an entire article counted as however many Latin fragments happened
|
|
14
|
+
* to be embedded in it. The rail whose job is to say what to fix was saying
|
|
15
|
+
* nothing at all, on exactly the language the CN review stage exists for.
|
|
16
|
+
*
|
|
17
|
+
* MEASURED against the three real translation pairs in the tenant — whitespace
|
|
18
|
+
* count vs `Intl.Segmenter`, with the English source's count for scale:
|
|
19
|
+
*
|
|
20
|
+
* zh #1 headline EN 9 ws 1 segmented 13
|
|
21
|
+
* blog EN 507 ws 26 segmented 609
|
|
22
|
+
* linkedin EN 190 ws 11 segmented 234
|
|
23
|
+
* zh #2 headline EN 8 ws 1 segmented 11
|
|
24
|
+
* blog EN 463 ws 16 segmented 524
|
|
25
|
+
* ar blog EN 497 ws 479 segmented 476 <- Arabic already worked
|
|
26
|
+
*
|
|
27
|
+
* So segmentation turns nonsense into a number an editor can act on, and leaves
|
|
28
|
+
* space-delimited scripts where they were.
|
|
29
|
+
*/
|
|
30
|
+
import { wordCount } from '../checks';
|
|
31
|
+
|
|
32
|
+
// The opening of the live zh blog (draft f6a2d629), not invented text.
|
|
33
|
+
const ZH_BLOG =
|
|
34
|
+
'来自《Arabian Business》的报道显示,海湾—亚洲技术走廊正进入新阶段,' +
|
|
35
|
+
'中国企业正从单纯投资转向在GCC范围内更深度嵌入的运营模式。';
|
|
36
|
+
const ZH_HEADLINE = '中国—海湾科技扩张引发入局者对执行的疑问';
|
|
37
|
+
const EN_HEADLINE = 'China-Gulf Tech Expansion Raises Execution Questions for Entrants';
|
|
38
|
+
const AR_HEADLINE = 'يتطلب تأسيس الشركات بشكل أسرع في دبي تنفيذًا دقيقًا من قبل الأجانب';
|
|
39
|
+
|
|
40
|
+
describe('wordCount on space-delimited scripts — unchanged', () => {
|
|
41
|
+
it('counts an English headline', () => {
|
|
42
|
+
expect(wordCount(EN_HEADLINE)).toBe(9);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('counts an Arabic headline, which was never broken', () => {
|
|
46
|
+
// Arabic uses spaces, so this was already right and must stay right:
|
|
47
|
+
// whitespace gave 12 on this live headline and segmentation gives 12.
|
|
48
|
+
expect(wordCount(AR_HEADLINE)).toBe(12);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('splits a hyphenated compound, which whitespace counting did not', () => {
|
|
52
|
+
// The one real behaviour change for English, and it is a correction:
|
|
53
|
+
// "China-Gulf" is two words. MEASURED across all 75 live English drafts,
|
|
54
|
+
// this moves 9 of 225 band checks — and the single headline it flips is
|
|
55
|
+
// "Qatar's Post-World-Cup Economy...", which whitespace scored 7 (below the
|
|
56
|
+
// 8-word floor) and segmentation scores 9 (in band). Largest blog shift is
|
|
57
|
+
// 19 words on a ~450-word brief.
|
|
58
|
+
expect(wordCount('China-Gulf Tech')).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is 0 for empty, null and undefined', () => {
|
|
62
|
+
expect(wordCount('')).toBe(0);
|
|
63
|
+
expect(wordCount(null)).toBe(0);
|
|
64
|
+
expect(wordCount(undefined)).toBe(0);
|
|
65
|
+
expect(wordCount(' ')).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not count punctuation as a word', () => {
|
|
69
|
+
expect(wordCount('Hello, world!')).toBe(2);
|
|
70
|
+
expect(wordCount('— — —')).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('wordCount on Chinese — the bug', () => {
|
|
75
|
+
it('no longer reports a 19-character headline as one word', () => {
|
|
76
|
+
expect(wordCount(ZH_HEADLINE)).toBeGreaterThan(5);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('lands near the English source it was translated from', () => {
|
|
80
|
+
// The live pair: a 9-word English headline became this. Anything in the
|
|
81
|
+
// same neighbourhood is a number an editor can reason about; `1` was not.
|
|
82
|
+
const n = wordCount(ZH_HEADLINE);
|
|
83
|
+
expect(n).toBeGreaterThanOrEqual(8);
|
|
84
|
+
expect(n).toBeLessThanOrEqual(20);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('counts a Chinese paragraph as prose, not as its Latin fragments', () => {
|
|
88
|
+
// The embedded 'Arabian Business' and 'GCC' were nearly the whole old count.
|
|
89
|
+
expect(wordCount(ZH_BLOG)).toBeGreaterThan(20);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('counts mixed Chinese and Latin without double-counting the Latin', () => {
|
|
93
|
+
const mixed = 'GCC市场';
|
|
94
|
+
expect(wordCount(mixed)).toBeGreaterThanOrEqual(2);
|
|
95
|
+
expect(wordCount(mixed)).toBeLessThanOrEqual(3);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('wordCount degrades rather than throws', () => {
|
|
100
|
+
it('still returns a count where Intl.Segmenter is unavailable', () => {
|
|
101
|
+
const saved = (globalThis as { Intl: typeof Intl }).Intl;
|
|
102
|
+
try {
|
|
103
|
+
// Older runtimes, and any environment that trims Intl to save bytes.
|
|
104
|
+
(globalThis as unknown as { Intl: unknown }).Intl = {} as unknown;
|
|
105
|
+
// The OLD whitespace answer — 8, because it cannot split "China-Gulf".
|
|
106
|
+
// Degraded but usable, which is the point: a wrong count beats a thrown
|
|
107
|
+
// error on a rail whose job is to report problems.
|
|
108
|
+
expect(wordCount(EN_HEADLINE)).toBe(8);
|
|
109
|
+
expect(wordCount(ZH_HEADLINE)).toBeGreaterThan(0);
|
|
110
|
+
} finally {
|
|
111
|
+
(globalThis as { Intl: typeof Intl }).Intl = saved;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -139,10 +139,78 @@ const DEFAULTS = {
|
|
|
139
139
|
metaChars: [150, 160] as Band,
|
|
140
140
|
};
|
|
141
141
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
142
|
+
/**
|
|
143
|
+
* The sliver of `Intl.Segmenter` this file uses, declared locally rather than by
|
|
144
|
+
* raising the package's `lib` to es2022.
|
|
145
|
+
*
|
|
146
|
+
* This package ships raw TypeScript and is compiled by each consuming app, so a
|
|
147
|
+
* `lib` bump here would not reach them anyway — every tenant app's own tsconfig
|
|
148
|
+
* would have to agree. A local structural type works whatever they target, and
|
|
149
|
+
* the runtime guard below is what actually decides whether it is used.
|
|
150
|
+
*/
|
|
151
|
+
interface WordSegment {
|
|
152
|
+
isWordLike?: boolean;
|
|
153
|
+
}
|
|
154
|
+
interface SegmenterCtor {
|
|
155
|
+
new (locale?: string, options?: { granularity: 'word' }): {
|
|
156
|
+
segment(input: string): Iterable<WordSegment>;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Words that carry meaning, in ANY script. Empty → 0.
|
|
162
|
+
*
|
|
163
|
+
* WHY NOT `split(/\s+/)` (bd startsim-wn2p.28). Because Chinese does not put
|
|
164
|
+
* spaces between words, and OGMC's Chinese review stage was greeted by this:
|
|
165
|
+
*
|
|
166
|
+
* Headline length 1 words (the headline is 19 Chinese characters)
|
|
167
|
+
* Blog word count 16 words (the blog is 855 Chinese characters)
|
|
168
|
+
* LinkedIn word count 7 words (the post is 364 Chinese characters)
|
|
169
|
+
*
|
|
170
|
+
* Whitespace counting was returning the number of Latin fragments that happened
|
|
171
|
+
* to be embedded in the prose. The rail whose job is to say what to fix was
|
|
172
|
+
* saying nothing at all, on exactly the language it had just been extended to.
|
|
173
|
+
*
|
|
174
|
+
* `Intl.Segmenter` applies ICU's per-locale word rules, and MEASURED against the
|
|
175
|
+
* three real translation pairs in the tenant it does the right thing in both
|
|
176
|
+
* directions — it rescues CJK and leaves space-delimited scripts alone:
|
|
177
|
+
*
|
|
178
|
+
* zh headline EN 9 | whitespace 1 | segmented 13
|
|
179
|
+
* blog EN 507 | whitespace 26 | segmented 609
|
|
180
|
+
* linkedin EN 190 | whitespace 11 | segmented 234
|
|
181
|
+
* ar blog EN 497 | whitespace 479 | segmented 476
|
|
182
|
+
*
|
|
183
|
+
* A NOTE ON THE BANDS, which this function deliberately does not touch. A
|
|
184
|
+
* faithful Chinese translation of a 507-word English brief segments to ~609
|
|
185
|
+
* words, so the English 400–500 band still reads it as over-long. That is an
|
|
186
|
+
* EDITORIAL question — what length OGMC wants a Chinese brief to be — not an
|
|
187
|
+
* arithmetic one, and `ContentChecksConfig` already takes per-call band
|
|
188
|
+
* overrides for when they answer it. Fixing the counting without inventing
|
|
189
|
+
* their policy is the honest half.
|
|
190
|
+
*/
|
|
191
|
+
export function wordCount(text: string | null | undefined, locale?: string): number {
|
|
144
192
|
if (!text) return 0;
|
|
145
|
-
|
|
193
|
+
const trimmed = text.trim();
|
|
194
|
+
if (!trimmed) return 0;
|
|
195
|
+
// Guarded rather than assumed: Intl.Segmenter is absent in older runtimes and
|
|
196
|
+
// in environments that trim Intl to save bytes. Falling back to the old
|
|
197
|
+
// behaviour is right for the Latin content that is most of this corpus, and a
|
|
198
|
+
// wrong count beats a thrown error on a validation rail.
|
|
199
|
+
const Segmenter = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
|
|
200
|
+
if (typeof Segmenter !== 'function') {
|
|
201
|
+
return trimmed.split(/\s+/).filter(Boolean).length;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
let n = 0;
|
|
205
|
+
for (const part of new Segmenter(locale, { granularity: 'word' }).segment(trimmed)) {
|
|
206
|
+
// `isWordLike` is what keeps punctuation and the spaces themselves out —
|
|
207
|
+
// the segmenter yields those as segments too.
|
|
208
|
+
if (part.isWordLike) n += 1;
|
|
209
|
+
}
|
|
210
|
+
return n;
|
|
211
|
+
} catch {
|
|
212
|
+
return trimmed.split(/\s+/).filter(Boolean).length;
|
|
213
|
+
}
|
|
146
214
|
}
|
|
147
215
|
|
|
148
216
|
/** Count comma-separated tags in a flat string, ignoring empties. */
|