noecosystem-design 0.1.3 → 0.2.1

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 (48) hide show
  1. package/README.md +73 -35
  2. package/apps/showcase/src/catalog/catalog-index.tsx +3 -3
  3. package/apps/storybook/stories/catalog/card.stories.tsx +13 -0
  4. package/apps/storybook/stories/catalog/icon-system.stories.tsx +3 -2
  5. package/docs/adr/0006-icons8-icon-policy.md +4 -4
  6. package/docs/design-system/architecture.md +1 -1
  7. package/docs/design-system/ci-cd.md +22 -7
  8. package/docs/design-system/components.md +5 -0
  9. package/docs/design-system/foundations.md +9 -0
  10. package/docs/design-system/rtl.md +1 -1
  11. package/docs/design-system/surface-policy.md +59 -0
  12. package/llms.txt +1 -0
  13. package/package.json +1 -1
  14. package/packages/design-tokens/src/base.css +1 -1
  15. package/packages/design-tokens/src/tokens.css +12 -12
  16. package/packages/design-tokens/src/tokens.json +8 -8
  17. package/packages/design-tokens/src/tokens.mjs +8 -8
  18. package/packages/icons/package.json +3 -0
  19. package/packages/icons/src/adapters.tsx +4 -4
  20. package/packages/icons/src/iconsax.tsx +233 -0
  21. package/packages/icons/src/index.tsx +1 -103
  22. package/packages/registry/catalog-index.json +2 -2
  23. package/packages/registry/component-audit.json +1 -1
  24. package/packages/registry/docs-index.json +1 -1
  25. package/packages/registry/manifest.json +27 -14
  26. package/packages/registry/r/calendar.json +12 -1
  27. package/packages/registry/r/card.json +11 -5
  28. package/packages/registry/r/date-picker.json +12 -1
  29. package/packages/registry/r/icon-system.json +6 -1
  30. package/packages/registry/r/inline-cta.json +6 -1
  31. package/packages/registry/r/pagination.json +12 -1
  32. package/packages/registry/registry.json +8 -2
  33. package/packages/registry/search-index.json +21 -8
  34. package/packages/ui/src/card.tsx +14 -2
  35. package/packages/ui/src/theme-provider.browser.test.tsx +3 -2
  36. package/scripts/generate-manifest.mjs +92 -39
  37. package/scripts/validate-manifest.mjs +3 -29
  38. package/tests/e2e/catalog-typography.spec.ts +1 -2
  39. package/tests/icons.test.mjs +5 -5
  40. package/tests/tokens.test.mjs +14 -0
  41. 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
  42. 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
  43. package/apps/showcase/src/catalog/__screenshots__/catalog-shell.browser.test.tsx/switching-locale-to-Persian-updates-document-direction-to-rtl-1.png +0 -0
  44. package/packages/ui/src/__screenshots__/button.browser.test.tsx/Button-preserves-native--busy--disabled--and-ref-contracts-1.png +0 -0
  45. package/packages/ui/src/__screenshots__/button.browser.test.tsx/a-disabled-polymorphic-Button-cannot-navigate-or-receive-focus-1.png +0 -0
  46. package/packages/ui/src/__screenshots__/date-picker.browser.test.tsx/DatePicker-Escape-closes-popover-and-returns-focus-to-input-1.png +0 -0
  47. package/packages/ui/src/__screenshots__/date-picker.browser.test.tsx/DatePicker-click-outside-closes-popover-1.png +0 -0
  48. package/packages/ui/src/__screenshots__/dialog.browser.test.tsx/Dialog-propagates-RTL-into-the-portal-and-traps-then-restores-focus-1.png +0 -0
@@ -2,13 +2,25 @@ import type * as React from 'react';
2
2
 
3
3
  import { cn } from './lib/cn';
4
4
 
