@startsimpli/ui 0.4.116 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.116",
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",
50
+ "@startsimpli/auth": "^0.4.35",
51
51
  "@startsimpli/i18n": "^0.1.3",
52
- "@startsimpli/auth": "^0.4.35"
52
+ "@startsimpli/api": "^0.5.58"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -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 checks = obj(readData(record.data, 'auto_checks'));
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 = Number(get(checks, 'blogWords', 'blog_words')) || 0;
411
- const liWords = Number(get(checks, 'linkedinWords', 'linkedin_words')) || 0;
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". */
@@ -61,6 +61,7 @@ export {
61
61
  groupDrafts,
62
62
  wordTargets,
63
63
  gradeWords,
64
+ draftWordCount,
64
65
  type DraftSource,
65
66
  type DraftGroup,
66
67
  type CheckStatus,