@startsimpli/ui 0.4.52 → 0.4.53

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.52",
3
+ "version": "0.4.53",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -41,8 +41,8 @@
41
41
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
42
42
  "react": "^18.0.0 || ^19.0.0",
43
43
  "react-dom": "^18.0.0 || ^19.0.0",
44
- "@startsimpli/auth": "0.4.29",
45
- "@startsimpli/api": "0.5.44"
44
+ "@startsimpli/api": "0.5.44",
45
+ "@startsimpli/auth": "0.4.29"
46
46
  },
47
47
  "peerDependenciesMeta": {
48
48
  "@startsimpli/api": {
@@ -181,6 +181,15 @@ export const UI_CATALOG: CatalogEntry[] = [
181
181
  dataNeeds: 'A type with long-text / structured (json) fields to edit (optionally a source-list field).',
182
182
  source: { export: 'DocumentEditor', from: '@startsimpli/ui/document-editor' },
183
183
  },
184
+ {
185
+ id: 'validation-checklist',
186
+ category: 'component',
187
+ title: 'Validation checklist',
188
+ description:
189
+ "A pass/warn/fail review panel for an AI-generated content record: renders the deterministic auto-checks (word-count bands, hype-word scan, approved-source allow-list) as a green/amber/red checklist, surfaces the buried AI judge verdict (per-dimension scores + an expandable issues list), and supports a reasoned reviewer override. Its runContentChecks util RE-RUNS the deterministic checks over the reviewer's *edited* fields, so validation tracks the current draft, not the stale original — pair it with the document-editor to review-and-accept a content draft.",
190
+ dataNeeds: 'A content type carrying a judge verdict + auto-checks (and the editable content fields to recheck).',
191
+ source: { export: 'ValidationChecklist', from: '@startsimpli/ui' },
192
+ },
184
193
  {
185
194
  id: 'chat',
186
195
  category: 'component',
@@ -168,3 +168,8 @@ export type {
168
168
 
169
169
  // Record detail read-view
170
170
  export * from './record-detail'
171
+
172
+ // Content validation toolkit (768w.16.10.1) — ValidationChecklist + the pure
173
+ // runContentChecks recompute util. Light (React + lucide only), so it ships on
174
+ // the main barrel rather than a subpath.
175
+ export * from './validation'
@@ -0,0 +1,270 @@
1
+ import * as React from 'react';
2
+ import {
3
+ CheckCircle2,
4
+ AlertTriangle,
5
+ XCircle,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ ShieldCheck,
9
+ ShieldAlert,
10
+ } from 'lucide-react';
11
+ import { cn } from '../../lib/utils';
12
+ import { overallStatus, type CheckStatus, type ContentCheck } from './checks';
13
+
14
+ /** Redundant (color + icon + label) tone per check status — never color alone. */
15
+ const CHECK_TONE: Record<CheckStatus, { label: string; dot: string; text: string; softBg: string; Icon: typeof CheckCircle2 }> = {
16
+ pass: { label: 'Passing', dot: 'bg-emerald-500', text: 'text-emerald-700', softBg: 'bg-emerald-50', Icon: CheckCircle2 },
17
+ warn: { label: 'Warnings', dot: 'bg-amber-500', text: 'text-amber-700', softBg: 'bg-amber-50', Icon: AlertTriangle },
18
+ fail: { label: 'Failing', dot: 'bg-red-500', text: 'text-red-700', softBg: 'bg-red-50', Icon: XCircle },
19
+ };
20
+
21
+ /** One issue the AI judge raised against a guardrail. */
22
+ export interface JudgeIssue {
23
+ guardrail?: string;
24
+ severity?: string;
25
+ problem?: string;
26
+ fix?: string;
27
+ }
28
+
29
+ /** The AI judge's output stored alongside the content record. */
30
+ export interface JudgeVerdict {
31
+ /** Overall verdict word (e.g. "approve", "revise", "reject"). */
32
+ verdict?: string;
33
+ /** Per-dimension scores, keyed by dimension name. */
34
+ scores?: Record<string, unknown>;
35
+ /** Specific problems the judge flagged. */
36
+ issues?: JudgeIssue[];
37
+ /** One-line prose summary. */
38
+ summary?: string;
39
+ }
40
+
41
+ /** Reviewer's manual override of the automated checks. */
42
+ export interface ValidationOverride {
43
+ overridden: boolean;
44
+ reason?: string;
45
+ }
46
+
47
+ export interface ValidationChecklistProps {
48
+ /** The (recomputed) deterministic checks to render. */
49
+ checks: ContentCheck[];
50
+ /** The AI judge output — renders a verdict badge, scores, and issues. */
51
+ judgeVerdict?: JudgeVerdict;
52
+ /** Current override state. When set, the overall pill reflects it. */
53
+ override?: ValidationOverride;
54
+ /** Provide to make the override affordance interactive. Omit → read-only. */
55
+ onOverride?: (next: ValidationOverride) => void;
56
+ title?: string;
57
+ className?: string;
58
+ }
59
+
60
+ /** A tone pill naming the roll-up state. */
61
+ function StatusPill({ status, label }: { status: CheckStatus; label?: string }) {
62
+ const tone = CHECK_TONE[status];
63
+ const Icon = tone.Icon;
64
+ return (
65
+ <span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', tone.softBg, tone.text)}>
66
+ <Icon className="w-3.5 h-3.5" aria-hidden="true" />
67
+ {label ?? tone.label}
68
+ </span>
69
+ );
70
+ }
71
+
72
+ /** Colour a judge verdict word without needing to know the exact vocabulary. */
73
+ function verdictStatus(verdict?: string): CheckStatus {
74
+ const v = (verdict ?? '').toLowerCase();
75
+ if (/pass|approve|accept|ok|good/.test(v)) return 'pass';
76
+ if (/fail|reject|block|deny|bad/.test(v)) return 'fail';
77
+ return 'warn';
78
+ }
79
+
80
+ /**
81
+ * Presentational, bring-your-own-data validation panel (768w.16.10.1).
82
+ *
83
+ * Surfaces the buried machine review of an AI-generated content record as a
84
+ * scannable pass/warn/fail checklist plus the AI judge's verdict — so a Foundry
85
+ * tenant can accept/reject in one glance. Stateless except for local UI toggles
86
+ * (expand issues, edit override reason); the app owns the data and persistence.
87
+ */
88
+ export function ValidationChecklist({
89
+ checks,
90
+ judgeVerdict,
91
+ override,
92
+ onOverride,
93
+ title = 'Validation',
94
+ className,
95
+ }: ValidationChecklistProps) {
96
+ const overall = overallStatus(checks);
97
+ const [issuesOpen, setIssuesOpen] = React.useState(false);
98
+ const [overrideOpen, setOverrideOpen] = React.useState(false);
99
+ const [reason, setReason] = React.useState(override?.reason ?? '');
100
+
101
+ const counts = React.useMemo(
102
+ () => ({
103
+ pass: checks.filter((c) => c.status === 'pass').length,
104
+ warn: checks.filter((c) => c.status === 'warn').length,
105
+ fail: checks.filter((c) => c.status === 'fail').length,
106
+ }),
107
+ [checks],
108
+ );
109
+
110
+ const issues = judgeVerdict?.issues ?? [];
111
+ const scoreEntries = judgeVerdict?.scores ? Object.entries(judgeVerdict.scores) : [];
112
+
113
+ return (
114
+ <div className={cn('rounded-xl border bg-card text-card-foreground shadow p-5 space-y-4', className)}>
115
+ {/* Header: title + overall pill */}
116
+ <div className="flex items-center justify-between gap-3">
117
+ <div className="flex items-center gap-2 min-w-0">
118
+ <ShieldCheck className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
119
+ <h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
120
+ </div>
121
+ <div className="flex items-center gap-2 flex-shrink-0">
122
+ {override?.overridden && (
123
+ <span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
124
+ <ShieldAlert className="w-3 h-3" aria-hidden="true" />
125
+ Overridden
126
+ </span>
127
+ )}
128
+ <StatusPill status={override?.overridden ? 'pass' : overall} label={`${counts.pass}/${checks.length} passing`} />
129
+ </div>
130
+ </div>
131
+
132
+ {/* Checks list */}
133
+ {checks.length === 0 ? (
134
+ <p className="text-sm text-gray-500 py-2 text-center">No checks to show.</p>
135
+ ) : (
136
+ <ul className="space-y-1.5">
137
+ {checks.map((check) => {
138
+ const tone = CHECK_TONE[check.status];
139
+ const Icon = tone.Icon;
140
+ return (
141
+ <li key={check.id} className="flex items-center gap-2.5 text-sm">
142
+ <span className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', tone.dot)} aria-hidden="true" />
143
+ <Icon className={cn('w-4 h-4 flex-shrink-0', tone.text)} aria-hidden="true" />
144
+ <span className="text-gray-900">{check.label}</span>
145
+ {check.detail && <span className="text-gray-500 truncate">— {check.detail}</span>}
146
+ <span className="sr-only">{tone.label}</span>
147
+ </li>
148
+ );
149
+ })}
150
+ </ul>
151
+ )}
152
+
153
+ {/* AI judge section */}
154
+ {judgeVerdict && (
155
+ <div className="rounded-lg border bg-gray-50/60 p-3 space-y-3">
156
+ <div className="flex items-center justify-between gap-3">
157
+ <span className="text-xs font-semibold uppercase tracking-wide text-gray-500">AI judge</span>
158
+ {judgeVerdict.verdict && <StatusPill status={verdictStatus(judgeVerdict.verdict)} label={judgeVerdict.verdict} />}
159
+ </div>
160
+
161
+ {judgeVerdict.summary && <p className="text-sm text-gray-700">{judgeVerdict.summary}</p>}
162
+
163
+ {scoreEntries.length > 0 && (
164
+ <dl className="flex flex-wrap gap-2">
165
+ {scoreEntries.map(([dim, val]) => (
166
+ <div key={dim} className="rounded-md bg-white border px-2 py-1 text-xs">
167
+ <dt className="text-gray-500">{dim}</dt>
168
+ <dd className="font-medium text-gray-900">{String(val)}</dd>
169
+ </div>
170
+ ))}
171
+ </dl>
172
+ )}
173
+
174
+ {issues.length > 0 && (
175
+ <div>
176
+ <button
177
+ type="button"
178
+ onClick={() => setIssuesOpen((o) => !o)}
179
+ className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900"
180
+ aria-expanded={issuesOpen}
181
+ >
182
+ {issuesOpen ? <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronRight className="w-3.5 h-3.5" aria-hidden="true" />}
183
+ {issues.length} issue{issues.length === 1 ? '' : 's'}
184
+ </button>
185
+ {issuesOpen && (
186
+ <ul className="mt-2 space-y-2">
187
+ {issues.map((issue, i) => (
188
+ <li key={i} className="rounded-md border bg-white p-2 text-xs space-y-1">
189
+ <div className="flex items-center gap-2">
190
+ {issue.guardrail && <span className="font-medium text-gray-900">{issue.guardrail}</span>}
191
+ {issue.severity && (
192
+ <span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">{issue.severity}</span>
193
+ )}
194
+ </div>
195
+ {issue.problem && <p className="text-gray-700">{issue.problem}</p>}
196
+ {issue.fix && <p className="text-gray-500">Fix: {issue.fix}</p>}
197
+ </li>
198
+ ))}
199
+ </ul>
200
+ )}
201
+ </div>
202
+ )}
203
+ </div>
204
+ )}
205
+
206
+ {/* Override affordance — interactive only when onOverride is provided */}
207
+ {onOverride && (
208
+ <div className="border-t pt-3">
209
+ {override?.overridden ? (
210
+ <div className="flex items-start justify-between gap-3 text-sm">
211
+ <div className="min-w-0">
212
+ <p className="font-medium text-gray-900">Checks overridden</p>
213
+ {override.reason && <p className="text-gray-500 break-words">{override.reason}</p>}
214
+ </div>
215
+ <button
216
+ type="button"
217
+ onClick={() => onOverride({ overridden: false })}
218
+ className="text-xs font-medium text-gray-500 hover:text-gray-900 flex-shrink-0"
219
+ >
220
+ Clear
221
+ </button>
222
+ </div>
223
+ ) : overrideOpen ? (
224
+ <div className="space-y-2">
225
+ <label className="text-xs font-medium text-gray-600" htmlFor="validation-override-reason">
226
+ Reason for overriding checks
227
+ </label>
228
+ <textarea
229
+ id="validation-override-reason"
230
+ value={reason}
231
+ onChange={(e) => setReason(e.target.value)}
232
+ rows={2}
233
+ className="w-full rounded-md border px-2 py-1 text-sm"
234
+ placeholder="Why is this content acceptable despite the failing checks?"
235
+ />
236
+ <div className="flex items-center gap-2">
237
+ <button
238
+ type="button"
239
+ disabled={reason.trim().length === 0}
240
+ onClick={() => {
241
+ onOverride({ overridden: true, reason: reason.trim() });
242
+ setOverrideOpen(false);
243
+ }}
244
+ className="rounded-md bg-gray-900 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
245
+ >
246
+ Override
247
+ </button>
248
+ <button
249
+ type="button"
250
+ onClick={() => setOverrideOpen(false)}
251
+ className="text-xs font-medium text-gray-500 hover:text-gray-900"
252
+ >
253
+ Cancel
254
+ </button>
255
+ </div>
256
+ </div>
257
+ ) : (
258
+ <button
259
+ type="button"
260
+ onClick={() => setOverrideOpen(true)}
261
+ className="text-xs font-medium text-gray-600 hover:text-gray-900"
262
+ >
263
+ Override checks (with reason)
264
+ </button>
265
+ )}
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,84 @@
1
+ import * as React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { ValidationChecklist } from '../ValidationChecklist';
4
+ import type { ContentCheck } from '../checks';
5
+
6
+ const checks: ContentCheck[] = [
7
+ { id: 'blog-words', label: 'Blog word count', status: 'pass', detail: '450 words' },
8
+ { id: 'linkedin-words', label: 'LinkedIn word count', status: 'warn', detail: '100 words' },
9
+ { id: 'no-hype', label: 'No hype words', status: 'fail', detail: 'revolutionary' },
10
+ ];
11
+
12
+ describe('ValidationChecklist', () => {
13
+ it('renders every check with its label and detail', () => {
14
+ render(<ValidationChecklist checks={checks} />);
15
+ expect(screen.getByText('Blog word count')).toBeInTheDocument();
16
+ expect(screen.getByText('— 450 words')).toBeInTheDocument();
17
+ expect(screen.getByText('No hype words')).toBeInTheDocument();
18
+ });
19
+
20
+ it('summarises passing count in the overall pill', () => {
21
+ render(<ValidationChecklist checks={checks} />);
22
+ expect(screen.getByText('1/3 passing')).toBeInTheDocument();
23
+ });
24
+
25
+ it('renders the judge verdict, scores, and expandable issues', () => {
26
+ render(
27
+ <ValidationChecklist
28
+ checks={checks}
29
+ judgeVerdict={{
30
+ verdict: 'revise',
31
+ summary: 'Reads well but sourcing is thin.',
32
+ scores: { clarity: 8, accuracy: 6 },
33
+ issues: [{ guardrail: 'sourcing', severity: 'high', problem: 'Unsupported claim', fix: 'Add a citation' }],
34
+ }}
35
+ />,
36
+ );
37
+ expect(screen.getByText('AI judge')).toBeInTheDocument();
38
+ expect(screen.getByText('revise')).toBeInTheDocument();
39
+ expect(screen.getByText('Reads well but sourcing is thin.')).toBeInTheDocument();
40
+ expect(screen.getByText('clarity')).toBeInTheDocument();
41
+
42
+ // issues collapsed by default, expand on click
43
+ expect(screen.queryByText('Unsupported claim')).not.toBeInTheDocument();
44
+ fireEvent.click(screen.getByText('1 issue'));
45
+ expect(screen.getByText('Unsupported claim')).toBeInTheDocument();
46
+ expect(screen.getByText('Fix: Add a citation')).toBeInTheDocument();
47
+ });
48
+
49
+ it('is read-only when no onOverride is provided', () => {
50
+ render(<ValidationChecklist checks={checks} />);
51
+ expect(screen.queryByText('Override checks (with reason)')).not.toBeInTheDocument();
52
+ });
53
+
54
+ it('drives the override flow when onOverride is provided', () => {
55
+ const onOverride = jest.fn();
56
+ render(<ValidationChecklist checks={checks} onOverride={onOverride} />);
57
+
58
+ fireEvent.click(screen.getByText('Override checks (with reason)'));
59
+ const textarea = screen.getByLabelText('Reason for overriding checks');
60
+ // empty reason keeps the confirm disabled
61
+ expect(screen.getByRole('button', { name: 'Override' })).toBeDisabled();
62
+
63
+ fireEvent.change(textarea, { target: { value: 'Client approved the exception.' } });
64
+ fireEvent.click(screen.getByRole('button', { name: 'Override' }));
65
+ expect(onOverride).toHaveBeenCalledWith({ overridden: true, reason: 'Client approved the exception.' });
66
+ });
67
+
68
+ it('shows an overridden banner and can clear the override', () => {
69
+ const onOverride = jest.fn();
70
+ render(
71
+ <ValidationChecklist
72
+ checks={checks}
73
+ override={{ overridden: true, reason: 'Approved by editor.' }}
74
+ onOverride={onOverride}
75
+ />,
76
+ );
77
+ expect(screen.getByText('Checks overridden')).toBeInTheDocument();
78
+ expect(screen.getByText('Approved by editor.')).toBeInTheDocument();
79
+ expect(screen.getByText('Overridden')).toBeInTheDocument();
80
+
81
+ fireEvent.click(screen.getByText('Clear'));
82
+ expect(onOverride).toHaveBeenCalledWith({ overridden: false });
83
+ });
84
+ });
@@ -0,0 +1,191 @@
1
+ import {
2
+ runContentChecks,
3
+ overallStatus,
4
+ wordCount,
5
+ tagCount,
6
+ extractUrls,
7
+ hostOf,
8
+ DEFAULT_HYPE_WORDS,
9
+ type ContentCheck,
10
+ } from '../checks';
11
+
12
+ /** Build a string of exactly `n` whitespace-delimited words. */
13
+ const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
14
+ const find = (checks: ContentCheck[], id: string) => checks.find((c) => c.id === id);
15
+
16
+ describe('word/tag helpers', () => {
17
+ it('wordCount handles empty, whitespace, and normal input', () => {
18
+ expect(wordCount(undefined)).toBe(0);
19
+ expect(wordCount('')).toBe(0);
20
+ expect(wordCount(' ')).toBe(0);
21
+ expect(wordCount('one two three')).toBe(3);
22
+ expect(wordCount(words(462))).toBe(462);
23
+ });
24
+
25
+ it('tagCount splits on commas and ignores empties', () => {
26
+ expect(tagCount(undefined)).toBe(0);
27
+ expect(tagCount('')).toBe(0);
28
+ expect(tagCount('a, b, c')).toBe(3);
29
+ expect(tagCount('a,,b, ,c,')).toBe(3);
30
+ });
31
+ });
32
+
33
+ describe('runContentChecks — field presence', () => {
34
+ it('emits no checks for an empty fields object (no approvedHosts)', () => {
35
+ expect(runContentChecks({})).toEqual([]);
36
+ });
37
+
38
+ it('only emits a check for a field that is present', () => {
39
+ const checks = runContentChecks({ blog: words(450) });
40
+ expect(checks.map((c) => c.id).sort()).toEqual(['blog-words', 'no-hype']);
41
+ });
42
+ });
43
+
44
+ describe('word-count bands', () => {
45
+ it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => {
46
+ expect(find(runContentChecks({ blog: words(450) }), 'blog-words')).toMatchObject({ status: 'pass', detail: '450 words' });
47
+ expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words' });
48
+ expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn');
49
+ // inclusive edges
50
+ expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass');
51
+ expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.status).toBe('pass');
52
+ });
53
+
54
+ it('linkedin: default band 150–250', () => {
55
+ expect(find(runContentChecks({ linkedin: words(200) }), 'linkedin-words')!.status).toBe('pass');
56
+ expect(find(runContentChecks({ linkedin: words(100) }), 'linkedin-words')!.status).toBe('warn');
57
+ });
58
+
59
+ it('headline: default band 8–12 words', () => {
60
+ expect(find(runContentChecks({ headline: words(10) }), 'headline-words')!.status).toBe('pass');
61
+ expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.status).toBe('warn');
62
+ expect(find(runContentChecks({ headline: words(20) }), 'headline-words')!.status).toBe('warn');
63
+ });
64
+
65
+ it('honours a custom band override', () => {
66
+ const c = find(runContentChecks({ blog: words(100) }, { blogWords: [50, 150] }), 'blog-words');
67
+ expect(c!.status).toBe('pass');
68
+ });
69
+ });
70
+
71
+ describe('tag count and meta length', () => {
72
+ it('tags: default band 4–6', () => {
73
+ expect(find(runContentChecks({ tags: 'a,b,c,d,e' }), 'tags-count')).toMatchObject({ status: 'pass', detail: '5 tags' });
74
+ expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.status).toBe('warn');
75
+ expect(find(runContentChecks({ tags: 'a,b,c,d,e,f,g' }), 'tags-count')!.status).toBe('warn');
76
+ });
77
+
78
+ it('meta description: default band 150–160 chars', () => {
79
+ const inBand = 'x'.repeat(155);
80
+ const tooShort = 'x'.repeat(120);
81
+ expect(find(runContentChecks({ metaDescription: inBand }), 'meta-length')).toMatchObject({ status: 'pass', detail: '155 chars' });
82
+ expect(find(runContentChecks({ metaDescription: tooShort }), 'meta-length')!.status).toBe('warn');
83
+ });
84
+ });
85
+
86
+ describe('[link] placeholder', () => {
87
+ it('passes when present, warns when absent', () => {
88
+ expect(find(runContentChecks({ linkedin: 'read more [link] now' }), 'link-placeholder')!.status).toBe('pass');
89
+ expect(find(runContentChecks({ linkedin: 'no placeholder here' }), 'link-placeholder')).toMatchObject({
90
+ status: 'warn',
91
+ detail: 'no [link] found',
92
+ });
93
+ });
94
+
95
+ it('is skipped when requireLink is false', () => {
96
+ expect(find(runContentChecks({ linkedin: 'no placeholder' }, { requireLink: false }), 'link-placeholder')).toBeUndefined();
97
+ });
98
+ });
99
+
100
+ describe('no-hype', () => {
101
+ it('fails when a hype word appears (case-insensitive) across headline/blog/linkedin', () => {
102
+ const c = find(runContentChecks({ headline: 'A REVOLUTIONARY launch', blog: words(450) }), 'no-hype');
103
+ expect(c!.status).toBe('fail');
104
+ expect(c!.detail).toContain('revolutionary');
105
+ });
106
+
107
+ it('detects multi-word and hyphenated hype phrases', () => {
108
+ expect(find(runContentChecks({ blog: 'the best-in-class widget' }), 'no-hype')!.status).toBe('fail');
109
+ expect(find(runContentChecks({ blog: 'the most attractive option' }), 'no-hype')!.status).toBe('fail');
110
+ });
111
+
112
+ it('passes clean prose', () => {
113
+ expect(find(runContentChecks({ blog: 'a measured, factual paragraph about widgets' }), 'no-hype')!.status).toBe('pass');
114
+ });
115
+
116
+ it('honours a custom hype list', () => {
117
+ expect(find(runContentChecks({ blog: 'totally banana content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('fail');
118
+ // default hype words no longer apply when overridden
119
+ expect(find(runContentChecks({ blog: 'revolutionary content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('pass');
120
+ });
121
+
122
+ it('ships a non-empty default hype list', () => {
123
+ expect(DEFAULT_HYPE_WORDS.length).toBeGreaterThan(5);
124
+ expect(DEFAULT_HYPE_WORDS).toContain('game-changing');
125
+ });
126
+ });
127
+
128
+ describe('approved sources', () => {
129
+ const approvedHosts = ['example.com', 'gov.uk'];
130
+
131
+ it('is skipped entirely when approvedHosts is omitted', () => {
132
+ expect(find(runContentChecks({ sources: 'https://example.com/a' }), 'approved-sources')).toBeUndefined();
133
+ });
134
+
135
+ it('passes when every host is approved (www. stripped)', () => {
136
+ const c = find(runContentChecks({ sources: 'https://www.example.com/a https://gov.uk/b' }, { approvedHosts }), 'approved-sources');
137
+ expect(c!.status).toBe('pass');
138
+ });
139
+
140
+ it('fails when any host is not on the allow-list', () => {
141
+ const c = find(runContentChecks({ sources: 'https://example.com/a https://sketchy.io/x' }, { approvedHosts }), 'approved-sources');
142
+ expect(c!.status).toBe('fail');
143
+ expect(c!.detail).toContain('sketchy.io');
144
+ expect(c!.detail).not.toContain('example.com');
145
+ });
146
+
147
+ it('fails forum/social hosts even if listed', () => {
148
+ const c = find(
149
+ runContentChecks({ sources: 'https://reddit.com/r/x' }, { approvedHosts: ['reddit.com'] }),
150
+ 'approved-sources',
151
+ );
152
+ expect(c!.status).toBe('fail');
153
+ expect(c!.detail).toContain('reddit.com');
154
+ });
155
+
156
+ it('warns when there is no URL to check', () => {
157
+ expect(find(runContentChecks({ sources: 'no links here' }, { approvedHosts }), 'approved-sources')).toMatchObject({
158
+ status: 'warn',
159
+ detail: 'no sources to check',
160
+ });
161
+ // sources field absent but allow-list present → still a warn (nothing to check)
162
+ expect(find(runContentChecks({}, { approvedHosts }), 'approved-sources')!.status).toBe('warn');
163
+ });
164
+
165
+ it('extractUrls + hostOf normalise correctly', () => {
166
+ expect(extractUrls('see https://a.com/x and http://b.org')).toEqual(['https://a.com/x', 'http://b.org']);
167
+ expect(extractUrls('none')).toEqual([]);
168
+ expect(hostOf('https://www.Example.com/path')).toBe('example.com');
169
+ expect(hostOf('not a url')).toBe('');
170
+ });
171
+ });
172
+
173
+ describe('overallStatus', () => {
174
+ const mk = (status: ContentCheck['status']): ContentCheck => ({ id: status, label: status, status });
175
+
176
+ it('fail beats warn beats pass; empty is pass', () => {
177
+ expect(overallStatus([])).toBe('pass');
178
+ expect(overallStatus([mk('pass'), mk('pass')])).toBe('pass');
179
+ expect(overallStatus([mk('pass'), mk('warn')])).toBe('warn');
180
+ expect(overallStatus([mk('warn'), mk('fail')])).toBe('fail');
181
+ });
182
+
183
+ it('rolls up a realistic mixed run', () => {
184
+ const checks = runContentChecks(
185
+ { blog: words(450), linkedin: 'a revolutionary post [link]', tags: 'a,b,c,d,e' },
186
+ {},
187
+ );
188
+ // blog pass, linkedin word-count warn (2 words), link pass, hype fail, tags pass
189
+ expect(overallStatus(checks)).toBe('fail');
190
+ });
191
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Deterministic content checks (768w.16.10.1) — pure + framework-free.
3
+ *
4
+ * A Foundry tenant reviews AI-generated content before accepting it. The n8n
5
+ * pipeline that produced the content also ran a set of deterministic
6
+ * auto-checks (word-count bands, hype-word scan, approved-source allow-list)
7
+ * and stored their result on the record. This module re-implements the SAME
8
+ * logic so the client can RECOMPUTE the checks against the reviewer's *edited*
9
+ * content — validation reflects the current draft, not the stale original.
10
+ *
11
+ * Keep this identical to the n8n judge's deterministic logic so client +
12
+ * pipeline always agree. No React, no DOM — importable from a Node/LLM route.
13
+ */
14
+
15
+ /** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
16
+ export type CheckStatus = 'pass' | 'warn' | 'fail';
17
+
18
+ /** One deterministic check result. */
19
+ export interface ContentCheck {
20
+ /** Stable id for the check (e.g. `blog-words`, `no-hype`). */
21
+ id: string;
22
+ /** Human label shown in the checklist. */
23
+ label: string;
24
+ status: CheckStatus;
25
+ /** Short human explanation (e.g. "462 words", "unapproved: reddit.com"). */
26
+ detail?: string;
27
+ }
28
+
29
+ /**
30
+ * The content fields a record can expose for checking. All optional — a check
31
+ * is only emitted for a field that is present (an app without a `blog` field
32
+ * never gets a spurious blog check). `tags` and `sources` are flat strings
33
+ * (comma-separated tags, whitespace/URL-bearing sources) to match how the
34
+ * pipeline stores them.
35
+ */
36
+ export interface ContentFields {
37
+ blog?: string;
38
+ linkedin?: string;
39
+ headline?: string;
40
+ tags?: string;
41
+ metaDescription?: string;
42
+ sources?: string;
43
+ }
44
+
45
+ /** An inclusive `[min, max]` band. */
46
+ export type Band = [min: number, max: number];
47
+
48
+ /**
49
+ * Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`:
50
+ * the source allow-list is deliberately app-injected (no hardcoded list here),
51
+ * so the approved-sources check is skipped entirely when it is omitted.
52
+ */
53
+ export interface ContentChecksConfig {
54
+ /** Blog word-count band (default 400–500). */
55
+ blogWords?: Band;
56
+ /** LinkedIn word-count band (default 150–250). */
57
+ linkedinWords?: Band;
58
+ /** Headline word-count band (default 8–12). */
59
+ headlineWords?: Band;
60
+ /** Tag-count band (default 4–6). */
61
+ tagsCount?: Band;
62
+ /** Meta-description character band (default 150–160). */
63
+ metaChars?: Band;
64
+ /** Require a `[link]` placeholder in the LinkedIn post (default true). */
65
+ requireLink?: boolean;
66
+ /** Override the hype-word blocklist. */
67
+ hypeWords?: string[];
68
+ /** Approved source hosts (no `www.`). REQUIRED to run the source check. */
69
+ approvedHosts?: string[];
70
+ }
71
+
72
+ /**
73
+ * The default hype/marketing-fluff blocklist. If ANY appears in the content the
74
+ * hype check FAILS. Matches the n8n judge's list — keep the two in sync.
75
+ */
76
+ export const DEFAULT_HYPE_WORDS: string[] = [
77
+ 'game-changing',
78
+ 'gamechanging',
79
+ 'revolutionary',
80
+ 'world-leading',
81
+ 'world leading',
82
+ 'unprecedented',
83
+ 'undisputed',
84
+ 'transformational',
85
+ 'groundbreaking',
86
+ 'ground-breaking',
87
+ 'best-in-class',
88
+ 'unmatched',
89
+ 'unrivalled',
90
+ 'unrivaled',
91
+ 'on earth',
92
+ 'most attractive',
93
+ ];
94
+
95
+ /** Forum / social hosts that are never an acceptable source, allow-list or not. */
96
+ const FORUM_SOCIAL = /reddit|quora|facebook|twitter|forum/i;
97
+
98
+ const DEFAULTS = {
99
+ blogWords: [400, 500] as Band,
100
+ linkedinWords: [150, 250] as Band,
101
+ headlineWords: [8, 12] as Band,
102
+ tagsCount: [4, 6] as Band,
103
+ metaChars: [150, 160] as Band,
104
+ };
105
+
106
+ /** Count words in a string — whitespace-delimited, empty → 0. */
107
+ export function wordCount(text: string | null | undefined): number {
108
+ if (!text) return 0;
109
+ return text.trim().split(/\s+/).filter(Boolean).length;
110
+ }
111
+
112
+ /** Count comma-separated tags in a flat string, ignoring empties. */
113
+ export function tagCount(tags: string | null | undefined): number {
114
+ if (!tags) return 0;
115
+ return tags
116
+ .split(',')
117
+ .map((t) => t.trim())
118
+ .filter(Boolean).length;
119
+ }
120
+
121
+ /** pass when `n` is inside the inclusive band, else warn. */
122
+ function bandStatus(n: number, [min, max]: Band): CheckStatus {
123
+ return n >= min && n <= max ? 'pass' : 'warn';
124
+ }
125
+
126
+ /** Pull http(s) URLs out of a free-text sources string. */
127
+ export function extractUrls(text: string | null | undefined): string[] {
128
+ if (!text) return [];
129
+ return text.match(/https?:\/\/[^\s"'<>)\]]+/gi) ?? [];
130
+ }
131
+
132
+ /** Normalise a URL to its bare host (lowercased, `www.` stripped). '' if unparseable. */
133
+ export function hostOf(url: string): string {
134
+ try {
135
+ return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
136
+ } catch {
137
+ return '';
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Recompute every deterministic content check over `fields`. Pure. A check is
143
+ * emitted only when its underlying field is present; the approved-sources check
144
+ * is emitted only when `config.approvedHosts` is supplied.
145
+ */
146
+ export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] {
147
+ const checks: ContentCheck[] = [];
148
+ const {
149
+ blogWords = DEFAULTS.blogWords,
150
+ linkedinWords = DEFAULTS.linkedinWords,
151
+ headlineWords = DEFAULTS.headlineWords,
152
+ tagsCount = DEFAULTS.tagsCount,
153
+ metaChars = DEFAULTS.metaChars,
154
+ requireLink = true,
155
+ hypeWords = DEFAULT_HYPE_WORDS,
156
+ approvedHosts,
157
+ } = config;
158
+
159
+ // Word-count bands.
160
+ if (fields.headline !== undefined) {
161
+ const n = wordCount(fields.headline);
162
+ checks.push({ id: 'headline-words', label: 'Headline length', status: bandStatus(n, headlineWords), detail: `${n} words` });
163
+ }
164
+ if (fields.blog !== undefined) {
165
+ const n = wordCount(fields.blog);
166
+ checks.push({ id: 'blog-words', label: 'Blog word count', status: bandStatus(n, blogWords), detail: `${n} words` });
167
+ }
168
+ if (fields.linkedin !== undefined) {
169
+ const n = wordCount(fields.linkedin);
170
+ checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status: bandStatus(n, linkedinWords), detail: `${n} words` });
171
+ }
172
+
173
+ // Tag count.
174
+ if (fields.tags !== undefined) {
175
+ const n = tagCount(fields.tags);
176
+ checks.push({ id: 'tags-count', label: 'Tag count', status: bandStatus(n, tagsCount), detail: `${n} tags` });
177
+ }
178
+
179
+ // Meta description length (characters).
180
+ if (fields.metaDescription !== undefined) {
181
+ const len = fields.metaDescription.trim().length;
182
+ checks.push({ id: 'meta-length', label: 'Meta description length', status: bandStatus(len, metaChars), detail: `${len} chars` });
183
+ }
184
+
185
+ // `[link]` placeholder present in the LinkedIn post.
186
+ if (requireLink && fields.linkedin !== undefined) {
187
+ const has = fields.linkedin.includes('[link]');
188
+ checks.push({
189
+ id: 'link-placeholder',
190
+ label: '[link] placeholder in LinkedIn',
191
+ status: has ? 'pass' : 'warn',
192
+ detail: has ? undefined : 'no [link] found',
193
+ });
194
+ }
195
+
196
+ // No hype words — scan headline + blog + linkedin, lowercased.
197
+ const hasProse = fields.headline !== undefined || fields.blog !== undefined || fields.linkedin !== undefined;
198
+ if (hasProse) {
199
+ const haystack = [fields.headline, fields.blog, fields.linkedin]
200
+ .filter((s): s is string => typeof s === 'string')
201
+ .join('\n')
202
+ .toLowerCase();
203
+ const found = hypeWords.filter((w) => haystack.includes(w.toLowerCase()));
204
+ checks.push({
205
+ id: 'no-hype',
206
+ label: 'No hype words',
207
+ status: found.length > 0 ? 'fail' : 'pass',
208
+ detail: found.length > 0 ? found.join(', ') : undefined,
209
+ });
210
+ }
211
+
212
+ // Approved sources — only when the app supplied an allow-list.
213
+ if (approvedHosts !== undefined) {
214
+ const approved = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, '')));
215
+ const hosts = extractUrls(fields.sources).map(hostOf).filter(Boolean);
216
+ if (hosts.length === 0) {
217
+ checks.push({ id: 'approved-sources', label: 'Approved sources', status: 'warn', detail: 'no sources to check' });
218
+ } else {
219
+ const bad = Array.from(new Set(hosts.filter((h) => !approved.has(h) || FORUM_SOCIAL.test(h))));
220
+ checks.push({
221
+ id: 'approved-sources',
222
+ label: 'Approved sources',
223
+ status: bad.length > 0 ? 'fail' : 'pass',
224
+ detail: bad.length > 0 ? `unapproved: ${bad.join(', ')}` : `${hosts.length} source${hosts.length === 1 ? '' : 's'} ok`,
225
+ });
226
+ }
227
+ }
228
+
229
+ return checks;
230
+ }
231
+
232
+ /**
233
+ * Roll a list of checks up to one status: fail if any fails, else warn if any
234
+ * warns, else pass (empty → pass).
235
+ */
236
+ export function overallStatus(checks: ContentCheck[]): CheckStatus {
237
+ if (checks.some((c) => c.status === 'fail')) return 'fail';
238
+ if (checks.some((c) => c.status === 'warn')) return 'warn';
239
+ return 'pass';
240
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @startsimpli/ui validation toolkit (768w.16.10.1).
3
+ *
4
+ * Surfaces a Foundry tenant's machine review of AI-generated content — the AI
5
+ * judge verdict plus deterministic auto-checks — as a pass/warn/fail checklist,
6
+ * and RECOMPUTES the deterministic checks over the reviewer's *edited* content
7
+ * (so validation tracks the current draft, not the stale original).
8
+ *
9
+ * `checks.ts` is pure + framework-free (importable in a Node/LLM/n8n context);
10
+ * `ValidationChecklist` is presentational + BYO-data. Light-weight (React +
11
+ * lucide only), so it rides the main components barrel like the health blocks —
12
+ * no dedicated subpath (unlike document-editor, which is subpathed only because
13
+ * it drags react-markdown).
14
+ */
15
+ export {
16
+ runContentChecks,
17
+ overallStatus,
18
+ wordCount,
19
+ tagCount,
20
+ extractUrls,
21
+ hostOf,
22
+ DEFAULT_HYPE_WORDS,
23
+ type ContentCheck,
24
+ type ContentFields,
25
+ type ContentChecksConfig,
26
+ type CheckStatus,
27
+ type Band,
28
+ } from './checks';
29
+
30
+ export { ValidationChecklist } from './ValidationChecklist';
31
+ export type {
32
+ ValidationChecklistProps,
33
+ JudgeVerdict,
34
+ JudgeIssue,
35
+ ValidationOverride,
36
+ } from './ValidationChecklist';