@startsimpli/ui 0.4.65 → 0.4.67

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.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/src/components/navigation/__tests__/navigation.test.tsx +5 -2
  3. package/src/components/navigation/app-shell.tsx +13 -3
  4. package/src/components/navigation/grouped-nav.tsx +2 -2
  5. package/src/components/navigation/sidebar.tsx +20 -5
  6. package/src/components/settings/SettingsCard.tsx +16 -8
  7. package/src/components/team/InviteMemberDialog.tsx +10 -2
  8. package/src/components/team/MembersTable.tsx +11 -9
  9. package/src/components/team/__tests__/team-components.test.tsx +21 -0
  10. package/src/components/team/__tests__/theme-tokens.test.ts +76 -0
  11. package/src/components/team/domain-claim-card-default-class-names.ts +17 -14
  12. package/src/components/team/invite-member-dialog-default-class-names.ts +20 -12
  13. package/src/components/team/leave-team-dialog-default-class-names.ts +13 -11
  14. package/src/components/team/members-table-default-class-names.ts +14 -13
  15. package/src/components/team/org-switcher-default-class-names.ts +4 -3
  16. package/src/components/team/pages/domains-settings-page-default-class-names.ts +13 -13
  17. package/src/components/team/pages/team-settings-page-default-class-names.ts +17 -17
  18. package/src/components/team/pending-invitation-callout-default-class-names.ts +8 -6
  19. package/src/components/team/role-selector-default-class-names.ts +3 -2
  20. package/src/components/validation/__tests__/checks.test.ts +33 -0
  21. package/src/components/validation/__tests__/overlap.test.ts +58 -0
  22. package/src/components/validation/checks.ts +62 -0
  23. package/src/components/validation/index.ts +2 -0
  24. package/src/components/validation/overlap.ts +91 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.65",
3
+ "version": "0.4.67",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -128,7 +128,10 @@ describe('Sidebar', () => {
128
128
  ]
129
129
  render(<Sidebar appName="App" sections={customSections} />)
130
130
  const activeLink = screen.getByText('Active Link').closest('a')
131
- expect(activeLink).toHaveClass('bg-primary-600')
131
+ // Semantic token, not a numeric ramp — `bg-primary-600` is undefined in apps
132
+ // that only declare --color-primary, so the active pill vanished (startsim-1f3n).
133
+ expect(activeLink).toHaveClass('bg-primary')
134
+ expect(activeLink).toHaveClass('text-primary-foreground')
132
135
  })
133
136
 
134
137
  it('inactive link uses gray text', () => {
@@ -154,7 +157,7 @@ describe('Sidebar', () => {
154
157
  ]
155
158
  render(<Sidebar appName="App" sections={customSections} />)
156
159
  const homeLink = screen.getByText('Home').closest('a')
157
- expect(homeLink).not.toHaveClass('bg-primary-600')
160
+ expect(homeLink).not.toHaveClass('bg-primary')
158
161
  })
159
162
  })
160
163
 
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, type ReactNode } from 'react';
3
+ import { useEffect, useState, type ReactNode } from 'react';
4
4
  import { useAuth, buildCentralAuthUrl } from '@startsimpli/auth';
5
5
  import { LogOut, User } from 'lucide-react';
6
6
  import { Sidebar, SidebarLayout, type SidebarSection } from './sidebar';
