@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.
- package/package.json +2 -1
- package/src/catalog/catalog.ts +18 -0
- package/src/components/document-editor/DocumentEditor.tsx +476 -0
- package/src/components/document-editor/__tests__/DocumentEditor.test.tsx +125 -0
- package/src/components/document-editor/__tests__/sections.test.ts +107 -0
- package/src/components/document-editor/index.ts +20 -0
- package/src/components/document-editor/sections.ts +170 -0
- package/src/components/document-editor/types.ts +32 -0
- package/src/components/index.ts +5 -0
- package/src/components/validation/ValidationChecklist.tsx +270 -0
- package/src/components/validation/__tests__/ValidationChecklist.test.tsx +84 -0
- package/src/components/validation/__tests__/checks.test.ts +191 -0
- package/src/components/validation/checks.ts +240 -0
- package/src/components/validation/index.ts +36 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sectionsFromRecord,
|
|
3
|
+
recordPatchFromSections,
|
|
4
|
+
readData,
|
|
5
|
+
toCamelKey,
|
|
6
|
+
type DocAttribute,
|
|
7
|
+
} from '../sections';
|
|
8
|
+
import type { DocSection } from '../types';
|
|
9
|
+
|
|
10
|
+
const attrs: DocAttribute[] = [
|
|
11
|
+
{ name: 'blog', dataType: 'longtext', label: 'Blog post' },
|
|
12
|
+
{ name: 'linkedin', dataType: 'text' },
|
|
13
|
+
{ name: 'seo', dataType: 'json' },
|
|
14
|
+
{ name: 'sources', dataType: 'json' },
|
|
15
|
+
{ name: 'status', dataType: 'enum' },
|
|
16
|
+
{ name: 'stage', dataType: 'enum' },
|
|
17
|
+
{ name: 'score', dataType: 'number' },
|
|
18
|
+
{ name: 'created_at', dataType: 'date' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('toCamelKey / readData', () => {
|
|
22
|
+
it('camelCases snake and kebab keys', () => {
|
|
23
|
+
expect(toCamelKey('meta_description')).toBe('metaDescription');
|
|
24
|
+
expect(toCamelKey('primary-keyword')).toBe('primaryKeyword');
|
|
25
|
+
expect(toCamelKey('blog')).toBe('blog');
|
|
26
|
+
});
|
|
27
|
+
it('reads camel form first, then raw', () => {
|
|
28
|
+
expect(readData({ metaDescription: 'camel' }, 'meta_description')).toBe('camel');
|
|
29
|
+
expect(readData({ meta_description: 'raw' }, 'meta_description')).toBe('raw');
|
|
30
|
+
expect(readData(undefined, 'x')).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('sectionsFromRecord', () => {
|
|
35
|
+
const data = {
|
|
36
|
+
blog: '# Hello\n\nBody',
|
|
37
|
+
linkedin: 'A short post',
|
|
38
|
+
seo: { meta_description: 'desc', primary_keyword: 'kw' },
|
|
39
|
+
sources: [{ url: 'https://a.com' }, 'https://b.com'],
|
|
40
|
+
status: 'draft',
|
|
41
|
+
score: 5,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
it('routes data types to the right kinds and skips enum/system/unknown', () => {
|
|
45
|
+
const sections = sectionsFromRecord(attrs, data);
|
|
46
|
+
const byKey = Object.fromEntries(sections.map((s) => [s.key, s]));
|
|
47
|
+
expect(byKey.blog.kind).toBe('markdown');
|
|
48
|
+
expect(byKey.linkedin.kind).toBe('text');
|
|
49
|
+
expect(byKey.seo.kind).toBe('structured');
|
|
50
|
+
expect(byKey.sources.kind).toBe('list');
|
|
51
|
+
// enum (status, stage), number (score), date/system (created_at) are skipped
|
|
52
|
+
expect(sections.map((s) => s.key).sort()).toEqual(['blog', 'linkedin', 'seo', 'sources']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('coerces values into each kind shape', () => {
|
|
56
|
+
const { blog, seo, sources } = Object.fromEntries(
|
|
57
|
+
sectionsFromRecord(attrs, data).map((s) => [s.key, s]),
|
|
58
|
+
) as Record<string, DocSection>;
|
|
59
|
+
expect(typeof blog.value).toBe('string');
|
|
60
|
+
expect(seo.value).toEqual({ meta_description: 'desc', primary_keyword: 'kw' });
|
|
61
|
+
// list: array of {url} objects + a bare string → url extracted from objects, strings kept
|
|
62
|
+
expect(sources.value).toEqual(['https://a.com', 'https://b.com']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('parses a newline-delimited string into a list', () => {
|
|
66
|
+
const s = sectionsFromRecord([{ name: 'sources', dataType: 'text' }], { sources: 'https://a.com\nhttps://b.com\n' });
|
|
67
|
+
expect(s[0].kind).toBe('list');
|
|
68
|
+
expect(s[0].value).toEqual(['https://a.com', 'https://b.com']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('honors label, camelCase reads, exclude, readOnlyKeys and includeKinds', () => {
|
|
72
|
+
const camelData = { blog: 'x', linkedIn: 'y' };
|
|
73
|
+
const a: DocAttribute[] = [
|
|
74
|
+
{ name: 'blog', dataType: 'longtext', label: 'Blog post' },
|
|
75
|
+
{ name: 'linked_in', dataType: 'text' },
|
|
76
|
+
];
|
|
77
|
+
const sections = sectionsFromRecord(a, camelData, { readOnlyKeys: ['blog'] });
|
|
78
|
+
expect(sections.find((s) => s.key === 'blog')!.label).toBe('Blog post');
|
|
79
|
+
expect(sections.find((s) => s.key === 'blog')!.readOnly).toBe(true);
|
|
80
|
+
// linked_in humanized + camelCase-aware read of `linkedIn`
|
|
81
|
+
expect(sections.find((s) => s.key === 'linked_in')!.label).toBe('Linked in');
|
|
82
|
+
expect(sections.find((s) => s.key === 'linked_in')!.value).toBe('y');
|
|
83
|
+
|
|
84
|
+
expect(sectionsFromRecord(attrs, data, { exclude: ['seo'] }).some((s) => s.key === 'seo')).toBe(false);
|
|
85
|
+
expect(
|
|
86
|
+
sectionsFromRecord(attrs, data, { includeKinds: ['markdown'] }).map((s) => s.kind),
|
|
87
|
+
).toEqual(['markdown']);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('recordPatchFromSections', () => {
|
|
92
|
+
it('produces a data patch keyed by section.key with normalized shapes', () => {
|
|
93
|
+
const sections: DocSection[] = [
|
|
94
|
+
{ key: 'blog', label: 'Blog', kind: 'markdown', value: '# hi' },
|
|
95
|
+
{ key: 'linkedin', label: 'LinkedIn', kind: 'text', value: 'post' },
|
|
96
|
+
{ key: 'seo', label: 'SEO', kind: 'structured', value: { meta_description: 'd', primary_keyword: 'k' } },
|
|
97
|
+
{ key: 'sources', label: 'Sources', kind: 'list', value: ['https://a.com', ''] },
|
|
98
|
+
];
|
|
99
|
+
const patch = recordPatchFromSections(sections);
|
|
100
|
+
expect(patch).toEqual({
|
|
101
|
+
blog: '# hi',
|
|
102
|
+
linkedin: 'post',
|
|
103
|
+
seo: { meta_description: 'd', primary_keyword: 'k' },
|
|
104
|
+
sources: ['https://a.com'],
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/document-editor — a generic, schema-driven, Google-Docs-like
|
|
3
|
+
* editor over typed document sub-sections (blog markdown + text + structured SEO
|
|
4
|
+
* + source lists). Presentational + controlled: bring your own persistence.
|
|
5
|
+
*
|
|
6
|
+
* Exposed as its own subpath (like ./blog and ./collection) because it pulls
|
|
7
|
+
* react-markdown for the markdown preview — kept out of the main components
|
|
8
|
+
* barrel so consumers that don't edit documents don't drag that dependency.
|
|
9
|
+
*/
|
|
10
|
+
export { DocumentEditor } from './DocumentEditor';
|
|
11
|
+
export type { DocumentEditorProps, SaveStatus } from './DocumentEditor';
|
|
12
|
+
export type { DocSection, DocSectionKind } from './types';
|
|
13
|
+
export {
|
|
14
|
+
sectionsFromRecord,
|
|
15
|
+
recordPatchFromSections,
|
|
16
|
+
readData,
|
|
17
|
+
toCamelKey,
|
|
18
|
+
type DocAttribute,
|
|
19
|
+
type SectionsFromRecordOptions,
|
|
20
|
+
} from './sections';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven mappers between an entity record and DocumentEditor sections.
|
|
3
|
+
*
|
|
4
|
+
* These make the editor generic (not OGMC-specific): an app hands the editor an
|
|
5
|
+
* entity type's attributes + its `data` blob and gets back typed
|
|
6
|
+
* {@link DocSection}s; on save it turns the edited sections back into a `data`
|
|
7
|
+
* patch to PATCH. Pure + framework-free (no React) so they're cheap to unit-test
|
|
8
|
+
* and reuse in a Node route.
|
|
9
|
+
*
|
|
10
|
+
* Blob convention mirrors `collection/data.ts`: a tenant API client camelCases
|
|
11
|
+
* response keys, so a snake_case attr `meta_description` may land as
|
|
12
|
+
* `metaDescription` — reads try the camel form first, then the raw key.
|
|
13
|
+
*/
|
|
14
|
+
import type { DocSection, DocSectionKind } from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal attribute shape the mapper needs — a structural subset of the entity
|
|
18
|
+
* schema `AttributeDef` (name + dataType, optional label/config), so callers can
|
|
19
|
+
* pass their existing attribute objects directly.
|
|
20
|
+
*/
|
|
21
|
+
export interface DocAttribute {
|
|
22
|
+
name: string;
|
|
23
|
+
/** Entity data type — `text` | `longtext` | `json` | `enum` | `number` | … */
|
|
24
|
+
dataType: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
config?: Record<string, unknown> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SectionsFromRecordOptions {
|
|
30
|
+
/** Attribute names to skip outright (in addition to the built-in system/status skips). */
|
|
31
|
+
exclude?: string[];
|
|
32
|
+
/** Mark these section keys read-only. */
|
|
33
|
+
readOnlyKeys?: string[];
|
|
34
|
+
/** Restrict output to these kinds (e.g. only `markdown` + `text`). */
|
|
35
|
+
includeKinds?: DocSectionKind[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** snake_case / kebab → camelCase (matches collection/data.ts). */
|
|
39
|
+
export function toCamelKey(name: string): string {
|
|
40
|
+
return name.replace(/[_-]+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** camelCase-aware read of a value out of a `data` blob. */
|
|
44
|
+
export function readData(data: Record<string, unknown> | undefined | null, name: string): unknown {
|
|
45
|
+
if (!data) return undefined;
|
|
46
|
+
return data[toCamelKey(name)] ?? data[name];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Attribute names that are never editable document sections. */
|
|
50
|
+
const SYSTEM_NAMES = new Set(['id', 'status', 'created_at', 'updated_at', 'createdat', 'updatedat', 'external_id']);
|
|
51
|
+
|
|
52
|
+
function humanize(name: string): string {
|
|
53
|
+
const spaced = name.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
|
|
54
|
+
return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A name like `sources`, `source_urls`, `references` → a list section. */
|
|
58
|
+
function looksLikeList(name: string): boolean {
|
|
59
|
+
return /\bsources?\b/i.test(name) || /^sources?/i.test(name) || /_sources?$/i.test(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Decide the section kind for an attribute, or `null` to skip it. */
|
|
63
|
+
function kindFor(attr: DocAttribute): DocSectionKind | null {
|
|
64
|
+
if (looksLikeList(attr.name)) return 'list';
|
|
65
|
+
switch (attr.dataType) {
|
|
66
|
+
case 'longtext':
|
|
67
|
+
return 'markdown';
|
|
68
|
+
case 'json':
|
|
69
|
+
return 'structured';
|
|
70
|
+
case 'text':
|
|
71
|
+
return 'text';
|
|
72
|
+
default:
|
|
73
|
+
// number/boolean/date/enum/etc. are not document-editable sections.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toStr(v: unknown): string {
|
|
79
|
+
return v == null ? '' : String(v);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Coerce an arbitrary stored value into the shape a `kind` expects. */
|
|
83
|
+
function coerceValue(kind: DocSectionKind, raw: unknown): unknown {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'markdown':
|
|
86
|
+
case 'text':
|
|
87
|
+
return toStr(raw);
|
|
88
|
+
case 'list':
|
|
89
|
+
if (Array.isArray(raw)) {
|
|
90
|
+
return raw
|
|
91
|
+
.map((el) => {
|
|
92
|
+
if (typeof el === 'string') return el.trim();
|
|
93
|
+
if (el && typeof el === 'object') {
|
|
94
|
+
const o = el as Record<string, unknown>;
|
|
95
|
+
return toStr(o.url ?? o.href ?? o.value ?? o.name).trim();
|
|
96
|
+
}
|
|
97
|
+
return toStr(el).trim();
|
|
98
|
+
})
|
|
99
|
+
.filter((s) => s.length > 0);
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw === 'string') {
|
|
102
|
+
return raw
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.map((s) => s.trim())
|
|
105
|
+
.filter((s) => s.length > 0);
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
case 'structured': {
|
|
109
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
110
|
+
const out: Record<string, string> = {};
|
|
111
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) out[k] = toStr(v);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map an entity type's attributes + its `data` blob → editable DocSections.
|
|
121
|
+
* Skips system/status/enum attributes; heuristically routes `longtext`→markdown,
|
|
122
|
+
* `json`→structured, `text`→text, and any `source(s)`-ish name→list.
|
|
123
|
+
*/
|
|
124
|
+
export function sectionsFromRecord(
|
|
125
|
+
attributes: DocAttribute[],
|
|
126
|
+
data: Record<string, unknown> | undefined | null,
|
|
127
|
+
opts: SectionsFromRecordOptions = {},
|
|
128
|
+
): DocSection[] {
|
|
129
|
+
const exclude = new Set((opts.exclude ?? []).map((n) => n.toLowerCase()));
|
|
130
|
+
const readOnly = new Set(opts.readOnlyKeys ?? []);
|
|
131
|
+
const sections: DocSection[] = [];
|
|
132
|
+
|
|
133
|
+
for (const attr of attributes) {
|
|
134
|
+
const lname = attr.name.toLowerCase();
|
|
135
|
+
if (exclude.has(lname) || SYSTEM_NAMES.has(lname)) continue;
|
|
136
|
+
// Enums are status/vocabulary fields, not document prose.
|
|
137
|
+
if (attr.dataType === 'enum') continue;
|
|
138
|
+
|
|
139
|
+
const kind = kindFor(attr);
|
|
140
|
+
if (!kind) continue;
|
|
141
|
+
if (opts.includeKinds && !opts.includeKinds.includes(kind)) continue;
|
|
142
|
+
|
|
143
|
+
const placeholder = attr.config && typeof attr.config.placeholder === 'string'
|
|
144
|
+
? (attr.config.placeholder as string)
|
|
145
|
+
: undefined;
|
|
146
|
+
|
|
147
|
+
sections.push({
|
|
148
|
+
key: attr.name,
|
|
149
|
+
label: attr.label || humanize(attr.name),
|
|
150
|
+
kind,
|
|
151
|
+
value: coerceValue(kind, readData(data, attr.name)),
|
|
152
|
+
placeholder,
|
|
153
|
+
readOnly: readOnly.has(attr.name) || undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return sections;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Turn edited sections back into a `data` patch object (keyed by section.key) to
|
|
162
|
+
* merge and PATCH. Values are normalized to their kind's canonical shape.
|
|
163
|
+
*/
|
|
164
|
+
export function recordPatchFromSections(sections: DocSection[]): Record<string, unknown> {
|
|
165
|
+
const patch: Record<string, unknown> = {};
|
|
166
|
+
for (const s of sections) {
|
|
167
|
+
patch[s.key] = coerceValue(s.kind, s.value);
|
|
168
|
+
}
|
|
169
|
+
return patch;
|
|
170
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section model for the generic DocumentEditor.
|
|
3
|
+
*
|
|
4
|
+
* A "document" here is a typed, multi-part content record — e.g. a marketing
|
|
5
|
+
* draft = blog markdown + LinkedIn text + SEO fields + sources. Each part is a
|
|
6
|
+
* {@link DocSection} with a `kind` that decides how it renders + edits. Kept
|
|
7
|
+
* framework-free so the pure mappers in `./sections` (and any Node/LLM caller)
|
|
8
|
+
* can import it without dragging React.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How a section renders + edits. */
|
|
12
|
+
export type DocSectionKind = 'markdown' | 'text' | 'structured' | 'list';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One editable part of a document.
|
|
16
|
+
* `value` is typed by `kind`:
|
|
17
|
+
* - `markdown` / `text` → `string`
|
|
18
|
+
* - `structured` → `Record<string, string>`
|
|
19
|
+
* - `list` → `string[]`
|
|
20
|
+
*/
|
|
21
|
+
export interface DocSection {
|
|
22
|
+
/** Stable id — usually the source attribute name (`blog`, `linkedin`, `seo`, `sources`). */
|
|
23
|
+
key: string;
|
|
24
|
+
/** Human label shown above the section. */
|
|
25
|
+
label: string;
|
|
26
|
+
kind: DocSectionKind;
|
|
27
|
+
/** The section's current value (shape depends on `kind`). */
|
|
28
|
+
value: unknown;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
/** This one section is read-only even when the editor is editable. */
|
|
31
|
+
readOnly?: boolean;
|
|
32
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|