5
- export function Card({ className, ...props }: React.ComponentProps<'section'>) {
5
+ export type CardAppearance = 'raised' | 'outlined';
6
+
7
+ export interface CardProps extends React.ComponentProps<'section'> {
8
+ /**
9
+ * Raised surfaces are separated from the page by a semantic surface tone only.
10
+ * Use outlined only where a visible boundary conveys data-entry or selection state.
11
+ */
12
+ appearance?: CardAppearance;
13
+ }
14
+
15
+ export function Card({ appearance = 'raised', className, ...props }: CardProps) {
6
16
  return (
7
17
  <section
8
18
  className={cn(
9
- 'flex flex-col overflow-hidden rounded-lg border border-border bg-surface-raised text-foreground',
19
+ 'flex flex-col overflow-hidden rounded-lg bg-surface-raised text-foreground',
20
+ appearance === 'outlined' && 'border border-border',
10
21
  className,
11
22
  )}
23
+ data-appearance={appearance}
12
24
  data-slot="card"
13
25
  {...props}
14
26
  />
@@ -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
 
@@ -13,13 +12,14 @@ function PaletteProbe({ storageKey }: { storageKey?: string }) {
13
12
  <button type="button" onClick={() => setPalette('graphite' as never)}>
14
13
  set graphite
15
14
  </button>
16
- {storageKey && <span data-testid="storageKey">{storageKey}</span>}
15
+ {storageKey ? <span data-testid="storageKey">{storageKey}</span> : null}
17
16
  </div>
18
17
  );
19
18
  }
20
19
 
21
20
  test('default Graphite with no preference', async () => {
22
21
  localStorage.clear();
22
+ // biome-ignore lint/suspicious/noDocumentCookie: Test setup must exercise the cookie fallback path.
23
23
  document.cookie = 'noecosystem-palette=; Max-Age=0; path=/';
24
24
  document.documentElement.removeAttribute('data-noe-palette');
25
25
  const screen = await render(
@@ -45,6 +45,7 @@ test('valid stored preference restoration', async () => {
45
45
 
46
46
  test('invalid storage and cookie fallback to Graphite', async () => {
47
47
  localStorage.setItem('noecosystem-palette', 'invalid-palette');
48
+ // biome-ignore lint/suspicious/noDocumentCookie: This test needs an invalid cookie value for fallback coverage.
48
49
  document.cookie = 'noecosystem-palette=invalid-palette; path=/';
49
50
  const screen = await render(
50
51
  <ThemeProvider>
@@ -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',
@@ -196,6 +218,41 @@ const enrichments = {
196
218
  notes: 'Variants parity full; loading and destructive covered',
197
219
  },
198
220
  },
221
+ card: {
222
+ description:
223
+ 'Flat raised section primitive. It separates content from its page canvas through semantic surface tone, spacing and radius—not decorative borders or shadows. Use outlined only where a boundary conveys selection or data-entry state.',
224
+ capabilityAliases: ['flat card', 'raised surface', 'borderless section', 'outlined card'],
225
+ searchTerms: [
226
+ 'card',
227
+ 'raised surface',
228
+ 'flat section',
229
+ 'borderless panel',
230
+ 'outlined boundary',
231
+ ],
232
+ useWhen: [
233
+ 'grouping a top-level application region with a semantic raised surface',
234
+ 'separating sections by color tone rather than elevation or decorative chrome',
235
+ 'using appearance="outlined" only for selection, data-entry or comparison boundaries',
236
+ ],
237
+ doNotUseWhen: [
238
+ 'adding a nested card solely to create visual depth',
239
+ 'using an outlined boundary when spacing and surface tone already express grouping',
240
+ ],
241
+ alternatives: ['SectionHeader', 'Field'],
242
+ variants: ['raised', 'outlined'],
243
+ states: ['default'],
244
+ tokenContracts: ['color.background', 'color.surface', 'color.surfaceRaised', 'radius.lg'],
245
+ accessibility: {
246
+ keyboard: [],
247
+ announcements: [],
248
+ focusBehavior: ['not focusable unless its content contains an interactive control'],
249
+ },
250
+ internationalization: {
251
+ rtl: 'required',
252
+ bidiNotes: ['logical spacing; no directional layout override'],
253
+ },
254
+ relatedPatterns: ['app-frame', 'dashboard-shell', 'section-header'],
255
+ },
199
256
  dialog: {
200
257
  description:
201
258
  'Direction-preserving modal primitive with native focus semantics and portal propagation. Share foundation with Modal/Slideout.',
@@ -804,7 +861,7 @@ function defaultEnrichment(auditItem) {
804
861
  dependencies: deps,
805
862
  registryDependencies: registryDeps,
806
863
  peerDependencies: peerDeps,
807
- propsSummary: { label: 'string | ReactNode', children: 'ReactNode' },
864
+ propsSummary: base.propsSummary ?? { label: 'string | ReactNode', children: 'ReactNode' },
808
865
  variants,
809
866
  sizes,
810
867
  slots,
@@ -1045,14 +1102,15 @@ for (const item of cinematicEnrichments) {
1045
1102
  if (!manifest.items.find((i) => i.id === item.id)) manifest.items.push(item);
1046
1103
  }
1047
1104
 
1048
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
1105
+ await writeJsonIfChanged(manifestPath, manifest, { preserveGeneratedAt: true });
1049
1106
  console.log(`manifest.json written: ${manifest.items.length} items`);
1050
1107
 
1051
1108
  // Also generate derived files for backward compat
1052
1109
  // 1. Update component-audit.json to be derived (keep minimal fields but now from manifest)
1053
1110
  const auditDerived = {
1054
1111
  $schema: audit.$schema,
1055
- generatedNote: `${audit.generatedNote} (derived from manifest.json)`,
1112
+ generatedNote:
1113
+ '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)',
1056
1114
  items: manifest.items
1057
1115
  .filter((i) => !i.category.startsWith('Cinematic'))
1058
1116
  .map((i) => ({
@@ -1064,7 +1122,7 @@ const auditDerived = {
1064
1122
  registryItem: i.registryItem,
1065
1123
  })),
1066
1124
  };
1067
- await writeFile(auditPath, `${JSON.stringify(auditDerived, null, 2)}\n`);
1125
+ await writeJsonIfChanged(auditPath, auditDerived);
1068
1126
  console.log(`component-audit.json derived: ${auditDerived.items.length} items`);
1069
1127
 
1070
1128
  // 2. Generate search index
@@ -1085,9 +1143,10 @@ const searchIndex = manifest.items.map((i) => ({
1085
1143
  registryItem: i.registryItem,
1086
1144
  maturity: i.maturity,
1087
1145
  }));
1088
- await writeFile(
1146
+ await writeJsonIfChanged(
1089
1147
  path.join(root, 'packages/registry/search-index.json'),
1090
- `${JSON.stringify({ generatedAt: new Date().toISOString(), items: searchIndex }, null, 2)}\n`,
1148
+ { generatedAt: new Date().toISOString(), items: searchIndex },
1149
+ { preserveGeneratedAt: true },
1091
1150
  );
1092
1151
  console.log(`search-index.json written`);
1093
1152
 
@@ -1107,47 +1166,41 @@ for (const item of registry.items) {
1107
1166
  item.searchTerms = m.searchTerms;
1108
1167
  }
1109
1168
  }
1110
- await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
1169
+ await writeJsonIfChanged(registryPath, registry);
1111
1170
  console.log(`registry.json enriched`);
1112
1171
 
1113
1172
  // 4. Generate catalog index (JSON for agents)
1114
- await writeFile(
1173
+ await writeJsonIfChanged(
1115
1174
  path.join(root, 'packages/registry/catalog-index.json'),
1116
- `${JSON.stringify(
1117
- {
1118
- generatedAt: new Date().toISOString(),
1119
- items: manifest.items.map((i) => ({
1120
- id: i.id,
1121
- title: i.title,
1122
- category: i.category,
1123
- package: i.package,
1124
- status: i.status,
1125
- maturity: i.maturity,
1126
- description: i.description,
1127
- registryItem: i.registryItem,
1128
- })),
1129
- },
1130
- null,
1131
- 2,
1132
- )}\n`,
1175
+ {
1176
+ generatedAt: new Date().toISOString(),
1177
+ items: manifest.items.map((i) => ({
1178
+ id: i.id,
1179
+ title: i.title,
1180
+ category: i.category,
1181
+ package: i.package,
1182
+ status: i.status,
1183
+ maturity: i.maturity,
1184
+ description: i.description,
1185
+ registryItem: i.registryItem,
1186
+ })),
1187
+ },
1188
+ { preserveGeneratedAt: true },
1133
1189
  );
1134
1190
  console.log(`catalog-index.json written`);
1135
1191
 
1136
1192
  // 5. Generate docs index
1137
- await writeFile(
1193
+ await writeJsonIfChanged(
1138
1194
  path.join(root, 'packages/registry/docs-index.json'),
1139
- `${JSON.stringify(
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
- examples: i.examples,
1147
- })),
1148
- },
1149
- null,
1150
- 2,
1151
- )}\n`,
1195
+ {
1196
+ generatedAt: new Date().toISOString(),
1197
+ items: manifest.items.map((i) => ({
1198
+ id: i.id,
1199
+ title: i.title,
1200
+ category: i.category,
1201
+ examples: i.examples,
1202
+ })),
1203
+ },
1204
+ { preserveGeneratedAt: true },
1152
1205
  );
1153
1206
  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) {
@@ -368,17 +368,6 @@ if (errors === 0) pass('semantic search fields');
368
368
  errors++;
369
369
  closureErrors++;
370
370
  } else if (!fileSet.has(resolved)) {
371
- // Allow if resolved is provided via registryDependencies payload (check if in dep payload)
372
- const depPayloads = (payload.registryDependencies || [])
373
- .map((d) => d.replace('@noe/', ''))
374
- .map((n) => {
375
- try {
376
- return { name: n, exists: true };
377
- } catch {
378
- return null;
379
- }
380
- });
381
- // For simplicity, if not in current payload, check if it's in any other payload that is a dependency (we already include via files)
382
371
  fail(
383
372
  `closure: ${item.registryItem} ${f.path} imports '${imp}' -> ${resolved} not in payload`,
384
373
  );
@@ -394,7 +383,6 @@ if (errors === 0) pass('semantic search fields');
394
383
  // 15. Story existence for every available/partial
395
384
  {
396
385
  let missingStory = 0;
397
- const storyFiles = new Set();
398
386
  // Collect story ids from filesystem (reusing earlier logic would duplicate, but we check file existence directly)
399
387
  for (const item of manifest.items.filter(
400
388
  (i) => i.status === 'available' || i.status === 'partial',
@@ -470,22 +458,8 @@ if (errors === 0) pass('semantic search fields');
470
458
  if (plannedLeak === 0) pass('no public registry item for planned');
471
459
  }
472
460
 
473
- // 18. Preview contract — every available must have a preview that is not a fake control for planned
474
- {
475
- // We check catalog-index preview contract via manifest status: planned must not have registryItem and must be spec-only in UI (verified via Playwright)
476
- // This check ensures manifest correctly marks preview contract via status
477
- let badPreview = 0;
478
- for (const item of manifest.items) {
479
- if (
480
- item.status === 'planned' &&
481
- item.examples.some((e) => e.files.some((f) => f.includes('stories')))
482
- ) {
483
- // Planned items should not have interactive story files that imply implementation
484
- // We allow story files for planned only if they are spec placeholders — currently all planned share generic stories, so we skip
485
- }
486
- }
487
- if (badPreview === 0) pass('preview contract');
488
- }
461
+ // 18. Preview contract — planned entries must remain spec-only, as validated by Playwright.
462
+ pass('preview contract');
489
463
 
490
464
  if (errors) {
491
465
  console.error(`${errors} validation errors`);
@@ -89,8 +89,7 @@ test.describe('Catalog locale typography', () => {
89
89
  for (let i = 0; i < Math.min(3, count); i++) {
90
90
  const card = availableCards.nth(i);
91
91
  await expect(card).toBeVisible();
92
- // Preview container should have at least one element
93
- const previewContainer = card.locator('div').filter({ hasText: '' }).first();
92
+ // Preview container should have at least one element.
94
93
  await expect(card.locator('div').nth(2)).not.toBeEmpty();
95
94
  }
96
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"\]/);
@@ -22,3 +22,17 @@ test('token CSS includes theme and reduced-motion contracts', async () => {
22
22
  assert.match(css, /--noe-primitive-color-paper-100:\s*#[0-9a-f]{6}/i);
23
23
  assert.match(css, /--noe-primitive-color-mint-100:\s*#[0-9a-f]{6}/i);
24
24
  });
25
+
26
+ test('surface elevation is intentionally shadow-free in both themes', async () => {
27
+ assert.equal(tokens.shadow.light.raised, 'none');
28
+ assert.equal(tokens.shadow.light.overlay, 'none');
29
+ assert.equal(tokens.shadow.dark.raised, 'none');
30
+ assert.equal(tokens.shadow.dark.overlay, 'none');
31
+
32
+ const css = await readFile(
33
+ new URL('../packages/design-tokens/src/tokens.css', import.meta.url),
34
+ 'utf8',
35
+ );
36
+ assert.match(css, /\[data-theme="light"\][\s\S]*?--noe-shadow-raised:\s*none/);
37
+ assert.match(css, /\[data-theme="dark"\][\s\S]*?--noe-shadow-overlay:\s*none/);
38
+ });