@@ -52,6 +52,9 @@ export function AppShell({
52
52
  children,
53
53
  }: AppShellProps) {
54
54
  const { user, isLoading, logout } = useAuth();
55
+ // Owned here so the content spacer shrinks with the rail — the Sidebar used to
56
+ // keep this private, so "Collapse" narrowed the rail and left a dead 16rem gap.
57
+ const [collapsed, setCollapsed] = useState(false);
55
58
 
56
59
  const signin = (returnTo: string) => buildCentralAuthUrl('signin', { app, returnTo });
57
60
 
@@ -112,8 +115,15 @@ export function AppShell({
112
115
 
113
116
  return (
114
117
  <>
115
- <Sidebar appName={appName} sections={sections} userDropdown={userDropdown} collapsible />
116
- <SidebarLayout>{children}</SidebarLayout>
118
+ <Sidebar
119
+ appName={appName}
120
+ sections={sections}
121
+ userDropdown={userDropdown}
122
+ collapsible
123
+ collapsed={collapsed}
124
+ onCollapsedChange={setCollapsed}
125
+ />
126
+ <SidebarLayout collapsed={collapsed}>{children}</SidebarLayout>
117
127
  </>
118
128
  );
119
129
  }
@@ -69,7 +69,7 @@ function linkClasses(active: boolean, indented: boolean) {
69
69
  'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
70
70
  indented && 'pl-9',
71
71
  active
72
- ? 'bg-primary-600 text-white'
72
+ ? 'bg-primary text-primary-foreground'
73
73
  : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
74
74
  )
75
75
  }
@@ -175,7 +175,7 @@ function NavGroup({
175
175
  type="button"
176
176
  onClick={() => setOpen((o) => !o)}
177
177
  aria-expanded={expanded}
178
- className="w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
178
+ className="w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-ring"
179
179
  >
180
180
  {heading}
181
181
  <ChevronDown
@@ -40,9 +40,17 @@ export interface SidebarProps {
40
40
  */
41
41
  collapsible?: boolean;
42
42
  /**
43
- * Default collapsed state
43
+ * Default collapsed state (uncontrolled)
44
44
  */
45
45
  defaultCollapsed?: boolean;
46
+ /**
47
+ * Controlled collapsed state. Pass with onCollapsedChange when the surrounding
48
+ * layout also needs to know (see AppShell — the content spacer must shrink with
49
+ * the rail, otherwise "Collapse" only narrows the rail and leaves a dead gap).
50
+ */
51
+ collapsed?: boolean;
52
+ /** Fires whenever the collapse toggle is used. */
53
+ onCollapsedChange?: (collapsed: boolean) => void;
46
54
  }
47
55
 
48
56
  export function Sidebar({
@@ -52,9 +60,16 @@ export function Sidebar({
52
60
  className,
53
61
  collapsible = true,
54
62
  defaultCollapsed = false,
63
+ collapsed: controlledCollapsed,
64
+ onCollapsedChange,
55
65
  }: SidebarProps) {
56
66
  const pathname = usePathname();
57
- const [collapsed, setCollapsed] = useState(defaultCollapsed);
67
+ const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
68
+ const collapsed = controlledCollapsed ?? internalCollapsed;
69
+ const setCollapsed = (next: boolean) => {
70
+ if (controlledCollapsed === undefined) setInternalCollapsed(next);
71
+ onCollapsedChange?.(next);
72
+ };
58
73
 
59
74
  const isActivePath = (href: string) => {
60
75
  if (href === '/') {
@@ -76,7 +91,7 @@ export function Sidebar({
76
91
  <Link
77
92
  href="/"
78
93
  className={cn(
79
- 'font-bold hover:text-primary-400 transition-colors',
94
+ 'font-bold transition-colors hover:text-primary',
80
95
  collapsed ? 'text-lg' : 'text-xl'
81
96
  )}
82
97
  >
@@ -103,7 +118,7 @@ export function Sidebar({
103
118
  className={cn(
104
119
  'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
105
120
  isActive
106
- ? 'bg-primary-600 text-white'
121
+ ? 'bg-primary text-primary-foreground shadow-sm'
107
122
  : 'text-gray-300 hover:bg-gray-800 hover:text-white',
108
123
  collapsed && 'justify-center'
109
124
  )}
@@ -163,7 +178,7 @@ export interface SidebarLayoutProps {
163
178
 
164
179
  export function SidebarLayout({ children, collapsed = false }: SidebarLayoutProps) {
165
180
  return (
166
- <div className="flex h-screen bg-gray-50">
181
+ <div className="flex h-screen bg-background">
167
182
  {/* Spacer for fixed sidebar */}
168
183
  <div className={cn('flex-shrink-0 transition-all duration-200', collapsed ? 'w-20' : 'w-64')} />
169
184
 
@@ -8,24 +8,32 @@ export interface SettingsCardProps {
8
8
  children?: React.ReactNode
9
9
  }
10
10
 
11
+ /**
12
+ * A titled settings panel with an optional trailing action.
13
+ *
14
+ * Theme-driven surfaces (startsim-1f3n): it used to hard-code
15
+ * `bg-white text-gray-900`, which ignored every app's palette and was
16
+ * unreadable in dark mode. Now it renders on the `card` token like the rest of
17
+ * the system.
18
+ */
11
19
  export function SettingsCard({ title, description, icon: Icon, action, children }: SettingsCardProps) {
12
20
  return (
13
- <div className="rounded-xl border bg-white text-gray-900 shadow-sm">
14
- <div className="flex items-start justify-between p-6">
15
- <div className="flex items-start gap-4">
21
+ <div className="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
22
+ <div className="flex flex-wrap items-start justify-between gap-4 p-6">
23
+ <div className="flex min-w-0 items-start gap-4">
16
24
  {Icon && (
17
- <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
18
- <Icon className="h-5 w-5 text-gray-600" />
25
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
26
+ <Icon className="h-5 w-5 text-primary" />
19
27
  </div>
20
28
  )}
21
- <div>
29
+ <div className="min-w-0">
22
30
  <h3 className="font-semibold leading-none tracking-tight">{title}</h3>
23
31
  {description && (
24
- <p className="text-sm text-gray-500 mt-1.5">{description}</p>
32
+ <p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
25
33
  )}
26
34
  </div>
27
35
  </div>
28
- {action && <div className="flex-shrink-0 ml-4">{action}</div>}
36
+ {action && <div className="shrink-0">{action}</div>}
29
37
  </div>
30
38
  {children && <div className="px-6 pb-6 pt-0">{children}</div>}
31
39
  </div>
@@ -32,6 +32,12 @@ export interface InviteMemberDialogProps {
32
32
  triggerLabel?: string
33
33
  /** Title in the modal header. */
34
34
  title?: string
35
+ /** Submit button label. Override when the backend adds directly rather than
36
+ * emailing an invite (e.g. Foundry: "Add member"). */
37
+ submitLabel?: string
38
+ /** Confirmation line after a successful submit. Receives the email so the
39
+ * caller can match its own semantics ("Added ada@x to this foundry"). */
40
+ successMessage?: (email: string) => string
35
41
  /** Default role pre-selected. */
36
42
  defaultRole?: TeamRole
37
43
  /** Available roles in the dropdown. Defaults to admin/member/viewer (no owner). */
@@ -48,6 +54,8 @@ export function InviteMemberDialog({
48
54
  onInvited,
49
55
  triggerLabel = 'Invite member',
50
56
  title = 'Invite a teammate',
57
+ submitLabel = 'Send invitation',
58
+ successMessage = (email) => `Invitation sent to ${email}`,
51
59
  defaultRole = 'member',
52
60
  availableRoles = ['admin', 'member', 'viewer'],
53
61
  classNames,
@@ -95,7 +103,7 @@ export function InviteMemberDialog({
95
103
  if (onSubmit) {
96
104
  invited = await onSubmit({ email: email.trim(), teamId, role })
97
105
  }
98
- setSuccess(`Invitation sent to ${email.trim()}`)
106
+ setSuccess(successMessage(email.trim()))
99
107
  setEmail('')
100
108
  onInvited?.(invited)
101
109
  } catch (err) {
@@ -170,7 +178,7 @@ export function InviteMemberDialog({
170
178
  className={cls.submitButton}
171
179
  disabled={submitting}
172
180
  >
173
- {submitting ? 'Sending…' : 'Send invitation'}
181
+ {submitting ? 'Saving…' : submitLabel}
174
182
  </button>
175
183
  </div>
176
184
  </form>
@@ -77,7 +77,12 @@ export function MembersTable({
77
77
  </td>
78
78
  </tr>
79
79
  )}
80
- {members.map((m) => (
80
+ {members.map((m) => {
81
+ const displayName = userDisplayName(m.user) || m.userId
82
+ // Members with no name fall back to their email, so printing the
83
+ // email underneath stacked the same string on itself.
84
+ const secondary = m.user?.email && m.user.email !== displayName ? m.user.email : null
85
+ return (
81
86
  <tr key={m.id} className={cls.row}>
82
87
  <td className={cls.cell}>
83
88
  <div className="flex items-center gap-3">
@@ -93,13 +98,9 @@ export function MembersTable({
93
98
  {userInitials(m.user)}
94
99
  </span>
95
100
  )}
96
- <div className="flex flex-col">
97
- <span className={cls.name}>
98
- {userDisplayName(m.user) || m.userId}
99
- </span>
100
- {m.user?.email && (
101
- <span className={cls.email}>{m.user.email}</span>
102
- )}
101
+ <div className="flex min-w-0 flex-col">
102
+ <span className={cls.name}>{displayName}</span>
103
+ {secondary && <span className={cls.email}>{secondary}</span>}
103
104
  </div>
104
105
  </div>
105
106
  </td>
@@ -130,7 +131,8 @@ export function MembersTable({
130
131
  </td>
131
132
  )}
132
133
  </tr>
133
- ))}
134
+ )
135
+ })}
134
136
  </tbody>
135
137
  </table>
136
138
  </div>
@@ -73,6 +73,27 @@ describe('MembersTable', () => {
73
73
  expect(container.firstChild).toHaveClass('my-root-class')
74
74
  })
75
75
 
76
+ it('does not print the email twice when it is also the display name', () => {
77
+ // Foundry maps control-plane members with an email and no name, so
78
+ // userDisplayName() falls back to the email — the row used to stack the
79
+ // same string on itself. startsim-1f3n.4.
80
+ render(
81
+ <MembersTable
82
+ members={[
83
+ {
84
+ id: 'u1',
85
+ userId: 'u1',
86
+ teamId: 't1',
87
+ role: 'owner',
88
+ joinedAt: '2025-01-01',
89
+ user: { id: 'u1', email: 'qosha@debugg.ai' },
90
+ },
91
+ ]}
92
+ />
93
+ )
94
+ expect(screen.getAllByText('qosha@debugg.ai')).toHaveLength(1)
95
+ })
96
+
76
97
  it('shows the empty state when no members', () => {
77
98
  render(<MembersTable members={[]} />)
78
99
  expect(screen.getByText('No members yet.')).toBeInTheDocument()
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Theme-token guard for the team/settings surfaces (startsim-1f3n.1).
3
+ *
4
+ * These components ship default Tailwind classes. The only colors an app is
5
+ * guaranteed to have are the semantic tokens from packages/ui/theme/contract.css
6
+ * (--primary, --card, --border, --muted-foreground, …). Apps do NOT all define a
7
+ * numeric palette ramp: apps/foundry defines `--color-primary` with no
8
+ * `--color-primary-600`, so in Tailwind v4 `bg-primary-600` generates no rule at
9
+ * all — the Invite member button rendered as white text on a white card, i.e.
10
+ * invisible.
11
+ *
12
+ * Hard-coded `gray-*` / `bg-white` are the same class of bug one step quieter:
13
+ * the classes exist, but they ignore the app's theme and are unreadable in dark
14
+ * mode. Shared components must be theme-driven, not palette-driven.
15
+ */
16
+
17
+ import { INVITE_DIALOG_DEFAULTS } from '../invite-member-dialog-default-class-names'
18
+ import { MEMBERS_TABLE_DEFAULTS } from '../members-table-default-class-names'
19
+ import { ROLE_SELECTOR_DEFAULTS } from '../role-selector-default-class-names'
20
+ import { PENDING_INVITE_DEFAULTS } from '../pending-invitation-callout-default-class-names'
21
+ import { LEAVE_DIALOG_DEFAULTS } from '../leave-team-dialog-default-class-names'
22
+ import { ORG_SWITCHER_DEFAULTS } from '../org-switcher-default-class-names'
23
+ import { DOMAIN_CLAIM_DEFAULTS } from '../domain-claim-card-default-class-names'
24
+ import { TEAM_SETTINGS_PAGE_DEFAULTS } from '../pages/team-settings-page-default-class-names'
25
+ import { DOMAINS_SETTINGS_PAGE_DEFAULTS } from '../pages/domains-settings-page-default-class-names'
26
+
27
+ const MAPS: Record<string, Record<string, string>> = {
28
+ INVITE_DIALOG_DEFAULTS,
29
+ MEMBERS_TABLE_DEFAULTS,
30
+ ROLE_SELECTOR_DEFAULTS,
31
+ PENDING_INVITE_DEFAULTS,
32
+ LEAVE_DIALOG_DEFAULTS,
33
+ ORG_SWITCHER_DEFAULTS,
34
+ DOMAIN_CLAIM_DEFAULTS,
35
+ TEAM_SETTINGS_PAGE_DEFAULTS,
36
+ DOMAINS_SETTINGS_PAGE_DEFAULTS,
37
+ }
38
+
39
+ /** `bg-primary-600`, `text-primary-700`, `focus:border-primary-500`, … */
40
+ const PRIMARY_RAMP = /(^|:)[a-z-]*-primary-\d{2,3}\b/
41
+ /** `text-gray-500`, `bg-white`, `divide-gray-100`, … — theme-blind surfaces. */
42
+ const RAW_SURFACE = /(^|:)(bg|text|border|divide|ring)-(gray|slate|zinc|neutral|stone)-\d{2,3}\b|(^|:)bg-white\b/
43
+
44
+ function offenders(map: Record<string, string>, pattern: RegExp): string[] {
45
+ return Object.entries(map)
46
+ .flatMap(([slot, value]) =>
47
+ String(value)
48
+ .split(/\s+/)
49
+ .filter((cls) => pattern.test(cls))
50
+ .map((cls) => `${slot}: ${cls}`),
51
+ )
52
+ }
53
+
54
+ describe('team surfaces are theme-driven', () => {
55
+ it.each(Object.keys(MAPS))(
56
+ '%s uses no numeric primary ramp (undefined in token-only apps like foundry)',
57
+ (name) => {
58
+ expect(offenders(MAPS[name], PRIMARY_RAMP)).toEqual([])
59
+ },
60
+ )
61
+
62
+ it.each(Object.keys(MAPS))('%s uses no theme-blind gray/white surfaces', (name) => {
63
+ expect(offenders(MAPS[name], RAW_SURFACE)).toEqual([])
64
+ })
65
+
66
+ it('the invite trigger is a filled primary button that is visible on a card', () => {
67
+ expect(INVITE_DIALOG_DEFAULTS.trigger).toContain('bg-primary')
68
+ expect(INVITE_DIALOG_DEFAULTS.trigger).toContain('text-primary-foreground')
69
+ })
70
+
71
+ it('dialog panels sit on the card surface, not hard-coded white', () => {
72
+ expect(INVITE_DIALOG_DEFAULTS.panel).toContain('bg-card')
73
+ expect(LEAVE_DIALOG_DEFAULTS.panel).toContain('bg-card')
74
+ })
75
+ })
76
+
@@ -1,4 +1,5 @@
1
- /** Default Tailwind classes for {@link DomainClaimCard}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link DomainClaimCard}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
2
3
  export interface DomainClaimCardClassNames {
3
4
  root?: string
4
5
  header?: string
@@ -20,26 +21,28 @@ export interface DomainClaimCardClassNames {
20
21
  }
21
22
 
22
23
  export const DOMAIN_CLAIM_DEFAULTS: Required<DomainClaimCardClassNames> = {
23
- root: 'rounded-xl border border-gray-200 bg-white p-5',
24
+ root: 'rounded-xl border border-border bg-card p-5 text-card-foreground',
24
25
  header: 'flex items-center justify-between gap-3',
25
- domain: 'text-base font-semibold text-gray-900',
26
+ domain: 'text-base font-semibold text-foreground',
26
27
  statusBadge: 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
27
- statusVerified: 'bg-green-100 text-green-800',
28
- statusUnverified: 'bg-yellow-100 text-yellow-800',
29
- body: 'mt-4 space-y-3 text-sm text-gray-700',
30
- sectionLabel: 'text-xs font-medium uppercase tracking-wide text-gray-500',
28
+ statusVerified:
29
+ 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
30
+ statusUnverified:
31
+ 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
32
+ body: 'mt-4 space-y-3 text-sm text-foreground',
33
+ sectionLabel: 'text-xs font-medium uppercase tracking-wide text-muted-foreground',
31
34
  tokenRow: 'flex items-center gap-2',
32
35
  tokenCode:
33
- 'flex-1 rounded-md bg-gray-50 px-3 py-2 font-mono text-xs text-gray-900',
34
- helpText: 'text-xs text-gray-500',
36
+ 'flex-1 rounded-md bg-muted px-3 py-2 font-mono text-xs text-foreground',
37
+ helpText: 'text-xs text-muted-foreground',
35
38
  actions: 'mt-4 flex flex-wrap items-center gap-2',
36
39
  primaryButton:
37
- 'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
40
+ 'rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50',
38
41
  secondaryButton:
39
- 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50',
42
+ 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50',
40
43
  revokeButton:
41
- 'rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50',
42
- errorText: 'mt-2 text-sm text-red-600',
44
+ 'rounded-md border border-destructive/40 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50',
45
+ errorText: 'mt-2 text-sm text-destructive',
43
46
  codeInput:
44
- 'w-24 rounded-md border border-gray-300 px-2 py-1.5 text-sm tracking-widest outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
47
+ 'w-24 rounded-md border border-input bg-background px-2 py-1.5 text-sm tracking-widest text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring',
45
48
  }
@@ -1,4 +1,12 @@
1
- /** Default Tailwind classes for {@link InviteMemberDialog}. startsim-o7s. */
1
+ /**
2
+ * Default Tailwind classes for {@link InviteMemberDialog}. startsim-o7s.
3
+ *
4
+ * Semantic tokens only (startsim-1f3n) — see packages/ui/theme/contract.css.
5
+ * A numeric ramp like `bg-primary-600` is NOT part of the contract: apps that
6
+ * define `--color-primary` alone (foundry, gen-ai-consulting, workflow-studio)
7
+ * generate no rule for it, which is how this trigger became an invisible
8
+ * white-on-white button in the Foundry control plane.
9
+ */
2
10
  export interface InviteMemberDialogClassNames {
3
11
  trigger?: string
4
12
  overlay?: string
@@ -18,24 +26,24 @@ export interface InviteMemberDialogClassNames {
18
26
 
19
27
  export const INVITE_DIALOG_DEFAULTS: Required<InviteMemberDialogClassNames> = {
20
28
  trigger:
21
- 'inline-flex items-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700',
29
+ 'inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
22
30
  overlay:
23
31
  'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4',
24
32
  panel:
25
- 'w-full max-w-md rounded-xl bg-white shadow-xl ring-1 ring-black/5',
26
- header: 'border-b border-gray-100 px-6 py-4',
27
- title: 'text-base font-semibold text-gray-900',
33
+ 'w-full max-w-md rounded-xl border border-border bg-card text-card-foreground shadow-xl',
34
+ header: 'border-b border-border px-6 py-4',
35
+ title: 'text-base font-semibold text-foreground',
28
36
  body: 'space-y-4 px-6 py-4',
29
37
  fieldRow: '',
30
- label: 'block text-sm font-medium text-gray-700 mb-1',
38
+ label: 'mb-1 block text-sm font-medium text-foreground',
31
39
  input:
32
- 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
33
- errorText: 'text-sm text-red-600',
34
- successText: 'text-sm text-green-600',
40
+ 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring',
41
+ errorText: 'text-sm text-destructive',
42
+ successText: 'text-sm text-emerald-600 dark:text-emerald-400',
35
43
  footer:
36
- 'flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-3',
44
+ 'flex items-center justify-end gap-2 border-t border-border px-6 py-3',
37
45
  cancelButton:
38
- 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50',
46
+ 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted',
39
47
  submitButton:
40
- 'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50',
48
+ 'rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50',
41
49
  }
@@ -1,4 +1,5 @@
1
- /** Default Tailwind classes for {@link LeaveTeamDialog}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link LeaveTeamDialog}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
2
3
  export interface LeaveTeamDialogClassNames {
3
4
  trigger?: string
4
5
  overlay?: string
@@ -15,19 +16,20 @@ export interface LeaveTeamDialogClassNames {
15
16
 
16
17
  export const LEAVE_DIALOG_DEFAULTS: Required<LeaveTeamDialogClassNames> = {
17
18
  trigger:
18
- 'inline-flex items-center rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50',
19
+ 'inline-flex items-center rounded-md border border-destructive/40 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10',
19
20
  overlay:
20
21
  'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4',
21
- panel: 'w-full max-w-md rounded-xl bg-white shadow-xl ring-1 ring-black/5',
22
- header: 'border-b border-gray-100 px-6 py-4',
23
- title: 'text-base font-semibold text-gray-900',
24
- body: 'space-y-3 px-6 py-4 text-sm text-gray-700',
25
- warningText: 'text-sm text-gray-600',
26
- errorText: 'text-sm text-red-600',
22
+ panel:
23
+ 'w-full max-w-md rounded-xl border border-border bg-card text-card-foreground shadow-xl',
24
+ header: 'border-b border-border px-6 py-4',
25
+ title: 'text-base font-semibold text-foreground',
26
+ body: 'space-y-3 px-6 py-4 text-sm text-foreground',
27
+ warningText: 'text-sm text-muted-foreground',
28
+ errorText: 'text-sm text-destructive',
27
29
  footer:
28
- 'flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-3',
30
+ 'flex items-center justify-end gap-2 border-t border-border px-6 py-3',
29
31
  cancelButton:
30
- 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50',
32
+ 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted',
31
33
  confirmButton:
32
- 'rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50',
34
+ 'rounded-md bg-destructive px-3 py-1.5 text-sm font-semibold text-destructive-foreground shadow-sm transition-colors hover:bg-destructive/90 disabled:opacity-50',
33
35
  }
@@ -1,4 +1,5 @@
1
- /** Default Tailwind classes for {@link MembersTable}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link MembersTable}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
2
3
  export interface MembersTableClassNames {
3
4
  root?: string
4
5
  table?: string
@@ -18,22 +19,22 @@ export interface MembersTableClassNames {
18
19
  }
19
20
 
20
21
  export const MEMBERS_TABLE_DEFAULTS: Required<MembersTableClassNames> = {
21
- root: 'w-full overflow-x-auto rounded-lg border border-gray-200',
22
- table: 'w-full text-sm text-left',
23
- thead: 'bg-gray-50 text-xs uppercase tracking-wide text-gray-500',
22
+ root: 'w-full overflow-x-auto rounded-lg border border-border bg-card',
23
+ table: 'w-full text-left text-sm',
24
+ thead: 'bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground',
24
25
  headerRow: '',
25
- th: 'px-4 py-2 font-medium',
26
- tbody: 'divide-y divide-gray-100 bg-white',
27
- row: 'hover:bg-gray-50',
26
+ th: 'px-4 py-2.5 font-medium',
27
+ tbody: 'divide-y divide-border',
28
+ row: 'transition-colors hover:bg-muted/40',
28
29
  cell: 'px-4 py-3 align-middle',
29
30
  avatar:
30
- 'flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-xs font-medium text-gray-600',
31
- name: 'font-medium text-gray-900',
32
- email: 'text-gray-500',
31
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary',
32
+ name: 'font-medium text-foreground',
33
+ email: 'text-muted-foreground',
33
34
  roleBadge:
34
- 'inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700',
35
+ 'inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground',
35
36
  actions: 'flex items-center justify-end gap-2',
36
37
  removeButton:
37
- 'rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-100 disabled:opacity-50',
38
- emptyRow: 'px-4 py-6 text-center text-sm text-gray-500',
38
+ 'rounded-md border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:opacity-50',
39
+ emptyRow: 'px-4 py-6 text-center text-sm text-muted-foreground',
39
40
  }
@@ -1,4 +1,5 @@
1
- /** Default Tailwind classes for {@link OrgSwitcher}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link OrgSwitcher}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
2
3
  export interface OrgSwitcherClassNames {
3
4
  root?: string
4
5
  label?: string
@@ -7,7 +8,7 @@ export interface OrgSwitcherClassNames {
7
8
 
8
9
  export const ORG_SWITCHER_DEFAULTS: Required<OrgSwitcherClassNames> = {
9
10
  root: 'inline-flex items-center gap-2',
10
- label: 'text-xs font-medium uppercase tracking-wide text-gray-500',
11
+ label: 'text-xs font-medium uppercase tracking-wide text-muted-foreground',
11
12
  select:
12
- 'rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
13
+ 'rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring',
13
14
  }
@@ -60,30 +60,30 @@ export const DOMAINS_SETTINGS_PAGE_DEFAULTS: Required<
60
60
  > = {
61
61
  root: 'space-y-6',
62
62
  header: '',
63
- backLink: 'text-sm text-primary-600 hover:underline',
63
+ backLink: 'text-sm text-primary hover:underline',
64
64
  titleRow: 'mt-2 flex items-center justify-between gap-4',
65
- title: 'text-xl font-semibold text-gray-900',
66
- subtitle: 'mt-1 text-sm text-gray-500',
65
+ title: 'text-xl font-semibold text-foreground',
66
+ subtitle: 'mt-1 text-sm text-muted-foreground',
67
67
 
68
68
  primaryButton:
69
- 'rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50',
69
+ 'rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50',
70
70
  input:
71
- 'w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500',
72
- label: 'block text-sm font-medium text-gray-700',
73
- formCard: 'rounded-xl border border-gray-200 bg-white p-4 space-y-3',
71
+ 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring',
72
+ label: 'block text-sm font-medium text-foreground',
73
+ formCard: 'space-y-3 rounded-xl border border-border bg-card p-4',
74
74
  formActions: 'flex gap-2',
75
75
  cancelButton:
76
- 'rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50',
76
+ 'rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted',
77
77
 
78
- errorText: 'text-sm text-red-600',
79
- loadingText: 'text-sm text-gray-500',
78
+ errorText: 'text-sm text-destructive',
79
+ loadingText: 'text-sm text-muted-foreground',
80
80
 
81
81
  emptyState:
82
- 'rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center',
83
- emptyText: 'text-sm text-gray-600',
82
+ 'rounded-xl border border-dashed border-border bg-card p-10 text-center',
83
+ emptyText: 'text-sm text-muted-foreground',
84
84
 
85
85
  retryButton:
86
- 'rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50',
86
+ 'rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted',
87
87
 
88
88
  list: 'space-y-4',
89
89
  }
@@ -82,35 +82,35 @@ export const TEAM_SETTINGS_PAGE_DEFAULTS: Required<
82
82
  > = {
83
83
  root: 'space-y-8',
84
84
  header: '',
85
- backLink: 'text-sm text-primary-600 hover:underline',
86
- title: 'mt-2 text-xl font-semibold text-gray-900',
87
- subtitle: 'mt-1 text-sm text-gray-500',
85
+ backLink: 'text-sm text-primary hover:underline',
86
+ title: 'mt-2 text-xl font-semibold text-foreground',
87
+ subtitle: 'mt-1 text-sm text-muted-foreground',
88
88
 
89
89
  section: 'space-y-3',
90
90
  sectionHeader: 'flex items-center justify-between',
91
- sectionHeading: 'text-sm font-semibold uppercase tracking-wide text-gray-500',
92
- sectionBody: 'text-sm text-gray-500',
91
+ sectionHeading: 'text-sm font-semibold uppercase tracking-wide text-muted-foreground',
92
+ sectionBody: 'text-sm text-muted-foreground',
93
93
 
94
- dangerDivider: 'space-y-3 border-t border-gray-200 pt-6',
94
+ dangerDivider: 'space-y-3 border-t border-border pt-6',
95
95
 
96
- errorText: 'text-sm text-red-600',
97
- loadingText: 'text-sm text-gray-500',
98
- emptyText: 'text-sm text-gray-500',
96
+ errorText: 'text-sm text-destructive',
97
+ loadingText: 'text-sm text-muted-foreground',
98
+ emptyText: 'text-sm text-muted-foreground',
99
99
 
100
100
  retryButton:
101
- 'rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50',
101
+ 'rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted',
102
102
 
103
103
  invitationsTableWrapper:
104
- 'overflow-hidden rounded-xl border border-gray-200 bg-white',
104
+ 'overflow-hidden rounded-xl border border-border bg-card',
105
105
  invitationsTable: 'w-full text-sm',
106
- invitationsThead: 'bg-gray-50 text-left text-xs uppercase text-gray-500',
106
+ invitationsThead: 'bg-muted/50 text-left text-xs uppercase text-muted-foreground',
107
107
  invitationsTh: 'px-4 py-3 font-medium',
108
- invitationsTbody: 'divide-y divide-gray-100',
109
- invitationsEmailCell: 'px-4 py-3 text-gray-700',
108
+ invitationsTbody: 'divide-y divide-border',
109
+ invitationsEmailCell: 'px-4 py-3 text-foreground',
110
110
  invitationsRoleCell: 'px-4 py-3',
111
111
  invitationsRoleBadge:
112
- 'inline-flex items-center rounded-full bg-primary-50 px-2 py-0.5 text-xs font-medium text-primary-700',
113
- invitationsExpiryCell: 'px-4 py-3 text-gray-500',
112
+ 'inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary',
113
+ invitationsExpiryCell: 'px-4 py-3 text-muted-foreground',
114
114
  invitationsActionsCell: 'px-4 py-3 text-right',
115
- invitationsRevokeButton: 'text-red-600 hover:underline text-sm',
115
+ invitationsRevokeButton: 'text-sm text-destructive hover:underline',
116
116
  }
@@ -1,4 +1,6 @@
1
- /** Default Tailwind classes for {@link PendingInvitationCallout}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link PendingInvitationCallout}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — the callout picks up the app's brand
3
+ * color instead of a hard-coded blue that clashes with every non-blue theme. */
2
4
  export interface PendingInvitationCalloutClassNames {
3
5
  root?: string
4
6
  message?: string
@@ -10,13 +12,13 @@ export interface PendingInvitationCalloutClassNames {
10
12
  }
11
13
 
12
14
  export const PENDING_INVITE_DEFAULTS: Required<PendingInvitationCalloutClassNames> = {
13
- root: 'flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3',
14
- message: 'flex-1 text-sm text-blue-900',
15
+ root: 'flex items-center gap-3 rounded-lg border border-primary/25 bg-primary/5 px-4 py-3',
16
+ message: 'flex-1 text-sm text-foreground',
15
17
  emphasized: 'font-semibold',
16
18
  actions: 'flex items-center gap-2',
17
19
  acceptButton:
18
- 'rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50',
20
+ 'rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50',
19
21
  declineButton:
20
- 'rounded-md border border-blue-300 px-3 py-1.5 text-sm font-medium text-blue-700 hover:bg-blue-100',
21
- errorText: 'mt-2 text-sm text-red-600',
22
+ 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
23
+ errorText: 'mt-2 text-sm text-destructive',
22
24
  }
@@ -1,4 +1,5 @@
1
- /** Default Tailwind classes for {@link RoleSelector}. startsim-o7s. */
1
+ /** Default Tailwind classes for {@link RoleSelector}. startsim-o7s.
2
+ * Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe. */
2
3
  export interface RoleSelectorClassNames {
3
4
  root?: string
4
5
  select?: string
@@ -7,5 +8,5 @@ export interface RoleSelectorClassNames {
7
8
  export const ROLE_SELECTOR_DEFAULTS: Required<RoleSelectorClassNames> = {
8
9
  root: 'inline-block',
9
10
  select:
10
- 'rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50',
11
+ 'rounded-md border border-input bg-background px-2 py-1 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
11
12
  }
@@ -284,3 +284,36 @@ describe('runContentChecks — locations (768w.16.15.3 jump-to-issue)', () => {
284
284
  expect(checks.some((c) => c.status !== 'pass')).toBe(true);
285
285
  });
286
286
  });
287
+
288
+ describe('source-overlap check', () => {
289
+ const SRC =
290
+ 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported, adding another category to a tax architecture that already captures selected products across the Gulf region.';
291
+
292
+ it('is skipped when no sourceTexts are supplied', () => {
293
+ expect(find(runContentChecks({ blog: 'anything' }), 'source-overlap')).toBeUndefined();
294
+ expect(find(runContentChecks({ blog: 'anything' }, { sourceTexts: [] }), 'source-overlap')).toBeUndefined();
295
+ });
296
+
297
+ it('fails when the blog copies a long verbatim run from a source', () => {
298
+ const c = find(
299
+ runContentChecks(
300
+ { blog: 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported.' },
301
+ { sourceTexts: [SRC] },
302
+ ),
303
+ 'source-overlap',
304
+ );
305
+ expect(c!.status).toBe('fail');
306
+ expect(c!.locations?.[0].field).toBe('blog');
307
+ });
308
+
309
+ it('passes a genuine paraphrase', () => {
310
+ const c = find(
311
+ runContentChecks(
312
+ { blog: 'From 2026 the Kingdom widens its sugary-drink levy, a reminder Gulf tax rules vary by market.' },
313
+ { sourceTexts: [SRC] },
314
+ ),
315
+ 'source-overlap',
316
+ );
317
+ expect(c!.status).toBe('pass');
318
+ });
319
+ });
@@ -0,0 +1,58 @@
1
+ import { verbatimOverlap, normWords } from '../overlap';
2
+
3
+ const SOURCE = `Saudi Arabia will apply excise tax to sweetened drinks from January 2026,
4
+ Gulf News reported, adding another category to a tax architecture that already
5
+ captures selected products across the Gulf region. For businesses outside the region,
6
+ the broader lesson is that excise tax in the GCC should be viewed as a shared framework
7
+ with local variations that change launch economics.`;
8
+
9
+ describe('normWords', () => {
10
+ it('lowercases, strips punctuation, collapses whitespace', () => {
11
+ expect(normWords('Hello, WORLD! (2026)')).toEqual(['hello', 'world', '2026']);
12
+ expect(normWords('')).toEqual([]);
13
+ expect(normWords(null as unknown as string)).toEqual([]);
14
+ });
15
+ });
16
+
17
+ describe('verbatimOverlap', () => {
18
+ it('flags a long verbatim copy (whole sentence lifted from the source)', () => {
19
+ const copied = 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported.';
20
+ const r = verbatimOverlap(copied, [SOURCE]);
21
+ expect(r.longestRunWords).toBeGreaterThanOrEqual(12);
22
+ expect(r.overlapRatio).toBeGreaterThan(0.5);
23
+ expect(r.sample).toContain('excise tax to sweetened drinks');
24
+ });
25
+
26
+ it('passes a genuine paraphrase of the same facts', () => {
27
+ const paraphrase =
28
+ 'From 2026 the Kingdom broadens its excise regime to cover sugary beverages, a reminder that Gulf tax rules differ market by market.';
29
+ const r = verbatimOverlap(paraphrase, [SOURCE]);
30
+ expect(r.longestRunWords).toBeLessThan(6);
31
+ expect(r.overlapRatio).toBeLessThan(0.15);
32
+ });
33
+
34
+ it('ignores a short shared phrase (< minRun words)', () => {
35
+ // "excise tax" is shared but only 2 words — below the 6-word shingle.
36
+ const r = verbatimOverlap('Companies weighing excise tax exposure should plan ahead.', [SOURCE]);
37
+ expect(r.longestRunWords).toBe(0);
38
+ });
39
+
40
+ it('is case- and punctuation-insensitive', () => {
41
+ const copied = 'SHOULD BE VIEWED AS A SHARED FRAMEWORK — with local variations!!!';
42
+ const r = verbatimOverlap(copied, [SOURCE]);
43
+ expect(r.longestRunWords).toBeGreaterThanOrEqual(6);
44
+ expect(r.sample).toContain('shared framework');
45
+ });
46
+
47
+ it('returns zeros for empty candidate or no sources', () => {
48
+ expect(verbatimOverlap('', [SOURCE])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
49
+ expect(verbatimOverlap('anything here', [])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
50
+ expect(verbatimOverlap('anything here', [''])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
51
+ });
52
+
53
+ it('finds the copied span across multiple sources', () => {
54
+ const copied = 'a tax architecture that already captures selected products';
55
+ const r = verbatimOverlap(copied, ['unrelated text about camels', SOURCE]);
56
+ expect(r.longestRunWords).toBeGreaterThanOrEqual(7);
57
+ });
58
+ });
@@ -11,6 +11,7 @@
11
11
  * Keep this identical to the n8n judge's deterministic logic so client +
12
12
  * pipeline always agree. No React, no DOM — importable from a Node/LLM route.
13
13
  */
14
+ import { verbatimOverlap } from './overlap';
14
15
 
15
16
  /** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
16
17
  export type CheckStatus = 'pass' | 'warn' | 'fail';
@@ -90,6 +91,18 @@ export interface ContentChecksConfig {
90
91
  hypeWords?: string[];
91
92
  /** Approved source hosts (no `www.`). REQUIRED to run the source check. */
92
93
  approvedHosts?: string[];
94
+ /**
95
+ * The cited source articles' TEXT. REQUIRED to run the verbatim-overlap check —
96
+ * the guardrail against copying too much from a source. Omit it (or pass []) and
97
+ * the check is skipped (nothing to compare against).
98
+ */
99
+ sourceTexts?: string[];
100
+ /** Longest verbatim run (words) that FAILS the overlap check (default 15). */
101
+ overlapFailRun?: number;
102
+ /** Longest verbatim run (words) that WARNS (default 8). */
103
+ overlapWarnRun?: number;
104
+ /** Copied fraction (0–1) that FAILS regardless of run length (default 0.25). */
105
+ overlapFailRatio?: number;
93
106
  }
94
107
 
95
108
  /**
@@ -186,6 +199,10 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
186
199
  requireLink = true,
187
200
  hypeWords = DEFAULT_HYPE_WORDS,
188
201
  approvedHosts,
202
+ sourceTexts,
203
+ overlapFailRun = 15,
204
+ overlapWarnRun = 8,
205
+ overlapFailRatio = 0.25,
189
206
  } = config;
190
207
 
191
208
  // Word-count bands.
@@ -297,6 +314,51 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
297
314
  }
298
315
  }
299
316
 
317
+ // Verbatim overlap — the anti-plagiarism guardrail. Only when the caller supplied
318
+ // the cited source article TEXT. Scans blog + linkedin against the sources and
319
+ // reports the WORST offender (longest copied run / highest copied fraction).
320
+ if (sourceTexts !== undefined && sourceTexts.length > 0) {
321
+ const targets: Array<[keyof ContentFields, string | undefined]> = [
322
+ ['blog', fields.blog],
323
+ ['linkedin', fields.linkedin],
324
+ ];
325
+ let worstRun = 0;
326
+ let worstRatio = 0;
327
+ let worstField: keyof ContentFields | null = null;
328
+ let worstSample = '';
329
+ for (const [field, text] of targets) {
330
+ if (typeof text !== 'string' || !text.trim()) continue;
331
+ if (worstField === null) worstField = field; // at least one field present → emit (even a clean pass)
332
+ const o = verbatimOverlap(text, sourceTexts);
333
+ if (o.longestRunWords > worstRun) {
334
+ worstRun = o.longestRunWords;
335
+ worstField = field;
336
+ worstSample = o.sample;
337
+ }
338
+ if (o.overlapRatio > worstRatio) worstRatio = o.overlapRatio;
339
+ }
340
+ if (worstField !== null) {
341
+ const status: CheckStatus =
342
+ worstRun >= overlapFailRun || worstRatio >= overlapFailRatio
343
+ ? 'fail'
344
+ : worstRun >= overlapWarnRun
345
+ ? 'warn'
346
+ : 'pass';
347
+ checks.push({
348
+ id: 'source-overlap',
349
+ label: 'Original wording (not copied from sources)',
350
+ status,
351
+ detail:
352
+ status === 'pass'
353
+ ? `${worstRun}-word max run`
354
+ : `copies ${worstRun} consecutive words (${Math.round(worstRatio * 100)}% overlap)`,
355
+ ...(status !== 'pass'
356
+ ? { locations: [{ field: worstField, matches: worstSample ? [worstSample] : undefined }] }
357
+ : {}),
358
+ });
359
+ }
360
+ }
361
+
300
362
  return checks;
301
363
  }
302
364
 
@@ -28,6 +28,8 @@ export {
28
28
  type Band,
29
29
  } from './checks';
30
30
 
31
+ export { verbatimOverlap, normWords, type OverlapResult } from './overlap';
32
+
31
33
  export { ValidationChecklist } from './ValidationChecklist';
32
34
  export type {
33
35
  ValidationChecklistProps,
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Verbatim-overlap detector (bd startsim-768w.18.16.1).
3
+ *
4
+ * Measures how much of a generated passage is copied VERBATIM from its source
5
+ * articles — the guardrail against plagiarism / SEO duplicate-content now that the
6
+ * writer is fed real article text. Pure + framework-free so it unit-tests in node.
7
+ *
8
+ * Method: word-shingle coverage. Both sides are normalised (lowercased, punctuation
9
+ * stripped, whitespace collapsed) to words; every `minRun`-word shingle of the
10
+ * sources goes in a set; each candidate position covered by an in-source shingle is
11
+ * marked "copied". From that we report the LONGEST consecutive copied run (words)
12
+ * and the fraction of the candidate that is copied. Short shared phrases (< minRun
13
+ * words) are deliberately invisible — quoting a handful of words is fine.
14
+ */
15
+
16
+ export interface OverlapResult {
17
+ /** Longest run of consecutive candidate words copied verbatim from a source. */
18
+ longestRunWords: number;
19
+ /** Fraction (0–1) of candidate words that sit inside a copied run. */
20
+ overlapRatio: number;
21
+ /** The longest copied span (normalised words), for a human detail. '' when none. */
22
+ sample: string;
23
+ }
24
+
25
+ /** Lowercase, strip punctuation, collapse whitespace → word list. */
26
+ export function normWords(s: string): string[] {
27
+ return String(s ?? '')
28
+ .toLowerCase()
29
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
30
+ .split(/\s+/)
31
+ .filter(Boolean);
32
+ }
33
+
34
+ /**
35
+ * Verbatim overlap of `candidate` against `sources`. `minRun` (default 6) is the
36
+ * shingle size — the shortest copied run that counts (so ≤5-word coincidences /
37
+ * short quotes are ignored). Returns zeros for empty candidate or no sources.
38
+ */
39
+ export function verbatimOverlap(
40
+ candidate: string,
41
+ sources: string[],
42
+ minRun = 6,
43
+ ): OverlapResult {
44
+ const cand = normWords(candidate);
45
+ const n = cand.length;
46
+ const empty: OverlapResult = { longestRunWords: 0, overlapRatio: 0, sample: '' };
47
+ if (n === 0 || minRun < 1) return empty;
48
+
49
+ const shingles = new Set<string>();
50
+ for (const src of sources ?? []) {
51
+ const w = normWords(src);
52
+ for (let i = 0; i + minRun <= w.length; i++) {
53
+ shingles.add(w.slice(i, i + minRun).join(' '));
54
+ }
55
+ }
56
+ if (shingles.size === 0) return empty;
57
+
58
+ const covered = new Array<boolean>(n).fill(false);
59
+ for (let i = 0; i + minRun <= n; i++) {
60
+ if (shingles.has(cand.slice(i, i + minRun).join(' '))) {
61
+ for (let j = i; j < i + minRun; j++) covered[j] = true;
62
+ }
63
+ }
64
+
65
+ let longest = 0;
66
+ let coveredCount = 0;
67
+ let cur = 0;
68
+ let curStart = 0;
69
+ let bestStart = -1;
70
+ let bestEnd = -1;
71
+ for (let i = 0; i < n; i++) {
72
+ if (covered[i]) {
73
+ coveredCount++;
74
+ if (cur === 0) curStart = i;
75
+ cur++;
76
+ if (cur > longest) {
77
+ longest = cur;
78
+ bestStart = curStart;
79
+ bestEnd = i;
80
+ }
81
+ } else {
82
+ cur = 0;
83
+ }
84
+ }
85
+
86
+ return {
87
+ longestRunWords: longest,
88
+ overlapRatio: coveredCount / n,
89
+ sample: longest > 0 ? cand.slice(bestStart, bestEnd + 1).join(' ') : '',
90
+ };
91
+ }