@startsimpli/ui 0.4.51 → 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.
@@ -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';