noecosystem-design 0.1.2 → 0.2.0

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 (136) hide show
  1. package/README.md +73 -35
  2. package/apps/showcase/src/catalog/catalog-index.tsx +8 -8
  3. package/apps/showcase/src/catalog/catalog-shell.tsx +7 -2
  4. package/apps/storybook/stories/catalog/calendar.stories.tsx +3 -1
  5. package/apps/storybook/stories/catalog/date-picker.stories.tsx +5 -1
  6. package/apps/storybook/stories/catalog/icon-system.stories.tsx +3 -2
  7. package/docs/adr/0006-icons8-icon-policy.md +4 -4
  8. package/docs/design-system/architecture.md +1 -1
  9. package/docs/design-system/ci-cd.md +22 -7
  10. package/docs/design-system/inventory.md +0 -1
  11. package/docs/design-system/rtl.md +1 -1
  12. package/package.json +2 -2
  13. package/packages/ai-ui/src/activity-feed.tsx +1 -2
  14. package/packages/ai-ui/src/agent-composer.tsx +1 -1
  15. package/packages/ai-ui/src/agent-message.tsx +1 -1
  16. package/packages/ai-ui/src/agent-plan.tsx +1 -1
  17. package/packages/ai-ui/src/agent-workspace.tsx +1 -2
  18. package/packages/ai-ui/src/approval-request.tsx +1 -1
  19. package/packages/ai-ui/src/run-inspector.tsx +1 -1
  20. package/packages/ai-ui/src/tool-call.tsx +1 -1
  21. package/packages/data-ui/src/activity-gauge.tsx +1 -1
  22. package/packages/data-ui/src/chart-frame.tsx +1 -1
  23. package/packages/data-ui/src/chart.tsx +1 -2
  24. package/packages/data-ui/src/dashboard-shell.tsx +1 -2
  25. package/packages/data-ui/src/data-table.tsx +1 -1
  26. package/packages/data-ui/src/filter-bar.tsx +1 -2
  27. package/packages/data-ui/src/metric-card.tsx +1 -1
  28. package/packages/data-ui/src/pagination.tsx +1 -3
  29. package/packages/data-ui/src/pie-chart.tsx +1 -1
  30. package/packages/data-ui/src/radar-chart.tsx +1 -1
  31. package/packages/design-tokens/src/base.css +1 -1
  32. package/packages/design-tokens/src/palette.ts +4 -4
  33. package/packages/design-tokens/src/tokens.css +6 -12
  34. package/packages/design-tokens/src/tokens.json +4 -4
  35. package/packages/design-tokens/src/tokens.mjs +4 -4
  36. package/packages/icons/package.json +3 -0
  37. package/packages/icons/src/adapters.tsx +4 -4
  38. package/packages/icons/src/iconsax.tsx +233 -0
  39. package/packages/icons/src/index.tsx +1 -103
  40. package/packages/registry/catalog-index.json +1 -1
  41. package/packages/registry/component-audit.json +1 -1
  42. package/packages/registry/docs-index.json +135 -403
  43. package/packages/registry/manifest.json +1642 -6110
  44. package/packages/registry/r/activity-feed.json +9 -32
  45. package/packages/registry/r/agent-composer.json +9 -34
  46. package/packages/registry/r/agent-message.json +9 -32
  47. package/packages/registry/r/agent-plan.json +9 -32
  48. package/packages/registry/r/agent-workspace.json +9 -32
  49. package/packages/registry/r/alert.json +9 -33
  50. package/packages/registry/r/app-frame.json +9 -32
  51. package/packages/registry/r/approval-request.json +9 -34
  52. package/packages/registry/r/badge.json +9 -33
  53. package/packages/registry/r/bidi-text.json +7 -25
  54. package/packages/registry/r/bottom-navigation.json +9 -32
  55. package/packages/registry/r/button.json +7 -29
  56. package/packages/registry/r/calendar.json +15 -31
  57. package/packages/registry/r/card.json +9 -32
  58. package/packages/registry/r/chart-frame.json +9 -32
  59. package/packages/registry/r/chart.json +8 -30
  60. package/packages/registry/r/code-block.json +9 -32
  61. package/packages/registry/r/context-menu.json +8 -36
  62. package/packages/registry/r/dashboard-01.json +7 -23
  63. package/packages/registry/r/dashboard-02.json +6 -20
  64. package/packages/registry/r/dashboard-shell.json +9 -32
  65. package/packages/registry/r/data-list.json +9 -32
  66. package/packages/registry/r/data-table.json +9 -32
  67. package/packages/registry/r/date-picker.json +15 -31
  68. package/packages/registry/r/dialog.json +11 -45
  69. package/packages/registry/r/direction-provider.json +6 -21
  70. package/packages/registry/r/drawer.json +10 -36
  71. package/packages/registry/r/dropdown.json +9 -37
  72. package/packages/registry/r/empty-state.json +9 -32
  73. package/packages/registry/r/error-state.json +9 -32
  74. package/packages/registry/r/fab.json +9 -34
  75. package/packages/registry/r/featured-icon.json +7 -29
  76. package/packages/registry/r/filter-bar.json +10 -35
  77. package/packages/registry/r/gradient-picker.json +7 -24
  78. package/packages/registry/r/header-navigation.json +9 -32
  79. package/packages/registry/r/hero-header.json +7 -25
  80. package/packages/registry/r/icon-system.json +14 -29
  81. package/packages/registry/r/image-picker.json +7 -24
  82. package/packages/registry/r/inline-cta.json +14 -32
  83. package/packages/registry/r/input.json +9 -33
  84. package/packages/registry/r/landing-page.json +6 -21
  85. package/packages/registry/r/login-page.json +9 -35
  86. package/packages/registry/r/marketing-header.json +8 -26
  87. package/packages/registry/r/menu.json +9 -34
  88. package/packages/registry/r/messaging.json +9 -33
  89. package/packages/registry/r/metric-card.json +9 -32
  90. package/packages/registry/r/mobile-screen.json +9 -32
  91. package/packages/registry/r/not-found.json +7 -25
  92. package/packages/registry/r/page-header.json +9 -32
  93. package/packages/registry/r/pagination.json +15 -31
  94. package/packages/registry/r/progress-steps.json +9 -33
  95. package/packages/registry/r/run-inspector.json +9 -32
  96. package/packages/registry/r/section-header.json +9 -32
  97. package/packages/registry/r/settings-01.json +5 -19
  98. package/packages/registry/r/settings-02.json +5 -19
  99. package/packages/registry/r/settings-page.json +9 -32
  100. package/packages/registry/r/sidebar-navigation.json +9 -32
  101. package/packages/registry/r/sign-in-form.json +9 -34
  102. package/packages/registry/r/skeleton.json +9 -32
  103. package/packages/registry/r/spinner.json +9 -32
  104. package/packages/registry/r/tabs.json +9 -34
  105. package/packages/registry/r/textarea.json +9 -33
  106. package/packages/registry/r/tool-call.json +9 -32
  107. package/packages/registry/r/tooltip.json +9 -33
  108. package/packages/registry/r/utility-button.json +9 -37
  109. package/packages/registry/r/workflow-canvas.json +9 -32
  110. package/packages/registry/r/workflow-node.json +9 -32
  111. package/packages/registry/registry.json +232 -937
  112. package/packages/registry/search-index.json +579 -2194
  113. package/packages/ui/package.json +4 -1
  114. package/packages/ui/src/date-picker.browser.test.tsx +1 -1
  115. package/packages/ui/src/date-picker.tsx +1 -2
  116. package/packages/ui/src/gradient-picker.tsx +6 -2
  117. package/packages/ui/src/theme-provider.browser.test.tsx +15 -6
  118. package/packages/ui/src/theme-provider.tsx +19 -5
  119. package/packages/workflow-ui/src/workflow-canvas.tsx +1 -1
  120. package/packages/workflow-ui/src/workflow-node.tsx +1 -1
  121. package/scripts/enrich-registry-payloads.mjs +9 -3
  122. package/scripts/fix-registry-closure.mjs +2 -1
  123. package/scripts/generate-manifest.mjs +61 -39
  124. package/scripts/validate-manifest.mjs +24 -33
  125. package/scripts/verify-registry-consumer.mjs +33 -7
  126. package/tests/e2e/catalog-typography.spec.ts +6 -4
  127. package/tests/icons.test.mjs +5 -5
  128. package/tsconfig.base.json +17 -51
  129. package/apps/showcase/src/catalog/__screenshots__/catalog-shell.browser.test.tsx/filters-the-component-inventory-and-exposes-the-selected-audit-state-1.png +0 -0
  130. package/apps/showcase/src/catalog/__screenshots__/catalog-shell.browser.test.tsx/opens-a-component-detail-view-and-returns-to-the-grid-1.png +0 -0
  131. package/apps/showcase/src/catalog/__screenshots__/catalog-shell.browser.test.tsx/switching-locale-to-Persian-updates-document-direction-to-rtl-1.png +0 -0
  132. package/packages/ui/src/__screenshots__/button.browser.test.tsx/Button-preserves-native--busy--disabled--and-ref-contracts-1.png +0 -0
  133. package/packages/ui/src/__screenshots__/button.browser.test.tsx/a-disabled-polymorphic-Button-cannot-navigate-or-receive-focus-1.png +0 -0
  134. package/packages/ui/src/__screenshots__/date-picker.browser.test.tsx/DatePicker-Escape-closes-popover-and-returns-focus-to-input-1.png +0 -0
  135. package/packages/ui/src/__screenshots__/date-picker.browser.test.tsx/DatePicker-click-outside-closes-popover-1.png +0 -0
  136. package/packages/ui/src/__screenshots__/dialog.browser.test.tsx/Dialog-propagates-RTL-into-the-portal-and-traps-then-restores-focus-1.png +0 -0
