@startsimpli/ui 0.4.71 → 0.4.73

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.71",
3
+ "version": "0.4.73",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -42,8 +42,8 @@
42
42
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
- "@startsimpli/auth": "^0.4.31",
46
- "@startsimpli/api": "^0.5.45"
45
+ "@startsimpli/api": "^0.5.45",
46
+ "@startsimpli/auth": "^0.4.31"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@startsimpli/api": {
@@ -26,6 +26,8 @@ import {
26
26
  isChosen,
27
27
  gradeWords,
28
28
  wordTargets,
29
+ sourceOverlapCheck,
30
+ pickChanges,
29
31
  type DraftGroup,
30
32
  type DraftSource,
31
33
  type CheckStatus,
@@ -92,14 +94,7 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
92
94
  async function pick(group: DraftGroup, cand: EntityRecord) {
93
95
  setBusyKey(group.key);
94
96
  try {
95
- await Promise.all(
96
- group.candidates.map((c) =>
97
- writeEntity(c, {
98
- chosen: String(c.id) === String(cand.id),
99
- status: String(c.id) === String(cand.id) ? 'selected' : field(c, 'status') === 'ready' ? 'ready' : '',
100
- }),
101
- ),
102
- );
97
+ await Promise.all(group.candidates.map((c) => writeEntity(c, pickChanges(c.id, cand.id))));
103
98
  await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
104
99
  } catch (err) {
105
100
  notify.error(err instanceof Error ? err.message : 'Could not save the pick.');
@@ -108,10 +103,16 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
108
103
  }
109
104
  }
110
105
 
111
- async function markReady(record: EntityRecord) {
106
+ async function markReady(record: EntityRecord, reason?: string) {
112
107
  setBusyKey(String(record.id));
113
108
  try {
114
- await writeEntity(record, { status: 'ready', chosen: true });
109
+ await writeEntity(record, {
110
+ status: 'ready',
111
+ chosen: true,
112
+ // A source-overlap fail doesn't hard-block the accept — the reviewer can
113
+ // override with a reason (768w.18.16.2). Record it so the override is auditable.
114
+ ...(reason ? { ready_override_reason: reason } : {}),
115
+ });
115
116
  await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
116
117
  } catch (err) {
117
118
  notify.error(err instanceof Error ? err.message : 'Could not mark ready.');
@@ -185,7 +186,7 @@ function StoryGroup({
185
186
  openId: string | null;
186
187
  onToggleOpen: (id: string) => void;
187
188
  onPick: (g: DraftGroup, c: EntityRecord) => Promise<void>;
188
- onMarkReady: (c: EntityRecord) => Promise<void>;
189
+ onMarkReady: (c: EntityRecord, reason?: string) => Promise<void>;
189
190
  busy: boolean;
190
191
  busyId: string | null;
191
192
  }) {
@@ -228,7 +229,7 @@ function StoryGroup({
228
229
  key={`rev-${c.id}`}
229
230
  record={c}
230
231
  contentType={group.contentType}
231
- onMarkReady={() => onMarkReady(c)}
232
+ onMarkReady={(reason) => onMarkReady(c, reason)}
232
233
  marking={busyId === String(c.id)}
233
234
  />
234
235
  ))}
@@ -303,7 +304,7 @@ function ContentReview({
303
304
  }: {
304
305
  record: EntityRecord;
305
306
  contentType: string;
306
- onMarkReady: () => void;
307
+ onMarkReady: (reason?: string) => void;
307
308
  marking: boolean;
308
309
  }) {
309
310
  const blog = field(record, 'blog');
@@ -313,6 +314,14 @@ function ContentReview({
313
314
  const checks = obj(readData(record.data, 'auto_checks'));
314
315
  const ready = field(record, 'status') === 'ready';
315
316
 
317
+ // Verbatim-overlap guardrail (768w.18.16.2): recompute the source-overlap check
318
+ // client-side against the cited article text on the record. Only surfaces when
319
+ // the draft actually carries source_texts. A fail gates the accept below.
320
+ const overlap = sourceOverlapCheck(record);
321
+ const overlapFail = overlap?.status === 'fail';
322
+ const [override, setOverride] = useState('');
323
+ const blocked = overlapFail && !ready && override.trim().length < 4;
324
+
316
325
  const tags = ((): string[] => {
317
326
  const t = seo.tags;
318
327
  if (Array.isArray(t)) return t.map(String);
@@ -431,16 +440,55 @@ function ContentReview({
431
440
  sources {approvedSources ? 'approved' : 'unverified'}
432
441
  </Badge>
433
442
  )}
443
+ {overlap && (
444
+ <Badge
445
+ variant="outline"
446
+ title={overlap.detail}
447
+ className={`text-[10px] ${
448
+ overlap.status === 'pass'
449
+ ? 'text-emerald-700'
450
+ : overlap.status === 'warn'
451
+ ? 'text-amber-700'
452
+ : 'text-rose-700'
453
+ }`}
454
+ >
455
+ {overlap.status === 'pass' ? 'original wording' : `source overlap: ${overlap.detail}`}
456
+ </Badge>
457
+ )}
434
458
  </div>
435
459
  </section>
436
460
 
437
- <Button onClick={onMarkReady} disabled={marking || ready} className="w-full">
461
+ {overlapFail && !ready && (
462
+ <section>
463
+ <div className="rounded-md border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800">
464
+ <p className="flex items-center gap-1 font-medium">
465
+ <AlertTriangle className="h-3.5 w-3.5" /> This draft copies too much from its source ({overlap?.detail}).
466
+ </p>
467
+ <p className="mt-1 text-rose-700">Paraphrase the flagged run, or give a reason to publish it anyway.</p>
468
+ <textarea
469
+ value={override}
470
+ onChange={(e) => setOverride(e.target.value)}
471
+ placeholder="Reason to publish despite the overlap…"
472
+ className="mt-2 w-full rounded border border-rose-200 bg-white p-2 text-xs text-gray-800"
473
+ rows={2}
474
+ />
475
+ </div>
476
+ </section>
477
+ )}
478
+
479
+ <Button
480
+ onClick={() => onMarkReady(overlapFail ? override.trim() : undefined)}
481
+ disabled={marking || ready || blocked}
482
+ className="w-full"
483
+ >
438
484
  {ready ? (
439
485
  <>
440
486
  <CheckCircle2 className="mr-1.5 h-4 w-4" /> Ready to post
441
487
  </>
442
488
  ) : marking ? (
443
489
  'Saving…'
490
+ ) : overlapFail ? (
491
+ 'Mark ready to post anyway'
444
492
  ) : (
445
493
  'Mark ready to post'
446
494
  )}
@@ -1,4 +1,4 @@
1
- import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets } from '../drafts';
1
+ import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges } from '../drafts';
2
2
  import type { EntityRecord } from '../types';
3
3
 
4
4
  function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
@@ -89,3 +89,48 @@ describe('gradeWords', () => {
89
89
  expect(gradeWords(100, wordTargets('lead_magnet').linkedin)).toBe('none');
90
90
  });
91
91
  });
92
+
93
+ describe('sourceOverlapCheck (768w.18.16.2 wiring)', () => {
94
+ const SOURCE =
95
+ 'A Houthi threat to impose a naval blockade against Saudi Arabia in the Red Sea could significantly ' +
96
+ 'widen the Iran war and strain a U.S. military already focused on stopping attacks across the region.';
97
+
98
+ it('returns null when the draft carries no source_texts', () => {
99
+ expect(sourceOverlapCheck(rec('d1', 'Draft', { blog: SOURCE }))).toBeNull();
100
+ expect(sourceOverlapCheck(rec('d2', 'Draft', { blog: SOURCE, source_texts: [] }))).toBeNull();
101
+ });
102
+
103
+ it('FAILS when the blog copies a long verbatim run from a source_text', () => {
104
+ const check = sourceOverlapCheck(rec('d3', 'Draft', { blog: `Intro. ${SOURCE} Outro.`, source_texts: [SOURCE] }));
105
+ expect(check).not.toBeNull();
106
+ expect(check!.id).toBe('source-overlap');
107
+ expect(check!.status).toBe('fail');
108
+ });
109
+
110
+ it('PASSES when the blog is original prose against the source_text', () => {
111
+ const original =
112
+ 'Foreign operators with Gulf shipping exposure should reassess route reliability, war-risk premiums, ' +
113
+ 'and contract terms as regional tensions evolve this quarter.';
114
+ const check = sourceOverlapCheck(rec('d4', 'Draft', { blog: original, source_texts: [SOURCE] }));
115
+ expect(check).not.toBeNull();
116
+ expect(check!.status).toBe('pass');
117
+ });
118
+
119
+ it('reads camelCased sourceTexts too', () => {
120
+ const check = sourceOverlapCheck(rec('d5', 'Draft', { blog: `x ${SOURCE}`, sourceTexts: [SOURCE] }));
121
+ expect(check!.status).toBe('fail');
122
+ });
123
+ });
124
+
125
+ describe('pickChanges (startsim-yvd3 — pick must not write an invalid status)', () => {
126
+ it('marks the picked candidate chosen and writes NO status key', () => {
127
+ const picked = pickChanges('a', 'a');
128
+ expect(picked).toEqual({ chosen: true });
129
+ expect('status' in picked).toBe(false); // status enum is the tenant's; picking must not touch it
130
+ expect(pickChanges('b', 'a')).toEqual({ chosen: false });
131
+ });
132
+ it('compares ids as strings (handles number vs string ids)', () => {
133
+ expect(pickChanges(7 as unknown as string, '7')).toEqual({ chosen: true });
134
+ expect(pickChanges(7 as unknown as string, 8 as unknown as string)).toEqual({ chosen: false });
135
+ });
136
+ });
@@ -9,6 +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
13
 
13
14
  export interface DraftSource {
14
15
  url: string;
@@ -158,3 +159,31 @@ export function gradeWords(count: number, [lo, hi]: [number, number]): CheckStat
158
159
  if (count > hi) return 'high';
159
160
  return 'ok';
160
161
  }
162
+
163
+ /**
164
+ * The record change a Pick applies to ONE candidate: mark it chosen (true for the
165
+ * picked candidate, false for the rest). Picking is a selection flag — it must NOT
166
+ * write a lifecycle `status`. The tenant constrains draft `status` to an enum, so
167
+ * writing 'selected' (or '') 400s the whole pick (startsim-yvd3); the "picked"
168
+ * badge already keys off `chosen` via isChosen(). Pure, so it's unit-tested.
169
+ */
170
+ export function pickChanges(candidateId: EntityRecord['id'], pickedId: EntityRecord['id']): { chosen: boolean } {
171
+ return { chosen: String(candidateId) === String(pickedId) };
172
+ }
173
+
174
+ /**
175
+ * The verbatim-overlap guardrail (768w.18.16.2) for a single draft. Runs the
176
+ * shared `source-overlap` check over the draft's blog + linkedin against the
177
+ * cited source article TEXT stamped on the record (`data.source_texts`, populated
178
+ * by the writer's Stamp Source Texts step). Returns null when the draft carries
179
+ * no source text — there is no ground truth to compare against, so there is
180
+ * nothing to surface. Pure, so the wiring is unit-testable without React.
181
+ */
182
+ export function sourceOverlapCheck(r: EntityRecord): ContentCheck | null {
183
+ const raw = readData(r.data, 'source_texts');
184
+ const sourceTexts = Array.isArray(raw) ? raw.map(str).filter(Boolean) : [];
185
+ if (sourceTexts.length === 0) return null;
186
+ const blog = str(readData(r.data, 'blog'));
187
+ const linkedin = str(readData(r.data, 'linkedin'));
188
+ return runContentChecks({ blog, linkedin }, { sourceTexts }).find((c) => c.id === 'source-overlap') ?? null;
189
+ }