@startsimpli/ui 0.4.107 → 0.4.109

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.107",
3
+ "version": "0.4.109",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -45,7 +45,7 @@
45
45
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
46
46
  "react": "^18.0.0 || ^19.0.0",
47
47
  "react-dom": "^18.0.0 || ^19.0.0",
48
- "@startsimpli/api": "^0.5.57",
48
+ "@startsimpli/api": "^0.5.58",
49
49
  "@startsimpli/auth": "^0.4.35",
50
50
  "@startsimpli/i18n": "^0.1.3"
51
51
  },
@@ -121,8 +121,8 @@
121
121
  "tailwindcss": "^3.4.19",
122
122
  "tsup": "^8.5.1",
123
123
  "typescript": "^6.0.3",
124
- "@startsimpli/realtime": "0.0.2",
125
- "@startsimpli/i18n": "0.1.3"
124
+ "@startsimpli/i18n": "0.1.3",
125
+ "@startsimpli/realtime": "0.0.2"
126
126
  },
127
127
  "keywords": [
128
128
  "ui",
@@ -0,0 +1,222 @@
1
+ /**
2
+ * bd startsim-4muob (c)/(d) — check policy resolves per content type and language.
3
+ *
4
+ * ONE BLOG BAND WAS APPLIED TO EVERY CONTENT TYPE. `ContentChecksConfig` has
5
+ * always taken per-call band overrides, but nothing ever resolved them from the
6
+ * `content_type` the call site had in hand, so a weekly brief and a long-form
7
+ * evergreen guide were judged against the same 400–500 words.
8
+ *
9
+ * THE NUMBERS IN THIS FILE ARE FIXTURES, and must stay fixtures. Which band a
10
+ * real content type gets is a TENANT's editorial decision (bd startsim-wncr6);
11
+ * the day one customer's house style is written down in @startsimpli/ui is the
12
+ * day every other tenant starts shipping it. What this package owns is the
13
+ * mechanism and the defaults — so these tests prove the mechanism with content
14
+ * types no tenant has.
15
+ */
16
+ import {
17
+ resolveCheckPolicy,
18
+ type CheckPolicySet,
19
+ } from '../policy';
20
+ import { runContentChecks } from '../checks';
21
+
22
+ const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
23
+ const blogBandOf = (set: CheckPolicySet, contentType?: string, language?: string) =>
24
+ resolveCheckPolicy(set, { contentType, language }).blogWords;
25
+
26
+ /** A fixture tenant's policy. Arbitrary numbers, deliberately not anyone's. */
27
+ const TENANT: CheckPolicySet = [
28
+ { contentType: 'short_form', policy: { blogWords: [300, 400] } },
29
+ { contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
30
+ { contentType: 'plain_form', policy: { blogWords: [600, 800] } },
31
+ ];
32
+
33
+ describe('(c) a content type resolves its own band', () => {
34
+ it('three content types yield three different blog bands', () => {
35
+ const short = blogBandOf(TENANT, 'short_form');
36
+ const long = blogBandOf(TENANT, 'long_form');
37
+ const plain = blogBandOf(TENANT, 'plain_form');
38
+
39
+ expect(short).toEqual([300, 400]);
40
+ expect(long).toEqual([1200, 2000]);
41
+ expect(plain).toEqual([600, 800]);
42
+ // Name the property the bug violated: they are not all the same band.
43
+ expect(new Set([short, long, plain].map((b) => String(b))).size).toBe(3);
44
+ });
45
+
46
+ it('the resolved band is what the check actually judges against', () => {
47
+ // 500 words: in band for the long-form type's floor? No — under it. In band
48
+ // for plain_form? No — under. The point is that ONE draft gets three
49
+ // different verdicts, which is exactly what a single shared band cannot do.
50
+ const draft = { blog: words(500) };
51
+ const verdict = (contentType: string) =>
52
+ runContentChecks(draft, resolveCheckPolicy(TENANT, { contentType })).find(
53
+ (c) => c.id === 'blog-words',
54
+ )!;
55
+
56
+ expect(verdict('short_form').status).toBe('warn'); // 100 over 300–400
57
+ expect(verdict('short_form').detail).toBe('500 words — 100 over (300–400)');
58
+ expect(verdict('long_form').status).toBe('warn'); // 700 under 1200–2000
59
+ expect(verdict('plain_form').status).toBe('warn'); // 100 under 600–800
60
+ });
61
+ });
62
+
63
+ describe('(d) an unknown or absent content type falls back — it never skips', () => {
64
+ const DEFAULT_BLOG_BAND = [400, 500];
65
+
66
+ it('an empty policy set resolves to the package defaults', () => {
67
+ expect(resolveCheckPolicy([], { contentType: 'anything' })).toEqual({});
68
+ const c = runContentChecks({ blog: words(450) }, resolveCheckPolicy([], {}));
69
+ expect(c.find((x) => x.id === 'blog-words')).toMatchObject({ status: 'pass' });
70
+ });
71
+
72
+ it('an unknown content type resolves exactly what an absent one does', () => {
73
+ expect(blogBandOf(TENANT, 'a_type_nobody_declared')).toEqual(blogBandOf(TENANT, undefined));
74
+ });
75
+
76
+ it('an unknown content type still RUNS the check, against today’s defaults', () => {
77
+ // The failure this guards: "we don't know your type, so we won't check" —
78
+ // a skipped check reads as a passing one on the rail (bd startsim-wncr6).
79
+ const checks = runContentChecks(
80
+ { blog: words(506) },
81
+ resolveCheckPolicy(TENANT, { contentType: 'a_type_nobody_declared' }),
82
+ );
83
+ const blog = checks.find((c) => c.id === 'blog-words');
84
+ expect(blog).toBeDefined();
85
+ expect(blog!.status).toBe('warn');
86
+ // Judged against the shared defaults, unchanged from before this bead.
87
+ expect(blog!.detail).toBe(`506 words — 6 over (${DEFAULT_BLOG_BAND[0]}–${DEFAULT_BLOG_BAND[1]})`);
88
+ });
89
+
90
+ it('does not throw on a null-ish or empty content type', () => {
91
+ expect(() => resolveCheckPolicy(TENANT, {})).not.toThrow();
92
+ expect(() => resolveCheckPolicy(TENANT, { contentType: '' })).not.toThrow();
93
+ expect(() => resolveCheckPolicy(TENANT, { contentType: undefined })).not.toThrow();
94
+ });
95
+ });
96
+
97
+ describe('the language dimension (bd startsim-wn2p.29 needs this seam, not its numbers)', () => {
98
+ const BILINGUAL: CheckPolicySet = [
99
+ { contentType: 'short_form', policy: { blogWords: [300, 400] } },
100
+ { language: 'zz', policy: { blogWords: [900, 1000] } },
101
+ { contentType: 'short_form', language: 'zz', policy: { blogWords: [700, 800] } },
102
+ ];
103
+
104
+ it('a language rule applies across content types', () => {
105
+ expect(blogBandOf(BILINGUAL, 'other_form', 'zz')).toEqual([900, 1000]);
106
+ });
107
+
108
+ it('matches on the primary subtag, so zz-XX picks up the zz rule', () => {
109
+ expect(blogBandOf(BILINGUAL, 'other_form', 'zz-XX')).toEqual([900, 1000]);
110
+ });
111
+
112
+ it('a rule naming BOTH beats a rule naming one, whatever the declaration order', () => {
113
+ expect(blogBandOf(BILINGUAL, 'short_form', 'zz')).toEqual([700, 800]);
114
+ // and again with the two-matcher rule declared FIRST, where plain
115
+ // last-one-wins would have let the single-matcher rules override it
116
+ const reversed: CheckPolicySet = [...BILINGUAL].reverse();
117
+ expect(blogBandOf(reversed, 'short_form', 'zz')).toEqual([700, 800]);
118
+ });
119
+
120
+ it('two SINGLE-dimension rules rank equal, so the tenant’s declaration order decides', () => {
121
+ // The case the tenant will actually hit: a Chinese evergreen matches both a
122
+ // content-type rule and a language rule. They do not combine — one wins
123
+ // outright — and this package must not be the thing that decides which of a
124
+ // tenant's two editorial axes outranks the other.
125
+ const typeThenLang: CheckPolicySet = [
126
+ { contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
127
+ { language: 'zz', policy: { blogWords: [500, 620] } },
128
+ ];
129
+ expect(blogBandOf(typeThenLang, 'long_form', 'zz')).toEqual([500, 620]);
130
+
131
+ const langThenType: CheckPolicySet = [...typeThenLang].reverse();
132
+ expect(blogBandOf(langThenType, 'long_form', 'zz')).toEqual([1200, 2000]);
133
+ });
134
+
135
+ it('a combination gets its own band only when the tenant declares the combination', () => {
136
+ // Documented consequence: two independent rules never blend into a third.
137
+ // The right band for a Chinese evergreen is neither the English evergreen
138
+ // number nor the Chinese brief number (bd startsim-wn2p.29).
139
+ const withCross: CheckPolicySet = [
140
+ { contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
141
+ { language: 'zz', policy: { blogWords: [500, 620] } },
142
+ { contentType: 'long_form', language: 'zz', policy: { blogWords: [1500, 2400] } },
143
+ ];
144
+ expect(blogBandOf(withCross, 'long_form', 'zz')).toEqual([1500, 2400]);
145
+ // …and the single-dimension rules still answer on their own.
146
+ expect(blogBandOf(withCross, 'long_form', 'en')).toEqual([1200, 2000]);
147
+ expect(blogBandOf(withCross, 'other_form', 'zz')).toEqual([500, 620]);
148
+ });
149
+
150
+ it('a language nobody declared falls back to the content type’s band', () => {
151
+ expect(blogBandOf(BILINGUAL, 'short_form', 'qq')).toEqual([300, 400]);
152
+ });
153
+ });
154
+
155
+ describe('merge semantics — spelled out because an unspoken one skips checks silently', () => {
156
+ it('a more specific rule overrides only the keys it names', () => {
157
+ const set: CheckPolicySet = [
158
+ { policy: { blogWords: [100, 200], headlineWords: [3, 4] } },
159
+ { contentType: 'short_form', policy: { blogWords: [300, 400] } },
160
+ ];
161
+ const resolved = resolveCheckPolicy(set, { contentType: 'short_form' });
162
+ expect(resolved.blogWords).toEqual([300, 400]);
163
+ // headlineWords was never mentioned by the specific rule, so it survives.
164
+ expect(resolved.headlineWords).toEqual([3, 4]);
165
+ });
166
+
167
+ it('an inherited `checks` allow-list CARRIES THROUGH a rule that does not mention it', () => {
168
+ // Decided, not accidental: a rule leaves untouched every key it does not
169
+ // name. So a tenant narrowing the check set tenant-wide keeps that narrowing
170
+ // when it later adds a per-type band. To widen it again, say so explicitly.
171
+ const set: CheckPolicySet = [
172
+ { policy: { checks: ['blog-words'] } },
173
+ { contentType: 'short_form', policy: { blogWords: [300, 400] } },
174
+ ];
175
+ expect(resolveCheckPolicy(set, { contentType: 'short_form' }).checks).toEqual(['blog-words']);
176
+ });
177
+
178
+ it('a declared `checks` list REPLACES an inherited one wholesale, it does not union', () => {
179
+ const set: CheckPolicySet = [
180
+ { policy: { checks: ['blog-words'] } },
181
+ { contentType: 'short_form', policy: { checks: ['headline-words'] } },
182
+ ];
183
+ expect(resolveCheckPolicy(set, { contentType: 'short_form' }).checks).toEqual(['headline-words']);
184
+ });
185
+ });
186
+
187
+ describe('which checks run is configuration too (bd startsim-wncr6 part 2)', () => {
188
+ const fields = { blog: words(450), headline: words(10), tags: 'a,b,c,d,e' };
189
+
190
+ it('with no allow-list, every check the fields support still runs', () => {
191
+ const ids = runContentChecks(fields, resolveCheckPolicy([], {})).map((c) => c.id).sort();
192
+ expect(ids).toEqual(['blog-words', 'headline-words', 'no-hype', 'tags-count']);
193
+ });
194
+
195
+ it('a tenant that does not care about word count can say so', () => {
196
+ // Quinn, 2026-08-25: "Is word count a check that we even care about based on
197
+ // what we're doing? … They don't have to be blog article checks."
198
+ const set: CheckPolicySet = [{ policy: { checks: ['no-hype', 'tags-count'] } }];
199
+ const ids = runContentChecks(fields, resolveCheckPolicy(set, {})).map((c) => c.id).sort();
200
+ expect(ids).toEqual(['no-hype', 'tags-count']);
201
+ });
202
+
203
+ it('the allow-list is per content type, like the bands', () => {
204
+ const set: CheckPolicySet = [
205
+ { policy: { checks: ['blog-words', 'headline-words', 'no-hype', 'tags-count'] } },
206
+ { contentType: 'short_form', policy: { checks: ['headline-words'] } },
207
+ ];
208
+ expect(runContentChecks(fields, resolveCheckPolicy(set, { contentType: 'short_form' })).map((c) => c.id))
209
+ .toEqual(['headline-words']);
210
+ expect(runContentChecks(fields, resolveCheckPolicy(set, { contentType: 'other_form' })).map((c) => c.id).sort())
211
+ .toEqual(['blog-words', 'headline-words', 'no-hype', 'tags-count']);
212
+ });
213
+
214
+ it('an empty allow-list runs NOTHING, and is distinct from an absent one', () => {
215
+ // `[]` is a tenant saying "no deterministic checks", which is a real answer.
216
+ // `undefined` is "you decide", which means all of them. They must not be the
217
+ // same value — the approved-sources check learned that the hard way
218
+ // (bd startsim-4ipm: `approvedHosts: []` failed every host on earth).
219
+ expect(runContentChecks(fields, { checks: [] })).toEqual([]);
220
+ expect(runContentChecks(fields, {}).length).toBeGreaterThan(0);
221
+ });
222
+ });
@@ -44,7 +44,7 @@ describe('runContentChecks — field presence', () => {
44
44
  describe('word-count bands', () => {
45
45
  it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => {
46
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' });
47
+ expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words — 100 under (400–500)' });
48
48
  expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn');
49
49
  // inclusive edges
50
50
  expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass');
@@ -341,3 +341,56 @@ describe('source-overlap check', () => {
341
341
  expect(c!.status).toBe('pass');
342
342
  });
343
343
  });
344
+
345
+ describe('bd startsim-4muob (a)/(b) — a band check says which way it missed', () => {
346
+ /**
347
+ * THE MEETING, 2026-08-25. The rail read "Blog word count — 506 words" as a
348
+ * notice. In the room Quinn guessed the band was a ~800-word MINIMUM and
349
+ * offered to lower it; Malin pushed back that briefs are 400–500. The band was
350
+ * already [400, 500] and the draft was SIX WORDS OVER THE TOP. Nobody present
351
+ * could tell which direction it had missed — including the author of the check.
352
+ *
353
+ * A flag a reviewer cannot act on spends the goodwill the rail runs on.
354
+ */
355
+ it('(a) states the overshoot and the band when the count is OVER', () => {
356
+ expect(find(runContentChecks({ blog: words(506) }), 'blog-words')).toMatchObject({
357
+ status: 'warn',
358
+ detail: '506 words — 6 over (400–500)',
359
+ });
360
+ });
361
+
362
+ it('(a) states the shortfall and the band when the count is UNDER', () => {
363
+ expect(find(runContentChecks({ blog: words(312) }), 'blog-words')).toMatchObject({
364
+ status: 'warn',
365
+ detail: '312 words — 88 under (400–500)',
366
+ });
367
+ });
368
+
369
+ it('(b) an in-band check does not editorialise — it is still a bare count', () => {
370
+ // Green rows are read at a glance and never acted on. Spending the narrow
371
+ // rail column on "450 words — in the 400–500 band" says nothing new.
372
+ expect(find(runContentChecks({ blog: words(450) }), 'blog-words')!.detail).toBe('450 words');
373
+ expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.detail).toBe('400 words');
374
+ expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.detail).toBe('500 words');
375
+ });
376
+
377
+ it('(a) applies to EVERY band check, not just the blog one', () => {
378
+ // headline-words, linkedin-words, tags-count and meta-length all ran the
379
+ // same bandStatus + bare-detail pattern, so all four missed the same way.
380
+ expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.detail)
381
+ .toBe('5 words — 3 under (8–12)');
382
+ expect(find(runContentChecks({ linkedin: words(300) }), 'linkedin-words')!.detail)
383
+ .toBe('300 words — 50 over (150–250)');
384
+ expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.detail)
385
+ .toBe('2 tags — 2 under (4–6)');
386
+ expect(find(runContentChecks({ metaDescription: 'x'.repeat(200) }), 'meta-length')!.detail)
387
+ .toBe('200 chars — 40 over (150–160)');
388
+ });
389
+
390
+ it('(a) reports the band it was ACTUALLY judged against, not the default', () => {
391
+ // The bug's other half: a reviewer told "(400–500)" when the tenant configured
392
+ // something else would be misled twice over.
393
+ expect(find(runContentChecks({ blog: words(900) }, { blogWords: [1000, 1200] }), 'blog-words')!.detail)
394
+ .toBe('900 words — 100 under (1000–1200)');
395
+ });
396
+ });
@@ -69,12 +69,51 @@ export interface ContentFields {
69
69
  /** An inclusive `[min, max]` band. */
70
70
  export type Band = [min: number, max: number];
71
71
 
72
+ /**
73
+ * Every check this package implements, in the order `runContentChecks` emits
74
+ * them. Exported so a tenant can name the ones it wants (see `checks` below)
75
+ * and so a typo in that list is a compile error rather than a silently empty
76
+ * rail.
77
+ *
78
+ * A tenant's OWN checks are not in here and do not belong in here: they are
79
+ * computed by the tenant and appended to the returned array, the way
80
+ * marketing-agents already appends its approved-source stand-in.
81
+ */
82
+ export const CONTENT_CHECK_IDS = [
83
+ 'headline-words',
84
+ 'blog-words',
85
+ 'linkedin-words',
86
+ 'tags-count',
87
+ 'meta-length',
88
+ 'link-placeholder',
89
+ 'no-hype',
90
+ 'approved-sources',
91
+ 'source-overlap',
92
+ ] as const;
93
+
94
+ /** The id of a check implemented by this package. */
95
+ export type ContentCheckId = (typeof CONTENT_CHECK_IDS)[number];
96
+
72
97
  /**
73
98
  * Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`:
74
99
  * the source allow-list is deliberately app-injected (no hardcoded list here),
75
100
  * so the approved-sources check is skipped entirely when it is omitted.
76
101
  */
77
102
  export interface ContentChecksConfig {
103
+ /**
104
+ * WHICH checks run. Omitted = every check the supplied fields support (the
105
+ * behaviour before bd startsim-wncr6, and the right default for a caller that
106
+ * has no opinion). Present = exactly these, so a tenant can answer "is word
107
+ * count a check we even care about?" with a list instead of a fork.
108
+ *
109
+ * `[]` runs nothing and is deliberately DISTINCT from omitting the key — the
110
+ * approved-source check learned that lesson the expensive way (bd
111
+ * startsim-4ipm: `approvedHosts: []` is "fail every host", not "skip").
112
+ *
113
+ * Normally resolved per content type and language by `resolveCheckPolicy`
114
+ * (./policy) rather than written out here.
115
+ */
116
+ checks?: readonly ContentCheckId[];
78
117
  /** Blog word-count band (default 400–500). */
79
118
  blogWords?: Band;
80
119
  /** LinkedIn word-count band (default 150–250). */
@@ -227,6 +266,30 @@ function bandStatus(n: number, [min, max]: Band): CheckStatus {
227
266
  return n >= min && n <= max ? 'pass' : 'warn';
228
267
  }
229
268
 
269
+ /**
270
+ * A band check's `detail`, which now says which way it missed (bd startsim-ztlz7).
271
+ *
272
+ * WHY. On 2026-08-25 the quality rail showed "Blog word count — 506 words" as a
273
+ * notice in front of a customer. The band was already [400, 500] and the draft
274
+ * was SIX WORDS OVER THE TOP — but the row said only "506 words", so the guess in
275
+ * the room was that the band was an ~800-word MINIMUM, and an offer was made to
276
+ * lower it. The author of the check could not read his own check either. A
277
+ * number without its band is not a finding; it is a number.
278
+ *
279
+ * IN-BAND STAYS BARE. A green row is read at a glance and never acted on;
280
+ * spending the rail's narrow column on "450 words — in the 400–500 band" tells a
281
+ * reviewer nothing they were about to do something about.
282
+ *
283
+ * The band printed is the band the check was ACTUALLY judged against, so a tenant
284
+ * running its own numbers (./policy) is never shown the package default.
285
+ */
286
+ function bandDetail(n: number, [min, max]: Band, unit: string): string {
287
+ const count = `${n} ${unit}`;
288
+ if (n < min) return `${count} — ${min - n} under (${min}–${max})`;
289
+ if (n > max) return `${count} — ${n - max} over (${min}–${max})`;
290
+ return count;
291
+ }
292
+
230
293
  /**
231
294
  * `locations` for a whole-field problem (word/char counts, a missing
232
295
  * placeholder): the field itself is the finding, so there is no span to
@@ -259,6 +322,7 @@ export function hostOf(url: string): string {
259
322
  export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] {
260
323
  const checks: ContentCheck[] = [];
261
324
  const {
325
+ checks: enabled,
262
326
  blogWords = DEFAULTS.blogWords,
263
327
  linkedinWords = DEFAULTS.linkedinWords,
264
328
  headlineWords = DEFAULTS.headlineWords,
@@ -273,39 +337,52 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
273
337
  overlapFailRatio = 0.25,
274
338
  } = config;
275
339
 
340
+ /**
341
+ * Is this check switched on? No allow-list = all of them, so every existing
342
+ * caller is unaffected.
343
+ *
344
+ * Note what this does NOT do: it never drops a check because something about
345
+ * the draft was unrecognised. A check the tenant did not ask for is absent
346
+ * because the tenant said so; a check that could not be COMPUTED must be
347
+ * surfaced by the caller as its own not-checked row, never left out (bd
348
+ * startsim-4ipm, and the reason `resolveCheckPolicy` falls back rather than
349
+ * skips on an unknown content type).
350
+ */
351
+ const runs = (id: ContentCheckId): boolean => enabled === undefined || enabled.includes(id);
352
+
276
353
  // Word-count bands.
277
- if (fields.headline !== undefined) {
354
+ if (runs('headline-words') && fields.headline !== undefined) {
278
355
  const n = wordCount(fields.headline);
279
356
  const status = bandStatus(n, headlineWords);
280
- checks.push({ id: 'headline-words', label: 'Headline length', status, detail: `${n} words`, ...fieldLocation(status, 'headline') });
357
+ checks.push({ id: 'headline-words', label: 'Headline length', status, detail: bandDetail(n, headlineWords, 'words'), ...fieldLocation(status, 'headline') });
281
358
  }
282
- if (fields.blog !== undefined) {
359
+ if (runs('blog-words') && fields.blog !== undefined) {
283
360
  const n = wordCount(fields.blog);
284
361
  const status = bandStatus(n, blogWords);
285
- checks.push({ id: 'blog-words', label: 'Blog word count', status, detail: `${n} words`, ...fieldLocation(status, 'blog') });
362
+ checks.push({ id: 'blog-words', label: 'Blog word count', status, detail: bandDetail(n, blogWords, 'words'), ...fieldLocation(status, 'blog') });
286
363
  }
287
- if (fields.linkedin !== undefined) {
364
+ if (runs('linkedin-words') && fields.linkedin !== undefined) {
288
365
  const n = wordCount(fields.linkedin);
289
366
  const status = bandStatus(n, linkedinWords);
290
- checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail: `${n} words`, ...fieldLocation(status, 'linkedin') });
367
+ checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail: bandDetail(n, linkedinWords, 'words'), ...fieldLocation(status, 'linkedin') });
291
368
  }
292
369
 
293
370
  // Tag count.
294
- if (fields.tags !== undefined) {
371
+ if (runs('tags-count') && fields.tags !== undefined) {
295
372
  const n = tagCount(fields.tags);
296
373
  const status = bandStatus(n, tagsCount);
297
- checks.push({ id: 'tags-count', label: 'Tag count', status, detail: `${n} tags`, ...fieldLocation(status, 'tags') });
374
+ checks.push({ id: 'tags-count', label: 'Tag count', status, detail: bandDetail(n, tagsCount, 'tags'), ...fieldLocation(status, 'tags') });
298
375
  }
299
376
 
300
377
  // Meta description length (characters).
301
- if (fields.metaDescription !== undefined) {
378
+ if (runs('meta-length') && fields.metaDescription !== undefined) {
302
379
  const len = fields.metaDescription.trim().length;
303
380
  const status = bandStatus(len, metaChars);
304
- checks.push({ id: 'meta-length', label: 'Meta description length', status, detail: `${len} chars`, ...fieldLocation(status, 'metaDescription') });
381
+ checks.push({ id: 'meta-length', label: 'Meta description length', status, detail: bandDetail(len, metaChars, 'chars'), ...fieldLocation(status, 'metaDescription') });
305
382
  }
306
383
 
307
384
  // `[link]` placeholder present in the LinkedIn post.
308
- if (requireLink && fields.linkedin !== undefined) {
385
+ if (requireLink && runs('link-placeholder') && fields.linkedin !== undefined) {
309
386
  const has = fields.linkedin.includes('[link]');
310
387
  const status: CheckStatus = has ? 'pass' : 'warn';
311
388
  checks.push({
@@ -326,7 +403,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
326
403
  ['linkedin', fields.linkedin],
327
404
  ];
328
405
  const hasProse = proseFields.some(([, text]) => text !== undefined);
329
- if (hasProse) {
406
+ if (hasProse && runs('no-hype')) {
330
407
  const haystack = proseFields
331
408
  .map(([, text]) => text)
332
409
  .filter((s): s is string => typeof s === 'string')
@@ -354,7 +431,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
354
431
  }
355
432
 
356
433
  // Approved sources — only when the app supplied an allow-list.
357
- if (approvedHosts !== undefined) {
434
+ if (approvedHosts !== undefined && runs('approved-sources')) {
358
435
  const approved = approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, ''));
359
436
  /**
360
437
  * A host is approved when it IS an approved domain or is a SUBDOMAIN of one.
@@ -400,7 +477,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
400
477
  // Verbatim overlap — the anti-plagiarism guardrail. Only when the caller supplied
401
478
  // the cited source article TEXT. Scans blog + linkedin against the sources and
402
479
  // reports the WORST offender (longest copied run / highest copied fraction).
403
- if (sourceTexts !== undefined && sourceTexts.length > 0) {
480
+ if (sourceTexts !== undefined && sourceTexts.length > 0 && runs('source-overlap')) {
404
481
  const targets: Array<[keyof ContentFields, string | undefined]> = [
405
482
  ['blog', fields.blog],
406
483
  ['linkedin', fields.linkedin],
@@ -20,6 +20,8 @@ export {
20
20
  extractUrls,
21
21
  hostOf,
22
22
  DEFAULT_HYPE_WORDS,
23
+ CONTENT_CHECK_IDS,
24
+ type ContentCheckId,
23
25
  type ContentCheck,
24
26
  type CheckLocation,
25
27
  type ContentFields,
@@ -30,6 +32,19 @@ export {
30
32
 
31
33
  export { verbatimOverlap, normWords, type OverlapResult } from './overlap';
32
34
 
35
+ /**
36
+ * Which checks run and with what bands, resolved per content type and language.
37
+ * A tenant declares the policy; this package keeps the implementations and the
38
+ * defaults (bd startsim-wncr6).
39
+ */
40
+ export {
41
+ resolveCheckPolicy,
42
+ type CheckPolicy,
43
+ type CheckPolicyRule,
44
+ type CheckPolicySet,
45
+ type CheckContext,
46
+ } from './policy';
47
+
33
48
  export { ValidationChecklist } from './ValidationChecklist';
34
49
  export type {
35
50
  ValidationChecklistProps,
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Which checks run, and against what thresholds — resolved per content type and
3
+ * per language (bd startsim-wncr6). Pure + framework-free, like `checks.ts`.
4
+ *
5
+ * ── THE BUG THIS EXISTS FOR ──────────────────────────────────────────────────
6
+ * `ContentChecksConfig` has always taken per-call band overrides, but nothing
7
+ * ever resolved them from the `content_type` the call site had in hand. So a
8
+ * weekly brief ("short, current news") and a long-form evergreen guide ("much
9
+ * more in-depth, much bigger scope") were both judged against one 400–500 word
10
+ * band, and the same single band was applied to Chinese translations, where a
11
+ * faithful rendering of a 507-word English brief segments to ~609 words and
12
+ * therefore fails forever (bd startsim-wn2p.29).
13
+ *
14
+ * ── THE REQUIREMENT, WHICH IS BIGGER THAN THRESHOLDS ─────────────────────────
15
+ * Quinn, 2026-08-25: "Is word count a check that we even care about based on
16
+ * what we're doing? Or is it something else — rank how sensational we think it
17
+ * is, or how many source data pieces we have. They don't have to be blog
18
+ * article checks. They can be OGMC-specific checks that you guys want."
19
+ *
20
+ * So the CHECK SET is configuration too, not only its numbers. A rule may name
21
+ * `checks` as well as bands; this package supplies the implementations and the
22
+ * defaults, a tenant supplies the policy.
23
+ *
24
+ * ── TWO THINGS THIS FILE MUST NOT BECOME ─────────────────────────────────────
25
+ * 1. A HOME FOR ONE CUSTOMER'S EDITORIAL STANDARDS. There is not a single
26
+ * content_type value, locale or band in this file, and there must never be.
27
+ * @startsimpli/ui ships to every tenant; OGMC's `weekly_brief` band is
28
+ * OGMC's, declared in marketing-agents-web. The mirror of that rule is just
29
+ * as binding: a tenant must not fork a private copy of the CHECKS either —
30
+ * bd startsim-wn2p.28 cost four PRs because one word count had been written
31
+ * in four places.
32
+ * 2. A WAY TO SKIP A CHECK BY ACCIDENT. An unknown content type resolves to
33
+ * whatever the less specific rules said, ending at this package's defaults.
34
+ * It never resolves to "no check": a check that silently vanishes reads on
35
+ * the rail exactly like a check that passed. The sibling failure mode, from
36
+ * the same epic, is a band that CANNOT be satisfied by correct output — a
37
+ * permanent red trains reviewers to ignore the whole rail, which is why the
38
+ * language dimension exists rather than one band for every script.
39
+ */
40
+ import type { ContentChecksConfig } from './checks';
41
+
42
+ /**
43
+ * A tenant's standing policy for one situation: which checks run, and their
44
+ * thresholds.
45
+ *
46
+ * `approvedHosts` and `sourceTexts` are deliberately NOT part of a policy —
47
+ * they are facts about the draft in front of you (the tenant's live source
48
+ * records, the cited articles' text), resolved per render and merged in by the
49
+ * caller. Standing policy and per-draft evidence are different things and a
50
+ * tenant that confuses them ends up caching one as the other.
51
+ */
52
+ export type CheckPolicy = Omit<ContentChecksConfig, 'approvedHosts' | 'sourceTexts'>;
53
+
54
+ /**
55
+ * One rule: a policy plus the situations it applies to. Omit both matchers and
56
+ * the rule is the tenant-wide baseline.
57
+ *
58
+ * Matching is deliberately dumb — exact `content_type` values and a language
59
+ * subtag — because the values are the tenant's own schema enum and its own
60
+ * locale codes. This package knows none of them and must not pretend to.
61
+ */
62
+ export interface CheckPolicyRule {
63
+ /**
64
+ * The `content_type` value(s) this rule applies to, matched exactly (after a
65
+ * trim) against the tenant's own enum. Omitted = any content type.
66
+ */
67
+ contentType?: string | readonly string[];
68
+ /**
69
+ * The language(s) this rule applies to, as BCP-47 tags matched on the primary
70
+ * subtag: a rule for `zh` applies to `zh`, `zh-CN` and `zh-Hans-CN`; a rule
71
+ * for `zh-Hans` applies to `zh-Hans-CN` but not to bare `zh`. Case-insensitive.
72
+ * Omitted = any language.
73
+ */
74
+ language?: string | readonly string[];
75
+ /** What to apply when this rule matches. */
76
+ policy: CheckPolicy;
77
+ }
78
+
79
+ /** A tenant's whole policy, as an ordered list of rules. */
80
+ export type CheckPolicySet = readonly CheckPolicyRule[];
81
+
82
+ /** What is being checked, as far as policy is concerned. */
83
+ export interface CheckContext {
84
+ /** The draft's `content_type`, straight off the record. Unknown values are fine. */
85
+ contentType?: string | null;
86
+ /** The draft's language as a BCP-47 tag (`en`, `zh-CN`). Unknown values are fine. */
87
+ language?: string | null;
88
+ }
89
+
90
+ /** Normalise a matcher to a list of non-empty trimmed strings; null = "matches anything". */
91
+ function matcherList(value: string | readonly string[] | undefined): string[] | null {
92
+ if (value === undefined) return null;
93
+ const list = Array.isArray(value) ? [...value] : [value as string];
94
+ return list.map((v) => String(v).trim()).filter(Boolean);
95
+ }
96
+
97
+ /**
98
+ * A language tag matches a rule when it IS the rule's tag or is a more specific
99
+ * form of it. The hyphen is load-bearing for the same reason it is in the
100
+ * approved-host check: without it `zho` would match a rule written for `zh`.
101
+ */
102
+ function languageMatches(ruleTag: string, contextTag: string): boolean {
103
+ const rule = ruleTag.toLowerCase();
104
+ const ctx = contextTag.toLowerCase();
105
+ return ctx === rule || ctx.startsWith(`${rule}-`);
106
+ }
107
+
108
+ /** Does this rule apply? A matcher the context cannot answer simply does not match. */
109
+ function ruleApplies(rule: CheckPolicyRule, context: CheckContext): boolean {
110
+ const types = matcherList(rule.contentType);
111
+ if (types !== null) {
112
+ const contentType = (context.contentType ?? '').trim();
113
+ if (!contentType || !types.includes(contentType)) return false;
114
+ }
115
+ const languages = matcherList(rule.language);
116
+ if (languages !== null) {
117
+ const language = (context.language ?? '').trim();
118
+ if (!language || !languages.some((tag) => languageMatches(tag, language))) return false;
119
+ }
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * How specific a rule is: simply HOW MANY dimensions it names. Naming both
125
+ * outranks naming either, so a tenant can write its rules in whatever order
126
+ * reads best and still get the answer it meant.
127
+ *
128
+ * The two single-dimension rules rank EQUAL on purpose. Ranking content type
129
+ * above language (or the reverse) would mean that, for a Chinese evergreen
130
+ * draft, a rule for `lead_magnet` silently swallowed a rule for `zh` — and this
131
+ * package has no business deciding which of a tenant's two editorial axes is the
132
+ * more important one. Between equals the later declaration wins, so the tenant
133
+ * says which, in the order it writes them.
134
+ */
135
+ function specificity(rule: CheckPolicyRule): number {
136
+ return (rule.contentType !== undefined ? 1 : 0) + (rule.language !== undefined ? 1 : 0);
137
+ }
138
+
139
+ /**
140
+ * Resolve a tenant's policy for one draft into the config `runContentChecks`
141
+ * takes. Pure. Never throws on an unrecognised content type or language.
142
+ *
143
+ * MERGE SEMANTICS, spelled out because leaving them unspoken is how a check goes
144
+ * missing: matching rules are applied least-specific first, and each one
145
+ * overrides ONLY the keys it names. A key a rule does not mention keeps whatever
146
+ * a less specific rule set, ending at this package's defaults.
147
+ *
148
+ * TWO SINGLE-DIMENSION RULES DO NOT COMBINE, and this is the sharp edge. A rule
149
+ * for content type `lead_magnet` and a rule for language `zh` both match a
150
+ * Chinese evergreen draft, rank equal, and so the LATER one wins outright — the
151
+ * other's bands are not blended in. A tenant that wants a distinct number for a
152
+ * combination must declare that combination, which is what the two-matcher rule
153
+ * is for. Blending would be worse, not better: the right band for a Chinese
154
+ * evergreen is neither the English evergreen number nor the Chinese brief number,
155
+ * and quietly synthesising a third from them is how you get a band that correct
156
+ * output cannot satisfy (bd startsim-wn2p.29).
157
+ *
158
+ * That cuts both ways on the `checks` allow-list, and this is the deliberate
159
+ * half: a tenant that narrows the check set tenant-wide KEEPS that narrowing in
160
+ * a per-type rule that only adjusts a band. To widen it again, the specific rule
161
+ * says so — and when it does, its list REPLACES the inherited one outright
162
+ * rather than being unioned with it, so "these are the checks for evergreen" is
163
+ * readable as exactly that.
164
+ *
165
+ * @example
166
+ * // In the TENANT, never here — these are its enum values and its numbers.
167
+ * const POLICY: CheckPolicySet = [
168
+ * { contentType: 'weekly_brief', policy: { blogWords: [400, 500] } },
169
+ * { language: 'zh', policy: { metaChars: [60, 80] } },
170
+ * // A combination needs its own rule — the two above would not merge into it.
171
+ * { contentType: 'weekly_brief', language: 'zh', policy: { blogWords: [500, 620] } },
172
+ * ];
173
+ * runContentChecks(fields, {
174
+ * ...resolveCheckPolicy(POLICY, { contentType, language }),
175
+ * ...approvedSourceCheckConfig(basis), // per-draft evidence, not policy
176
+ * });
177
+ */
178
+ export function resolveCheckPolicy(
179
+ policies: CheckPolicySet,
180
+ context: CheckContext = {},
181
+ ): CheckPolicy {
182
+ const matching = (policies ?? [])
183
+ .map((rule, index) => ({ rule, index }))
184
+ .filter(({ rule }) => rule && ruleApplies(rule, context))
185
+ .sort((a, b) => specificity(a.rule) - specificity(b.rule) || a.index - b.index);
186
+
187
+ const resolved: Record<string, unknown> = {};
188
+ for (const { rule } of matching) {
189
+ for (const [key, value] of Object.entries(rule.policy ?? {})) {
190
+ // An absent key means "no opinion", so it must not erase what a less
191
+ // specific rule decided — otherwise spreading a partial object over a
192
+ // policy would silently reset half of it.
193
+ if (value !== undefined) resolved[key] = value;
194
+ }
195
+ }
196
+ return resolved as CheckPolicy;
197
+ }