@@ -21,6 +21,9 @@
21
21
  "react-dom": "^19.2.0"
22
22
  },
23
23
  "exports": {
24
- ".": "./src/index.ts"
24
+ ".": "./src/index.ts",
25
+ "./bidi": "./src/bidi.tsx",
26
+ "./button": "./src/button.tsx",
27
+ "./lib/cn": "./src/lib/cn.ts"
25
28
  }
26
29
  }
@@ -27,7 +27,7 @@ test('DatePicker click outside closes popover', async () => {
27
27
  style={{
28
28
  position: 'fixed',
29
29
  top: '0px',
30
- left: '0px',
30
+ insetInlineStart: '0px',
31
31
  width: '50px',
32
32
  height: '50px',
33
33
  background: 'red',
@@ -96,8 +96,7 @@ export function DatePicker({
96
96
  setPopoverStyle({
97
97
  position: 'fixed',
98
98
  top: `${rect.bottom + 8}px`,
99
- left: isRtl ? 'auto' : `${rect.left}px`,
100
- right: isRtl ? `${window.innerWidth - rect.right}px` : 'auto',
99
+ insetInlineStart: `${isRtl ? window.innerWidth - rect.right : rect.left}px`,
101
100
  zIndex: 'var(--noe-z-index-overlay, 50)',
102
101
  });
103
102
  };
@@ -42,14 +42,18 @@ export function GradientPicker({
42
42
  aria-label="Start color"
43
43
  type="color"
44
44
  defaultValue={GRADIENT_START}
45
- onChange={(e) => onValueChange?.(`linear-gradient(90deg, ${e.target.value}, ${GRADIENT_END})`)}
45
+ onChange={(e) =>
46
+ onValueChange?.(`linear-gradient(90deg, ${e.target.value}, ${GRADIENT_END})`)
47
+ }
46
48
  className="size-8 rounded-md border border-border"
47
49
  />
48
50
  <input
49
51
  aria-label="End color"
50
52
  type="color"
51
53
  defaultValue={GRADIENT_END}
52
- onChange={(e) => onValueChange?.(`linear-gradient(90deg, ${GRADIENT_START}, ${e.target.value})`)}
54
+ onChange={(e) =>
55
+ onValueChange?.(`linear-gradient(90deg, ${GRADIENT_START}, ${e.target.value})`)
56
+ }
53
57
  className="size-8 rounded-md border border-border"
54
58
  />
55
59
  <input
@@ -1,4 +1,3 @@
1
- import * as React from 'react';
2
1
  import { expect, test } from 'vitest';
3
2
  import { render } from 'vitest-browser-react';
4
3
 
@@ -10,14 +9,17 @@ function PaletteProbe({ storageKey }: { storageKey?: string }) {
10
9
  return (
11
10
  <div>
12
11
  <span data-testid="palette">{palette}</span>
13
- <button type="button" onClick={() => setPalette('graphite' as never)}>set graphite</button>
14
- {storageKey && <span data-testid="storageKey">{storageKey}</span>}
12
+ <button type="button" onClick={() => setPalette('graphite' as never)}>
13
+ set graphite
14
+ </button>
15
+ {storageKey ? <span data-testid="storageKey">{storageKey}</span> : null}
15
16
  </div>
16
17
  );
17
18
  }
18
19
 
19
20
  test('default Graphite with no preference', async () => {
20
21
  localStorage.clear();
22
+ // biome-ignore lint/suspicious/noDocumentCookie: Test setup must exercise the cookie fallback path.
21
23
  document.cookie = 'noecosystem-palette=; Max-Age=0; path=/';
22
24
  document.documentElement.removeAttribute('data-noe-palette');
23
25
  const screen = await render(
@@ -26,7 +28,9 @@ test('default Graphite with no preference', async () => {
26
28
  </ThemeProvider>,
27
29
  );
28
30
  await expect.element(screen.getByTestId('palette')).toHaveTextContent('graphite');
29
- await expect.poll(() => document.documentElement.getAttribute('data-noe-palette')).toBe('graphite');
31
+ await expect
32
+ .poll(() => document.documentElement.getAttribute('data-noe-palette'))
33
+ .toBe('graphite');
30
34
  });
31
35
 
32
36
  test('valid stored preference restoration', async () => {
@@ -41,6 +45,7 @@ test('valid stored preference restoration', async () => {
41
45
 
42
46
  test('invalid storage and cookie fallback to Graphite', async () => {
43
47
  localStorage.setItem('noecosystem-palette', 'invalid-palette');
48
+ // biome-ignore lint/suspicious/noDocumentCookie: This test needs an invalid cookie value for fallback coverage.
44
49
  document.cookie = 'noecosystem-palette=invalid-palette; path=/';
45
50
  const screen = await render(
46
51
  <ThemeProvider>
@@ -49,7 +54,9 @@ test('invalid storage and cookie fallback to Graphite', async () => {
49
54
  );
50
55
  // Should fallback to graphite
51
56
  await expect.element(screen.getByTestId('palette')).toHaveTextContent('graphite');
52
- await expect.poll(() => document.documentElement.getAttribute('data-noe-palette')).toBe('graphite');
57
+ await expect
58
+ .poll(() => document.documentElement.getAttribute('data-noe-palette'))
59
+ .toBe('graphite');
53
60
  });
54
61
 
55
62
  test('custom paletteStorageKey is honored', async () => {
@@ -84,7 +91,9 @@ test('palette/theme independence in light and dark modes', async () => {
84
91
  </ThemeProvider>,
85
92
  );
86
93
  await expect.element(screen.getByTestId('palette')).toHaveTextContent('graphite');
87
- await expect.poll(() => document.documentElement.getAttribute('data-noe-palette')).toBe('graphite');
94
+ await expect
95
+ .poll(() => document.documentElement.getAttribute('data-noe-palette'))
96
+ .toBe('graphite');
88
97
  await expect.poll(() => document.documentElement.dataset.theme).toBe('light');
89
98
  await expect.element(screen.getByTestId('palette')).toHaveTextContent('graphite');
90
99
  });
@@ -160,8 +160,12 @@ function applyThemeToDocument(theme: ResolvedTheme) {
160
160
  root.style.colorScheme = theme;
161
161
  const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
162
162
  if (meta) {
163
- const lightFallback = (tokens as unknown as { primitive: { color: { paper: Record<string, string> } } }).primitive?.color?.paper?.['100'] ?? '#f3f0ea';
164
- const darkFallback = (tokens as unknown as { primitive: { color: { ink: Record<string, string> } } }).primitive?.color?.ink?.['950'] ?? '#252423';
163
+ const lightFallback =
164
+ (tokens as unknown as { primitive: { color: { paper: Record<string, string> } } }).primitive
165
+ ?.color?.paper?.['100'] ?? '#f3f0ea';
166
+ const darkFallback =
167
+ (tokens as unknown as { primitive: { color: { ink: Record<string, string> } } }).primitive
168
+ ?.color?.ink?.['950'] ?? '#252423';
165
169
  const light = meta.getAttribute('data-theme-light') ?? lightFallback;
166
170
  const dark = meta.getAttribute('data-theme-dark') ?? darkFallback;
167
171
  meta.setAttribute('content', theme === 'light' ? light : dark);
@@ -202,7 +206,8 @@ export function ThemeProvider({
202
206
  });
203
207
 
204
208
  const [palette, setPaletteState] = React.useState<Palette>(() => {
205
- if (typeof window === 'undefined') return isPalette(defaultPalette) ? defaultPalette : DEFAULT_PALETTE;
209
+ if (typeof window === 'undefined')
210
+ return isPalette(defaultPalette) ? defaultPalette : DEFAULT_PALETTE;
206
211
  const stored = readStoredPalette(paletteStorageKey);
207
212
  if (stored && isPalette(stored)) return stored;
208
213
  if (isPalette(defaultPalette)) return defaultPalette;
@@ -307,7 +312,12 @@ export function ThemeProvider({
307
312
 
308
313
  React.useEffect(() => {
309
314
  const stored = readStoredPalette(paletteStorageKey);
310
- const initialPalette = stored && isPalette(stored) ? stored : isPalette(defaultPalette) ? defaultPalette : DEFAULT_PALETTE;
315
+ const initialPalette =
316
+ stored && isPalette(stored)
317
+ ? stored
318
+ : isPalette(defaultPalette)
319
+ ? defaultPalette
320
+ : DEFAULT_PALETTE;
311
321
  const validated = isPalette(initialPalette) ? initialPalette : DEFAULT_PALETTE;
312
322
  setPaletteState(validated);
313
323
  applyPaletteToDocument(validated);
@@ -318,7 +328,11 @@ export function ThemeProvider({
318
328
 
319
329
  React.useEffect(() => {
320
330
  const onStorage = (event: StorageEvent) => {
321
- if (event.key === paletteStorageKey || event.key === PALETTE_STORAGE_KEY || event.key === PALETTE_COOKIE_KEY) {
331
+ if (
332
+ event.key === paletteStorageKey ||
333
+ event.key === PALETTE_STORAGE_KEY ||
334
+ event.key === PALETTE_COOKIE_KEY
335
+ ) {
322
336
  if (isPalette(event.newValue)) {
323
337
  setPaletteState(event.newValue as Palette);
324
338
  applyPaletteToDocument(event.newValue as Palette);
@@ -48,4 +48,4 @@ export function WorkflowCanvas({
48
48
  ) : null}
49
49
  </section>
50
50
  );
51
- }
51
+ }
@@ -58,4 +58,4 @@ export function WorkflowNode({
58
58
  </div>
59
59
  </article>
60
60
  );
61
- }
61
+ }
@@ -46,9 +46,15 @@ for (const item of manifest.items.filter(
46
46
  ? ['DirectionProvider']
47
47
  : [],
48
48
  // Dependencies — merge manifest and payload, keeping externally installable npm packages; workspace @noe/* are satisfied via included files/registryDependencies
49
- registryDependencies: [...new Set([...(item.registryDependencies || []), ...(payload.registryDependencies || [])])],
50
- dependencies: [...new Set([...(item.dependencies || []), ...(payload.dependencies || [])])].filter((d) => !d.startsWith('@noe/')),
51
- peerDependencies: [...new Set([...(item.peerDependencies || []), ...(payload.peerDependencies || [])])],
49
+ registryDependencies: [
50
+ ...new Set([...(item.registryDependencies || []), ...(payload.registryDependencies || [])]),
51
+ ],
52
+ dependencies: [
53
+ ...new Set([...(item.dependencies || []), ...(payload.dependencies || [])]),
54
+ ].filter((d) => !d.startsWith('@noe/')),
55
+ peerDependencies: [
56
+ ...new Set([...(item.peerDependencies || []), ...(payload.peerDependencies || [])]),
57
+ ],
52
58
  // RTL/i18n
53
59
  rtl: item.internationalization?.rtl || 'required',
54
60
  i18n: {
@@ -7,7 +7,8 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
7
  function extractImports(content) {
8
8
  const imports = [];
9
9
  for (const m of content.matchAll(/from\s+['"]([^'"]+)['"]/g)) imports.push(m[1]);
10
- for (const m of content.matchAll(/import\s+['"]([^'"]+)['"]/g)) if (!imports.includes(m[1])) imports.push(m[1]);
10
+ for (const m of content.matchAll(/import\s+['"]([^'"]+)['"]/g))
11
+ if (!imports.includes(m[1])) imports.push(m[1]);
11
12
  return imports;
12
13
  }
13
14
 
@@ -10,6 +10,28 @@ const schemaPath = path.join(root, 'packages/registry/manifest.schema.json');
10
10
  const audit = JSON.parse(await readFile(auditPath, 'utf8'));
11
11
  const _schema = JSON.parse(await readFile(schemaPath, 'utf8'));
12
12
 
13
+ async function writeJsonIfChanged(filePath, value, { preserveGeneratedAt = false } = {}) {
14
+ let existingText = null;
15
+ let existingValue = null;
16
+
17
+ try {
18
+ existingText = await readFile(filePath, 'utf8');
19
+ existingValue = JSON.parse(existingText);
20
+ } catch {}
21
+
22
+ let output = value;
23
+ if (preserveGeneratedAt && existingValue?.generatedAt) {
24
+ const { generatedAt: _previousGeneratedAt, ...previousPayload } = existingValue;
25
+ const { generatedAt: _nextGeneratedAt, ...nextPayload } = value;
26
+ if (JSON.stringify(previousPayload) === JSON.stringify(nextPayload)) {
27
+ output = { ...value, generatedAt: existingValue.generatedAt };
28
+ }
29
+ }
30
+
31
+ const nextText = `${JSON.stringify(output, null, 2)}\n`;
32
+ if (nextText !== existingText) await writeFile(filePath, nextText);
33
+ }
34
+
13
35
  function toExportName(id) {
14
36
  const map = {
15
37
  'bidi-text': 'BidiText',
@@ -764,7 +786,11 @@ function defaultEnrichment(auditItem) {
764
786
  deps.push('@noe/icons');
765
787
  if (auditItem.id === 'filter-bar') registryDeps.push('input');
766
788
  // Hard-coded icon consumers discovered via audit (calendar, inline-cta, date-picker)
767
- if (auditItem.id === 'calendar' || auditItem.id === 'inline-cta' || auditItem.id === 'date-picker') {
789
+ if (
790
+ auditItem.id === 'calendar' ||
791
+ auditItem.id === 'inline-cta' ||
792
+ auditItem.id === 'date-picker'
793
+ ) {
768
794
  if (!deps.includes('@noe/icons')) deps.push('@noe/icons');
769
795
  }
770
796
 
@@ -1041,14 +1067,15 @@ for (const item of cinematicEnrichments) {
1041
1067
  if (!manifest.items.find((i) => i.id === item.id)) manifest.items.push(item);
1042
1068
  }
1043
1069
 
1044
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
1070
+ await writeJsonIfChanged(manifestPath, manifest, { preserveGeneratedAt: true });
1045
1071
  console.log(`manifest.json written: ${manifest.items.length} items`);
1046
1072
 
1047
1073
  // Also generate derived files for backward compat
1048
1074
  // 1. Update component-audit.json to be derived (keep minimal fields but now from manifest)
1049
1075
  const auditDerived = {
1050
1076
  $schema: audit.$schema,
1051
- generatedNote: `${audit.generatedNote} (derived from manifest.json)`,
1077
+ generatedNote:
1078
+ 'Canonical NOE public-component inventory. status=available requires a public export, a Storybook story at storyId, and (where distributable) a registry entry. Do not mark an item available without all three. (derived from manifest.json)',
1052
1079
  items: manifest.items
1053
1080
  .filter((i) => !i.category.startsWith('Cinematic'))
1054
1081
  .map((i) => ({
@@ -1060,7 +1087,7 @@ const auditDerived = {
1060
1087
  registryItem: i.registryItem,
1061
1088
  })),
1062
1089
  };
1063
- await writeFile(auditPath, `${JSON.stringify(auditDerived, null, 2)}\n`);
1090
+ await writeJsonIfChanged(auditPath, auditDerived);
1064
1091
  console.log(`component-audit.json derived: ${auditDerived.items.length} items`);
1065
1092
 
1066
1093
  // 2. Generate search index
@@ -1081,9 +1108,10 @@ const searchIndex = manifest.items.map((i) => ({
1081
1108
  registryItem: i.registryItem,
1082
1109
  maturity: i.maturity,
1083
1110
  }));
1084
- await writeFile(
1111
+ await writeJsonIfChanged(
1085
1112
  path.join(root, 'packages/registry/search-index.json'),
1086
- `${JSON.stringify({ generatedAt: new Date().toISOString(), items: searchIndex }, null, 2)}\n`,
1113
+ { generatedAt: new Date().toISOString(), items: searchIndex },
1114
+ { preserveGeneratedAt: true },
1087
1115
  );
1088
1116
  console.log(`search-index.json written`);
1089
1117
 
@@ -1103,47 +1131,41 @@ for (const item of registry.items) {
1103
1131
  item.searchTerms = m.searchTerms;
1104
1132
  }
1105
1133
  }
1106
- await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
1134
+ await writeJsonIfChanged(registryPath, registry);
1107
1135
  console.log(`registry.json enriched`);
1108
1136
 
1109
1137
  // 4. Generate catalog index (JSON for agents)
1110
- await writeFile(
1138
+ await writeJsonIfChanged(
1111
1139
  path.join(root, 'packages/registry/catalog-index.json'),
1112
- `${JSON.stringify(
1113
- {
1114
- generatedAt: new Date().toISOString(),
1115
- items: manifest.items.map((i) => ({
1116
- id: i.id,
1117
- title: i.title,
1118
- category: i.category,
1119
- package: i.package,
1120
- status: i.status,
1121
- maturity: i.maturity,
1122
- description: i.description,
1123
- registryItem: i.registryItem,
1124
- })),
1125
- },
1126
- null,
1127
- 2,
1128
- )}\n`,
1140
+ {
1141
+ generatedAt: new Date().toISOString(),
1142
+ items: manifest.items.map((i) => ({
1143
+ id: i.id,
1144
+ title: i.title,
1145
+ category: i.category,
1146
+ package: i.package,
1147
+ status: i.status,
1148
+ maturity: i.maturity,
1149
+ description: i.description,
1150
+ registryItem: i.registryItem,
1151
+ })),
1152
+ },
1153
+ { preserveGeneratedAt: true },
1129
1154
  );
1130
1155
  console.log(`catalog-index.json written`);
1131
1156
 
1132
1157
  // 5. Generate docs index
1133
- await writeFile(
1158
+ await writeJsonIfChanged(
1134
1159
  path.join(root, 'packages/registry/docs-index.json'),
1135
- `${JSON.stringify(
1136
- {
1137
- generatedAt: new Date().toISOString(),
1138
- items: manifest.items.map((i) => ({
1139
- id: i.id,
1140
- title: i.title,
1141
- category: i.category,
1142
- examples: i.examples,
1143
- })),
1144
- },
1145
- null,
1146
- 2,
1147
- )}\n`,
1160
+ {
1161
+ generatedAt: new Date().toISOString(),
1162
+ items: manifest.items.map((i) => ({
1163
+ id: i.id,
1164
+ title: i.title,
1165
+ category: i.category,
1166
+ examples: i.examples,
1167
+ })),
1168
+ },
1169
+ { preserveGeneratedAt: true },
1148
1170
  );
1149
1171
  console.log(`docs-index.json written`);
@@ -276,7 +276,7 @@ if (errors === 0) pass('semantic search fields');
276
276
  .map((i) => i.registryItem);
277
277
  const counts = new Map();
278
278
  for (const n of regItems) counts.set(n, (counts.get(n) || 0) + 1);
279
- const dups = [...counts.entries()].filter(([name, c]) => c > 1).map(([name]) => name);
279
+ const dups = [...counts.entries()].filter(([, c]) => c > 1).map(([name]) => name);
280
280
  const allowedShared = new Set(['input']); // field shares input payload
281
281
  const illegalDups = dups.filter((n) => !allowedShared.has(n));
282
282
  if (illegalDups.length) {
@@ -292,7 +292,9 @@ if (errors === 0) pass('semantic search fields');
292
292
  // 13. Source-file and payload existence
293
293
  {
294
294
  let missing = 0;
295
- for (const item of manifest.items.filter((i) => i.status === 'available' || i.status === 'partial')) {
295
+ for (const item of manifest.items.filter(
296
+ (i) => i.status === 'available' || i.status === 'partial',
297
+ )) {
296
298
  if (!item.registryItem) {
297
299
  fail(`available/partial ${item.id} missing registryItem`);
298
300
  errors++;
@@ -323,7 +325,9 @@ if (errors === 0) pass('semantic search fields');
323
325
  // 14. Registry payload closure (relative imports must be included)
324
326
  {
325
327
  let closureErrors = 0;
326
- for (const item of manifest.items.filter((i) => i.status === 'available' || i.status === 'partial')) {
328
+ for (const item of manifest.items.filter(
329
+ (i) => i.status === 'available' || i.status === 'partial',
330
+ )) {
327
331
  if (!item.registryItem) continue;
328
332
  const payloadPath = path.join(root, `packages/registry/r/${item.registryItem}.json`);
329
333
  let payload;
@@ -364,18 +368,9 @@ if (errors === 0) pass('semantic search fields');
364
368
  errors++;
365
369
  closureErrors++;
366
370
  } else if (!fileSet.has(resolved)) {
367
- // Allow if resolved is provided via registryDependencies payload (check if in dep payload)
368
- const depPayloads = (payload.registryDependencies || [])
369
- .map((d) => d.replace('@noe/', ''))
370
- .map((n) => {
371
- try {
372
- return { name: n, exists: true };
373
- } catch {
374
- return null;
375
- }
376
- });
377
- // For simplicity, if not in current payload, check if it's in any other payload that is a dependency (we already include via files)
378
- fail(`closure: ${item.registryItem} ${f.path} imports '${imp}' -> ${resolved} not in payload`);
371
+ fail(
372
+ `closure: ${item.registryItem} ${f.path} imports '${imp}' -> ${resolved} not in payload`,
373
+ );
379
374
  errors++;
380
375
  closureErrors++;
381
376
  }
@@ -388,9 +383,10 @@ if (errors === 0) pass('semantic search fields');
388
383
  // 15. Story existence for every available/partial
389
384
  {
390
385
  let missingStory = 0;
391
- const storyFiles = new Set();
392
386
  // Collect story ids from filesystem (reusing earlier logic would duplicate, but we check file existence directly)
393
- for (const item of manifest.items.filter((i) => i.status === 'available' || i.status === 'partial')) {
387
+ for (const item of manifest.items.filter(
388
+ (i) => i.status === 'available' || i.status === 'partial',
389
+ )) {
394
390
  if (!item.storyId) {
395
391
  fail(`available/partial ${item.id} missing storyId`);
396
392
  errors++;
@@ -417,14 +413,18 @@ if (errors === 0) pass('semantic search fields');
417
413
  // 16. Install command correctness
418
414
  {
419
415
  let badInstall = 0;
420
- for (const item of manifest.items.filter((i) => i.status === 'available' || i.status === 'partial')) {
416
+ for (const item of manifest.items.filter(
417
+ (i) => i.status === 'available' || i.status === 'partial',
418
+ )) {
421
419
  if (!item.registryItem) continue;
422
420
  const payloadPath = path.join(root, `packages/registry/r/${item.registryItem}.json`);
423
421
  try {
424
422
  const payload = await json(payloadPath);
425
423
  const expected = `pnpm dlx shadcn@latest add @noe/${item.registryItem}`;
426
424
  if (payload.install !== expected) {
427
- fail(`install command mismatch for ${item.registryItem}: expected "${expected}" got "${payload.install}"`);
425
+ fail(
426
+ `install command mismatch for ${item.registryItem}: expected "${expected}" got "${payload.install}"`,
427
+ );
428
428
  errors++;
429
429
  badInstall++;
430
430
  }
@@ -443,7 +443,9 @@ if (errors === 0) pass('semantic search fields');
443
443
  errors++;
444
444
  plannedLeak++;
445
445
  }
446
- const rPath = item.registryItem ? path.join(root, `packages/registry/r/${item.registryItem}.json`) : null;
446
+ const rPath = item.registryItem
447
+ ? path.join(root, `packages/registry/r/${item.registryItem}.json`)
448
+ : null;
447
449
  if (rPath) {
448
450
  try {
449
451
  await stat(rPath);
@@ -456,19 +458,8 @@ if (errors === 0) pass('semantic search fields');
456
458
  if (plannedLeak === 0) pass('no public registry item for planned');
457
459
  }
458
460
 
459
- // 18. Preview contract — every available must have a preview that is not a fake control for planned
460
- {
461
- // We check catalog-index preview contract via manifest status: planned must not have registryItem and must be spec-only in UI (verified via Playwright)
462
- // This check ensures manifest correctly marks preview contract via status
463
- let badPreview = 0;
464
- for (const item of manifest.items) {
465
- if (item.status === 'planned' && item.examples.some((e) => e.files.some((f) => f.includes('stories')))) {
466
- // Planned items should not have interactive story files that imply implementation
467
- // We allow story files for planned only if they are spec placeholders — currently all planned share generic stories, so we skip
468
- }
469
- }
470
- if (badPreview === 0) pass('preview contract');
471
- }
461
+ // 18. Preview contract — planned entries must remain spec-only, as validated by Playwright.
462
+ pass('preview contract');
472
463
 
473
464
  if (errors) {
474
465
  console.error(`${errors} validation errors`);
@@ -160,11 +160,22 @@ async function testConsumer({ name, rsc, items }) {
160
160
  await run('pnpm', ['install'], { cwd: consumer });
161
161
 
162
162
  // Batch install all items in one shadcn invocation (serial but batched for speed) — still verifies every payload individually
163
- console.log(`\n[${name}] Installing ${items.length} payloads: ${items.slice(0, 3).join(', ')}...`);
163
+ console.log(
164
+ `\n[${name}] Installing ${items.length} payloads: ${items.slice(0, 3).join(', ')}...`,
165
+ );
164
166
  try {
165
167
  const { stdout, stderr } = await run(
166
168
  'pnpm',
167
- ['dlx', 'shadcn@latest', 'add', ...items.map((i) => `@noe/${i}`), '--yes', '--overwrite', '--cwd', consumer],
169
+ [
170
+ 'dlx',
171
+ 'shadcn@latest',
172
+ 'add',
173
+ ...items.map((i) => `@noe/${i}`),
174
+ '--yes',
175
+ '--overwrite',
176
+ '--cwd',
177
+ consumer,
178
+ ],
168
179
  { cwd: root },
169
180
  );
170
181
  if (stdout) process.stdout.write(stdout);
@@ -180,11 +191,18 @@ async function testConsumer({ name, rsc, items }) {
180
191
  const files = await walk(consumer);
181
192
  let payloadCheck;
182
193
  try {
183
- payloadCheck = JSON.parse(await readFile(path.join(root, `packages/registry/r/${item}.json`), 'utf8'));
194
+ payloadCheck = JSON.parse(
195
+ await readFile(path.join(root, `packages/registry/r/${item}.json`), 'utf8'),
196
+ );
184
197
  } catch {}
185
- const expectedBasenames = payloadCheck ? payloadCheck.files.map((f) => path.basename(f.path)) : [item];
198
+ const expectedBasenames = payloadCheck
199
+ ? payloadCheck.files.map((f) => path.basename(f.path))
200
+ : [item];
186
201
  const found = files.find((f) => expectedBasenames.some((b) => f.endsWith(b)));
187
- assert.ok(found, `[${name}] shadcn did not install ${item}; expected one of ${expectedBasenames.join(', ')}, found: ${files.slice(0, 30).join(', ')}`);
202
+ assert.ok(
203
+ found,
204
+ `[${name}] shadcn did not install ${item}; expected one of ${expectedBasenames.join(', ')}, found: ${files.slice(0, 30).join(', ')}`,
205
+ );
188
206
  console.log(`[${name}] Verified ${item} -> ${found}`);
189
207
  for (const pf of payloadCheck?.files || []) {
190
208
  const targetPath = pf.target || pf.path;
@@ -231,7 +249,10 @@ async function testConsumer({ name, rsc, items }) {
231
249
  }
232
250
 
233
251
  // Build via vite to satisfy production build requirement — per-item import verification already done via tsc
234
- await writeFile(path.join(consumer, 'src/entry.tsx'), `import * as React from 'react';\nexport const ok = true;\n`);
252
+ await writeFile(
253
+ path.join(consumer, 'src/entry.tsx'),
254
+ `import * as React from 'react';\nexport const ok = true;\n`,
255
+ );
235
256
  await run('pnpm', ['exec', 'vite', 'build'], { cwd: consumer });
236
257
  console.log(`[${name}] vite build PASS for all ${items.length} items`);
237
258
 
@@ -307,7 +328,12 @@ try {
307
328
  skipped: planned.length,
308
329
  skippedIds: planned,
309
330
  installable: available,
310
- results: results.map((r) => ({ name: r.name, verified: r.verified, failed: r.failed, failures: r.failures })),
331
+ results: results.map((r) => ({
332
+ name: r.name,
333
+ verified: r.verified,
334
+ failed: r.failed,
335
+ failures: r.failures,
336
+ })),
311
337
  };
312
338
  await writeFile(
313
339
  path.join(root, 'artifacts/registry-consumer-report.json'),
@@ -3,7 +3,9 @@ import { expect, test } from '@playwright/test';
3
3
  import { CATALOG_URL } from '../../playwright.config';
4
4
 
5
5
  test.describe('Catalog locale typography', () => {
6
- test('Persian locale waits for IRANYekanX and renders Persian with correct font', async ({ page }) => {
6
+ test('Persian locale waits for IRANYekanX and renders Persian with correct font', async ({
7
+ page,
8
+ }) => {
7
9
  await page.goto(`${CATALOG_URL}?locale=fa`);
8
10
  // Wait for effectiveLocale to become fa (font ready) — html lang becomes fa only after font check passes
9
11
  await expect(page.locator('html')).toHaveAttribute('lang', 'fa', { timeout: 10000 });
@@ -28,7 +30,8 @@ test.describe('Catalog locale typography', () => {
28
30
  // Find an English technical value inside the Persian page (e.g., Bidi preview's TechnicalValue)
29
31
  const englishInRtl = page.locator('text=a1b2c3d4').first();
30
32
  // If not found, fallback to any LTR isolated element
31
- const target = (await englishInRtl.count()) > 0 ? englishInRtl : page.locator('[dir="ltr"]').first();
33
+ const target =
34
+ (await englishInRtl.count()) > 0 ? englishInRtl : page.locator('[dir="ltr"]').first();
32
35
  await expect(target).toBeVisible();
33
36
  const fontFamily = await target.evaluate((el) => getComputedStyle(el).fontFamily);
34
37
  // Should NOT be IRANYekanX, should be SF Pro or fallback
@@ -86,8 +89,7 @@ test.describe('Catalog locale typography', () => {
86
89
  for (let i = 0; i < Math.min(3, count); i++) {
87
90
  const card = availableCards.nth(i);
88
91
  await expect(card).toBeVisible();
89
- // Preview container should have at least one element
90
- const previewContainer = card.locator('div').filter({ hasText: '' }).first();
92
+ // Preview container should have at least one element.
91
93
  await expect(card.locator('div').nth(2)).not.toBeEmpty();
92
94
  }
93
95
  // Spot check Button
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import test from 'node:test';
4
4
 
5
5
  const iconSource = await readFile(
6
- new URL('../packages/icons/src/index.tsx', import.meta.url),
6
+ new URL('../packages/icons/src/iconsax.tsx', import.meta.url),
7
7
  'utf8',
8
8
  );
9
9
  const baseCss = await readFile(
@@ -11,10 +11,10 @@ const baseCss = await readFile(
11
11
  'utf8',
12
12
  );
13
13
 
14
- test('the vetted Icons8 chevron uses the logical icon contract', () => {
15
- assert.match(iconSource, /export const icons8Source/);
16
- assert.match(iconSource, /provider: 'Icons8'/);
17
- assert.match(iconSource, /data-icon-source="icons8"/);
14
+ test('the vetted Iconsax wrapper uses the logical icon contract', () => {
15
+ assert.match(iconSource, /export const iconsaxFreeSource/);
16
+ assert.match(iconSource, /provider: 'Iconsax Free'/);
17
+ assert.match(iconSource, /data-icon-source="iconsax-free"/);
18
18
  assert.match(iconSource, /data-logical-direction=\{direction\}/);
19
19
  assert.match(baseCss, /\.noe-logical-icon\[data-logical-direction="backward"\]/);
20
20
  assert.match(baseCss, /\[dir="rtl"\] \.noe-logical-icon\[data-logical-direction="forward"\]/);