@estiva-app/ui 0.26.0 → 0.28.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 (62) hide show
  1. package/dist/gates/app-checks.d.ts.map +1 -1
  2. package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
  3. package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
  4. package/dist/gates/{chunk-EA33NP5B.js → chunk-XE6HC5MJ.js} +8 -12
  5. package/dist/gates/chunk-XE6HC5MJ.js.map +7 -0
  6. package/dist/gates/cli.js +1 -1
  7. package/dist/gates/create-app.d.ts.map +1 -1
  8. package/dist/gates/create-app.js +1 -1
  9. package/dist/gates/gate-config.d.ts +1 -1
  10. package/dist/gates/gate-config.d.ts.map +1 -1
  11. package/dist/gates/index.d.ts +2 -1
  12. package/dist/gates/index.d.ts.map +1 -1
  13. package/dist/gates/index.js +36 -6
  14. package/dist/gates/index.js.map +2 -2
  15. package/dist/gates/status.d.ts +2 -0
  16. package/dist/gates/status.d.ts.map +1 -1
  17. package/dist/gates/token-lint.d.ts +27 -1
  18. package/dist/gates/token-lint.d.ts.map +1 -1
  19. package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
  20. package/dist/registry/app.d.ts.map +1 -1
  21. package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
  22. package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
  23. package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
  24. package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
  25. package/dist/registry/cli.js +69 -8
  26. package/dist/registry/cli.js.map +3 -3
  27. package/dist/registry/contract.d.ts +29 -0
  28. package/dist/registry/contract.d.ts.map +1 -0
  29. package/dist/registry/gated-5CPIUQAW.js +230 -0
  30. package/dist/registry/gated-5CPIUQAW.js.map +7 -0
  31. package/dist/registry/gated.d.ts +31 -0
  32. package/dist/registry/gated.d.ts.map +1 -0
  33. package/dist/registry/index.d.ts +1 -0
  34. package/dist/registry/index.d.ts.map +1 -1
  35. package/dist/registry/index.js +15 -3
  36. package/package.json +1 -1
  37. package/registry.json +1 -1
  38. package/src/CollapsibleSection.stories.tsx +6 -0
  39. package/src/ListColumn.stories.tsx +6 -0
  40. package/src/MenuItem.stories.tsx +151 -135
  41. package/src/Person.stories.tsx +7 -0
  42. package/src/PersonTrigger.stories.tsx +7 -0
  43. package/src/SectionHeader.stories.tsx +5 -0
  44. package/src/gates/app-checks.ts +19 -2
  45. package/src/gates/create-app.ts +7 -11
  46. package/src/gates/gate-config.ts +3 -2
  47. package/src/gates/gates.test.ts +35 -1
  48. package/src/gates/index.ts +2 -1
  49. package/src/gates/status.ts +15 -0
  50. package/src/gates/token-lint.ts +47 -2
  51. package/src/registry/app.test.ts +14 -1
  52. package/src/registry/app.ts +5 -1
  53. package/src/registry/cli.ts +79 -7
  54. package/src/registry/contract.test.ts +158 -0
  55. package/src/registry/contract.ts +108 -0
  56. package/src/registry/gated.test.ts +98 -0
  57. package/src/registry/gated.ts +308 -0
  58. package/src/registry/index.ts +1 -0
  59. package/dist/gates/chunk-EA33NP5B.js.map +0 -7
  60. package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
  61. package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
  62. /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
@@ -1,135 +1,151 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconCopy, IconPin, IconTrash } from '@tabler/icons-react'
3
- import { Avatar } from './Avatar'
4
- import { useRef, useState } from 'react'
5
- import { Button } from './Button'
6
- import { Divider } from './Divider'
7
- import { EnterHint, Menu, MenuItem, MenuPanel, MenuSub } from './Menu'
8
-
9
- /**
10
- * One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
11
- * with an optional `description` line · one thing at the right edge —
12
- * `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
13
- * chevron. Stories sit on a `MenuPanel` — the menu's surface, drawn without
14
- * its behaviour — so the row is seen where it lives. A live menu portals and
15
- * places itself, so it cannot stand in the page (Katerina, D25); **Menu →
16
- * FromATrigger** is where the keyboard and the placement are.
17
- */
18
- const meta = {
19
- title: 'Overlays/MenuItem',
20
- component: MenuItem,
21
- decorators: [
22
- (Story) => (
23
- <div className="flex min-h-[120px] w-full items-center justify-center">
24
- <MenuPanel className="w-72">
25
- <Story />
26
- </MenuPanel>
27
- </div>
28
- ),
29
- ],
30
- args: { label: 'Rename', onClick: () => {} },
31
- argTypes: {
32
- onClick: { control: false },
33
- leading: { control: false },
34
- trailing: { control: false },
35
- },
36
- } satisfies Meta<typeof MenuItem>
37
-
38
- export default meta
39
- type Story = StoryObj<typeof meta>
40
-
41
- export const Default: Story = {}
42
-
43
- export const WithAnIcon: Story = {
44
- args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
45
- }
46
-
47
- export const WithAShortcut: Story = {
48
- args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
49
- }
50
-
51
- /** The row opens another menu. */
52
- export const Submenu: Story = {
53
- args: { label: 'Move to…', submenu: true },
54
- }
55
-
56
- /**
57
- * The submenu, working. The story above draws the row at rest — the chevron is
58
- * the whole affordance — but a row that opens another menu can only be tried
59
- * inside a real one, so here is one.
60
- *
61
- * Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
62
- * row, flips to the other side at a screen edge, and stays open while you
63
- * cross the diagonal into it.
64
- *
65
- * From the keyboard: → opens it and leaves the highlight on the row, ↓ steps
66
- * into it, and ← closes it and puts the highlight back on the row. Measured in
67
- * that order — → then ← alone does nothing, because focus has not entered yet.
68
- */
69
- export const SubmenuLive: Story = {
70
- parameters: { controls: { disable: true } },
71
- decorators: [(Story) => <Story />],
72
- render: () => (
73
- <div className="flex min-h-[220px] w-full items-start justify-center pt-4">
74
- <Menu trigger={<Button variant="outlined">Open the menu</Button>}>
75
- <MenuItem label="Rename" onClick={() => {}} />
76
- <MenuSub label="Move to…">
77
- <MenuItem label="Item one" onClick={() => {}} />
78
- <MenuItem label="Item two" onClick={() => {}} />
79
- <MenuItem label="Item three" onClick={() => {}} />
80
- </MenuSub>
81
- <Divider className="my-1" />
82
- <MenuItem label="Delete" destructive onClick={() => {}} />
83
- </Menu>
84
- </div>
85
- ),
86
- }
87
-
88
- /** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
89
- export const APerson: Story = {
90
- args: {
91
- label: 'Ana Duarte',
92
- description: 'Product designer',
93
- leading: <Avatar name="Ana Duarte" size={32} />,
94
- trailing: <EnterHint />,
95
- },
96
- }
97
-
98
- /** The submenu's chosen value. */
99
- export const Selected: Story = {
100
- args: { label: 'Item one', selected: true },
101
- }
102
-
103
- export const Destructive: Story = {
104
- args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
105
- }
106
-
107
- /** Every shape on one canvas. */
108
- export const AllVariants: Story = {
109
- parameters: { controls: { disable: true } },
110
- render: () => (
111
- <>
112
- <MenuItem label="Bare" onClick={() => {}} />
113
- <MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
114
- <MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
115
- <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
116
- <MenuItem label="Opens another menu" submenu onClick={() => {}} />
117
- <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
118
- <MenuItem label="The chosen value" selected onClick={() => {}} />
119
- <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
120
- <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
121
- </>
122
- ),
123
- }
124
-
125
- /** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
126
- export const Tall: Story = {
127
- args: {
128
- size: 'tall',
129
- label: 'Ana Duarte',
130
- description: 'Product designer',
131
- leading: <Avatar name="Ana Duarte" size={32} />,
132
- selected: true,
133
- trailing: <EnterHint />,
134
- },
135
- }
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconCopy, IconHash, IconPin, IconTrash } from '@tabler/icons-react'
3
+ import { Avatar } from './Avatar'
4
+ import { useRef, useState } from 'react'
5
+ import { Button } from './Button'
6
+ import { Divider } from './Divider'
7
+ import { EnterHint, Menu, MenuItem, MenuPanel, MenuSub } from './Menu'
8
+
9
+ /**
10
+ * One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
11
+ * with an optional `description` line · one thing at the right edge —
12
+ * `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
13
+ * chevron. Stories sit on a `MenuPanel` — the menu's surface, drawn without
14
+ * its behaviour — so the row is seen where it lives. A live menu portals and
15
+ * places itself, so it cannot stand in the page (Katerina, D25); **Menu →
16
+ * FromATrigger** is where the keyboard and the placement are.
17
+ */
18
+ const meta = {
19
+ title: 'Overlays/MenuItem',
20
+ component: MenuItem,
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="flex min-h-[120px] w-full items-center justify-center">
24
+ <MenuPanel className="w-72">
25
+ <Story />
26
+ </MenuPanel>
27
+ </div>
28
+ ),
29
+ ],
30
+ args: { label: 'Rename', onClick: () => {} },
31
+ argTypes: {
32
+ onClick: { control: false },
33
+ leading: { control: false },
34
+ trailing: { control: false },
35
+ },
36
+ } satisfies Meta<typeof MenuItem>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ export const Default: Story = {}
42
+
43
+ export const WithAnIcon: Story = {
44
+ args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
45
+ }
46
+
47
+ export const WithAShortcut: Story = {
48
+ args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
49
+ }
50
+
51
+ /** The row opens another menu. */
52
+ export const Submenu: Story = {
53
+ args: { label: 'Move to…', submenu: true },
54
+ }
55
+
56
+ /**
57
+ * The submenu, working. The story above draws the row at rest — the chevron is
58
+ * the whole affordance — but a row that opens another menu can only be tried
59
+ * inside a real one, so here is one.
60
+ *
61
+ * Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
62
+ * row, flips to the other side at a screen edge, and stays open while you
63
+ * cross the diagonal into it.
64
+ *
65
+ * From the keyboard: → opens it and leaves the highlight on the row, ↓ steps
66
+ * into it, and ← closes it and puts the highlight back on the row. Measured in
67
+ * that order — → then ← alone does nothing, because focus has not entered yet.
68
+ */
69
+ export const SubmenuLive: Story = {
70
+ parameters: { controls: { disable: true } },
71
+ decorators: [(Story) => <Story />],
72
+ render: () => (
73
+ <div className="flex min-h-[220px] w-full items-start justify-center pt-4">
74
+ <Menu trigger={<Button variant="outlined">Open the menu</Button>}>
75
+ <MenuItem label="Rename" onClick={() => {}} />
76
+ <MenuSub label="Move to…">
77
+ <MenuItem label="Item one" onClick={() => {}} />
78
+ <MenuItem label="Item two" onClick={() => {}} />
79
+ <MenuItem label="Item three" onClick={() => {}} />
80
+ </MenuSub>
81
+ <Divider className="my-1" />
82
+ <MenuItem label="Delete" destructive onClick={() => {}} />
83
+ </Menu>
84
+ </div>
85
+ ),
86
+ }
87
+
88
+ /** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
89
+ export const APerson: Story = {
90
+ args: {
91
+ label: 'Ana Duarte',
92
+ description: 'Product designer',
93
+ leading: <Avatar name="Ana Duarte" size={32} />,
94
+ trailing: <EnterHint />,
95
+ },
96
+ }
97
+
98
+ /** The submenu's chosen value. */
99
+ export const Selected: Story = {
100
+ args: { label: 'Item one', selected: true },
101
+ }
102
+
103
+ export const Destructive: Story = {
104
+ args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
105
+ }
106
+
107
+ /** Every shape on one canvas. */
108
+ export const AllVariants: Story = {
109
+ parameters: { controls: { disable: true } },
110
+ render: () => (
111
+ <>
112
+ <MenuItem label="Bare" onClick={() => {}} />
113
+ <MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
114
+ <MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
115
+ <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
116
+ <MenuItem label="Opens another menu" submenu onClick={() => {}} />
117
+ <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
118
+ <MenuItem label="The chosen value" selected onClick={() => {}} />
119
+ <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
120
+ <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
121
+ </>
122
+ ),
123
+ }
124
+
125
+ /** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
126
+ export const Tall: Story = {
127
+ args: {
128
+ size: 'tall',
129
+ label: 'Ana Duarte',
130
+ description: 'Product designer',
131
+ leading: <Avatar name="Ana Duarte" size={32} />,
132
+ selected: true,
133
+ // `hint`, not `trailing`: shown only while the row is highlighted, on the fill's own timing.
134
+ hint: <EnterHint />,
135
+ },
136
+ }
137
+
138
+ /** A topic in the picker: `EnterHint`'s `target` says what Enter gives you, after the key. Hover another row and back to see the hint follow the highlight. */
139
+ export const ATopic: Story = {
140
+ args: {
141
+ size: 'tall',
142
+ label: 'design',
143
+ description: '12 members',
144
+ leading: <IconHash size={16} stroke={1.5} className="text-text-secondary" />,
145
+ selected: true,
146
+ hint: <EnterHint target="#design" />,
147
+ },
148
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the
149
+ // target is muted text, 3.05:1 on a highlighted row in signal (AA 4.5:1).
150
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
151
+ }
@@ -13,6 +13,13 @@ type Story = StoryObj<typeof meta>
13
13
 
14
14
  export const Named: Story = {}
15
15
 
16
+ /** With their picture — here an inline SVG, as Avatar's own story uses, so the story needs nothing from anywhere. */
17
+ export const WithPicture: Story = {
18
+ args: {
19
+ picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
20
+ },
21
+ }
22
+
16
23
  /** Nobody has published a name: the silhouette and the em dash, muted — the same mark a property with no value uses. */
17
24
  export const Unnamed: Story = {
18
25
  args: { name: undefined },
@@ -14,6 +14,13 @@ type Story = StoryObj<typeof meta>
14
14
  /** The row shape — face · name · chevron on a 32px button, Ship's top bar. */
15
15
  export const Row: Story = {}
16
16
 
17
+ /** The row with the person's picture — an inline SVG, as Avatar's own story uses. */
18
+ export const RowWithPicture: Story = {
19
+ args: {
20
+ picture: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
21
+ },
22
+ }
23
+
17
24
  /** While what it opens is open, the row holds its hover fill. */
18
25
  export const RowOpen: Story = {
19
26
  args: { open: true },
@@ -17,6 +17,11 @@ type Story = StoryObj<typeof meta>
17
17
 
18
18
  export const Plain: Story = {}
19
19
 
20
+ /** `chevron`: the header of a section that folds. The title becomes the toggle, and the chevron turns with it. CollapsibleSection draws this for you. */
21
+ export const Folding: Story = {
22
+ args: { chevron: true, isExpanded: true, onToggle: () => {} },
23
+ }
24
+
20
25
  /** Actions beside the title, in the order given — revealed on hover or focus. */
21
26
  export const WithActions: Story = {
22
27
  args: {
@@ -45,7 +45,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
45
45
  'UIG-9': 'Lint rule — the className allow-list',
46
46
  'UIG-10': 'create-app — a command that makes a new Estiva app that runs',
47
47
  'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification",
48
- 'UIG-19': 'Lock the contract in CI, and make the three Storybooks one search',
48
+ 'UIG-19': 'Lock the contract in CI',
49
49
  'UIG-20': 'The Claude skill, reading the registry',
50
50
  'UIG-21': 'CLAUDE.md becomes an index, not a lecture',
51
51
  'UIG-22': 'Fingerprint — a hand-made header row',
@@ -56,6 +56,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
56
56
  'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
57
57
  'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
58
58
  'UIG-32': 'Peek and Ship take their gate pieces from the package',
59
+ 'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
59
60
  }
60
61
 
61
62
  /** Run several checks as one: the first that does not pass is the answer. */
@@ -81,6 +82,8 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
81
82
  const installed = at('node_modules/@estiva-app/ui/dist/index.d.ts')
82
83
  const realPage = (): Promise<CheckResult> => h.lint({ ...(cwd ? { cwd } : {}), config: GATES, file: page, code: h.read(at(page)), expect: 'none' })
83
84
  const button = component('<button type="button">x</button>')
85
+ /** A line TypeScript's own lint writes; ESLint refuses it where TypeScript's plugin is unknown (UIG-37). */
86
+ const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
84
87
  const ticket = (ref: string, checks: GateCheck[], owner = false): GateTicket => ({ ref, title: APP_TICKET_TITLES[ref], owner, checks })
85
88
 
86
89
  const tokenProbe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE) =>
@@ -190,7 +193,11 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
190
193
  { what: 'every part is described and sorted, and every file is accounted for', run: () => h.catalogue(at('.')) },
191
194
  ]),
192
195
  ticket('UIG-19', [
193
- { what: 'the usage-page contract runs in CI', run: () => h.ci(/[\w:-]*contract[\w:-]*/) },
196
+ // The contract runs inside `estiva-ui check`, which the gate job already runs
197
+ // (UIG-13), so an app changes no script: it takes the package that has it.
198
+ { what: 'the installed package checks the usage-page contract and the story links', run: () => h.contains(at('node_modules/@estiva-app/ui/dist/registry/cli.js'), 'the usage-page contract', "the installed estiva-ui check runs the contract") },
199
+ { what: "CI's job gate runs it", run: () => h.ciJob('gate', 'registry:check') },
200
+ { what: 'the app keeps no copy of it in its own gates-checks.mjs', run: () => h.exists(at('scripts/gates-checks.mjs')) ? h.lacks(at('scripts/gates-checks.mjs'), '"When not"', 'the app checks no page sections itself') : h.PASS('the app has no gates-checks.mjs') },
194
201
  ]),
195
202
  ticket('UIG-20', [
196
203
  { what: 'a committed skill runs ui:find', run: () => (h.listFiles('.claude/skills', (n) => n === 'SKILL.md').some((f) => h.read(f).includes('ui:find')) ? h.PASS('a skill runs ui:find') : h.FAIL('no skill in .claude/skills runs ui:find')) },
@@ -244,5 +251,15 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
244
251
  () => (h.exists('scripts/gates-status.mjs') ? h.FAIL('scripts/gates-status.mjs is a copy of the status engine: run estiva-gates status instead') : h.PASS('no copy of the status engine')),
245
252
  ], 'the gate config, the hook and gates:status are the package\'s; no copy of the engine') },
246
253
  ]),
254
+ ticket('UIG-37', [
255
+ { what: 'a TypeScript eslint-disable comment breaks neither the gate nor the token lint', run: all(h, [
256
+ probe(typescriptDirective, 'none'),
257
+ tokenProbe(typescriptDirective, 'none'),
258
+ ], `${[GATES, ...tokenConfigs].join(', ')}: a TypeScript directive is no error`) },
259
+ { what: 'a token lint on its own is the package\'s tokenConfig, not built by hand', run: () =>
260
+ (h.exists(at('eslint.tokens.config.js'))
261
+ ? h.contains(at('eslint.tokens.config.js'), /\btokenConfig\(/, 'eslint.tokens.config.js runs tokenConfig from @estiva-app/ui/gates')
262
+ : h.PASS('no token lint on its own')) },
263
+ ]),
247
264
  ]
248
265
  }
@@ -264,18 +264,13 @@ export default defineConfig([
264
264
  gateLint(),
265
265
  ])
266
266
  `,
267
- 'eslint.tokens.config.js': `import { TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'
267
+ 'eslint.tokens.config.js': `import { tokenConfig } from '@estiva-app/ui/gates'
268
268
  import reactHooks from 'eslint-plugin-react-hooks'
269
- import { defineConfig, globalIgnores } from 'eslint/config'
270
269
 
271
- // The token contract on its own (\`npm run lint:tokens\`). React's hooks plugin is
272
- // registered with its rules off, so its directives in the source do not break it.
273
- export default defineConfig([
274
- globalIgnores(TOKEN_LINT_IGNORES),
275
- { plugins: { 'react-hooks': reactHooks }, linterOptions: { reportUnusedDisableDirectives: 'off' } },
276
- tokenLint(),
277
- tokenValues(),
278
- ])
270
+ // The token contract on its own (\`npm run lint:tokens\`), the package's piece.
271
+ // React's hooks plugin is named so its directives do not break it; TypeScript's
272
+ // the package names itself.
273
+ export default tokenConfig({ quiet: { 'react-hooks': reactHooks } })
279
274
  `,
280
275
  'eslint.gates.config.js': `import { gateConfig } from '@estiva-app/ui/gates'
281
276
  import reactHooks from 'eslint-plugin-react-hooks'
@@ -284,7 +279,8 @@ import reactHooks from 'eslint-plugin-react-hooks'
284
279
  * The UI Guardrails' rules on their own — \`npm run lint:rules\`, CI's job \`gate\`
285
280
  * (GitHub requires it on main: never rename it), and what the editor hook in
286
281
  * \`.claude/settings.json\` lints a proposed write with. The package's gate,
287
- * imported. React's hooks plugin is named so its directives do not break it.
282
+ * imported. React's hooks plugin is named so its directives do not break it;
283
+ * TypeScript's the package names itself.
288
284
  *
289
285
  * A place that keeps something the gate refuses says why, on the line above:
290
286
  * \`// @estiva-escape: <reason>\`. Never \`eslint-disable\`: the count refuses it.
@@ -20,7 +20,7 @@ import type { ESLint, Linter } from 'eslint'
20
20
  import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
21
21
  import { parser as typescriptParser } from 'typescript-eslint'
22
22
  import estiva from '../eslint/index'
23
- import { TOKEN_LINT_IGNORES } from './token-lint'
23
+ import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
24
24
 
25
25
  export interface GateConfigOptions {
26
26
  /** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
@@ -29,7 +29,7 @@ export interface GateConfigOptions {
29
29
  * Plugins whose `eslint-disable` directives appear in the source, registered
30
30
  * here with every rule off. ESLint refuses a directive for a rule it does not
31
31
  * know, so without them the gate would fail on another lint's comments.
32
- * The token lint's names are registered already.
32
+ * The token lint's names and TypeScript's are registered already (UIG-37).
33
33
  */
34
34
  quiet?: Record<string, ESLint.Plugin>
35
35
  /** Folders the gate never reads, beside build output. */
@@ -63,6 +63,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
63
63
  { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
64
64
  {
65
65
  plugins: {
66
+ ...KNOWN_PLUGINS,
66
67
  ...quiet,
67
68
  'better-tailwindcss': betterTailwindcss,
68
69
  'token-values': betterTailwindcss,
@@ -7,7 +7,7 @@ import { writeGateCount } from './count'
7
7
  import { gateConfig, gateLint } from './gate-config'
8
8
  import { runHook } from './hook'
9
9
  import { helpers, runStatus } from './status'
10
- import { tokenLint, tokenValues } from './token-lint'
10
+ import { tokenConfig, tokenLint, tokenValues } from './token-lint'
11
11
 
12
12
  /**
13
13
  * The gate pieces, as an app and this package use them (UIG-10, docs/GATES.md
@@ -44,6 +44,8 @@ async function lintWith(config: Linter.Config[], code: string, filePath: string)
44
44
  }
45
45
 
46
46
  const component = (body: string) => `export function Probe() {\n return ${body}\n}\n`
47
+ /** A line TypeScript's own lint writes: Peek PR #324 failed the token lint on it (UIG-37). */
48
+ const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
47
49
 
48
50
  describe('tokenLint and tokenValues', () => {
49
51
  it('refuse a hand-written text size and name the token, in the words for an app', async () => {
@@ -98,6 +100,38 @@ describe('gateConfig', () => {
98
100
  const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
99
101
  expect(await lintWith(gateConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
100
102
  })
103
+
104
+ it("knows TypeScript's rule names, so a TypeScript directive does not break the gate (UIG-37)", async () => {
105
+ expect(await lintWith(gateConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
106
+ // An app that names the plugin itself, from its own copy, still loads: its copy wins.
107
+ const own = { rules: { 'no-explicit-any': { create: () => ({}) } } }
108
+ expect(await lintWith(gateConfig({ quiet: { '@typescript-eslint': own } }), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
109
+ })
110
+ })
111
+
112
+ describe('tokenConfig', () => {
113
+ it('runs the token contract on its own: the same messages as the two pieces, for an app and for the package', async () => {
114
+ const code = component('<div className="bg-gray-100 rounded-[3px]">x</div>')
115
+ const app = await lintWith(tokenConfig(), code, 'src/Probe.tsx')
116
+ expect(app.length).toBeGreaterThan(0)
117
+ expect(app).toEqual(await lintWith([tokenLint(), tokenValues()], code, 'src/Probe.tsx'))
118
+ const pkg = await lintWith(tokenConfig({ audience: 'package' }), code, 'src/Probe.tsx')
119
+ expect(pkg).toEqual(await lintWith([tokenLint({ audience: 'package' }), tokenValues({ audience: 'package' })], code, 'src/Probe.tsx'))
120
+ })
121
+
122
+ it("knows TypeScript's rule names and the quiet plugins, so their directives do not break it (UIG-37)", async () => {
123
+ expect(await lintWith(tokenConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
124
+ const quiet = { rules: { 'some-rule': { create: () => ({}) } } }
125
+ const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
126
+ expect(await lintWith(tokenConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
127
+ })
128
+
129
+ it('never reads build output, nor a folder the app names', async () => {
130
+ const hand = component('<div className="text-[14px]">x</div>')
131
+ const ignored = (messages: Linter.LintMessage[]) => messages.every((m) => !m.ruleId)
132
+ expect(ignored(await lintWith(tokenConfig(), hand, 'dist/Probe.tsx'))).toBe(true)
133
+ expect(ignored(await lintWith(tokenConfig({ ignores: ['demo'] }), hand, 'demo/Probe.tsx'))).toBe(true)
134
+ })
101
135
  })
102
136
 
103
137
  describe('writeGateCount', () => {
@@ -8,6 +8,7 @@
8
8
  * here, and an app imports them:
9
9
  *
10
10
  * - `tokenLint`, `tokenValues` — the token contract's lint settings (UIG-28).
11
+ * - `tokenConfig` — the token lint on its own, as `lint:tokens` runs it (UIG-37).
11
12
  * - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
12
13
  * - `writeGateCount` — `.gates-count.json` (seam S3).
13
14
  * - `runHook` — the editor gate a `PreToolUse` hook runs.
@@ -21,7 +22,7 @@
21
22
  * Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
22
23
  * components' browser bundle.
23
24
  */
24
- export { TOKEN_LINT_IGNORES, tokenLint, tokenValues, type TokenAudience, type TokenLintOptions } from './token-lint'
25
+ export { TOKEN_LINT_IGNORES, tokenConfig, tokenLint, tokenValues, type TokenAudience, type TokenConfigOptions, type TokenLintOptions } from './token-lint'
25
26
  export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
26
27
  export { writeGateCount, type CountOptions, type CountResult } from './count'
27
28
  export { runHook, type HookOptions, type HookResult } from './hook'
@@ -73,6 +73,8 @@ export interface GateHelpers {
73
73
  share(files: string[], test: (file: string) => boolean, label: string): CheckResult
74
74
  /** Build the catalogue of the app in `appDir` (UIG-13): passes when every part is described and sorted. */
75
75
  catalogue(appDir: string): Promise<CheckResult>
76
+ /** Every reusable part keeps the usage-page contract (UIG-19): the package's one copy of it. */
77
+ contract(appDir: string): Promise<CheckResult>
76
78
  }
77
79
 
78
80
  export interface GateCheck {
@@ -178,6 +180,19 @@ export function helpers(ROOT: string): GateHelpers {
178
180
  }
179
181
  },
180
182
 
183
+ async contract(appDir) {
184
+ try {
185
+ const { buildAppRegistry, contractProblems, CONTRACT_KINDS } = await import('../registry/index')
186
+ const registry = buildAppRegistry({ root: abs(appDir) })
187
+ const problems = contractProblems(registry, abs(appDir))
188
+ const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length
189
+ if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`)
190
+ return PASS(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`)
191
+ } catch (error) {
192
+ return FAIL(String(error instanceof Error ? error.message : error).split('\n')[0])
193
+ }
194
+ },
195
+
181
196
  file(rel) {
182
197
  return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
183
198
  },
@@ -16,6 +16,9 @@
16
16
  * inline style that sets a colour (errors); heights, spacing and border
17
17
  * widths written by hand (warnings, never blocking).
18
18
  *
19
+ * And `tokenConfig`, the two on their own as an app's `lint:tokens` runs them
20
+ * (UIG-37), with TypeScript's rule names known so its directives pass.
21
+ *
19
22
  * The patterns are the same for everyone. Only the words differ: an app is told
20
23
  * to use "the package", the package is told to add the token to `tokens.css`.
21
24
  * Measured on 17 September: the UIG-28 block was byte for byte the same in Peek
@@ -32,10 +35,10 @@
32
35
  */
33
36
  import { createRequire } from 'node:module'
34
37
  import { fileURLToPath } from 'node:url'
35
- import type { Linter } from 'eslint'
38
+ import type { ESLint, Linter } from 'eslint'
36
39
  import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
37
40
  import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'
38
- import { parser as typescriptParser } from 'typescript-eslint'
41
+ import { parser as typescriptParser, plugin as typescriptPlugin } from 'typescript-eslint'
39
42
 
40
43
  /** Who reads the messages. */
41
44
  export type TokenAudience = 'app' | 'package'
@@ -47,9 +50,30 @@ export interface TokenLintOptions {
47
50
  audience?: TokenAudience
48
51
  }
49
52
 
53
+ export interface TokenConfigOptions extends TokenLintOptions {
54
+ /**
55
+ * Plugins whose `eslint-disable` directives appear in the source, registered
56
+ * with every rule off. ESLint refuses a directive for a rule it does not
57
+ * know. TypeScript's are registered already.
58
+ */
59
+ quiet?: Record<string, ESLint.Plugin>
60
+ /** Folders the token lint never reads, beside build output. */
61
+ ignores?: string[]
62
+ }
63
+
50
64
  /** What the token lint never reads: build output. */
51
65
  export const TOKEN_LINT_IGNORES = ['dist/', 'storybook-static/', 'node_modules/']
52
66
 
67
+ /**
68
+ * Plugins a check that runs on its own registers, every rule off, so the
69
+ * directives another lint writes do not fail it (UIG-37). TypeScript's lint is
70
+ * in every app and writes `eslint-disable-next-line @typescript-eslint/...`;
71
+ * Peek PR #324 failed the token lint on one. Only the checks that run alone
72
+ * take this — a full lint that loads TypeScript's rules from its own copy would
73
+ * refuse a second plugin under the same name.
74
+ */
75
+ export const KNOWN_PLUGINS: Record<string, ESLint.Plugin> = { '@typescript-eslint': typescriptPlugin as ESLint.Plugin }
76
+
53
77
  const CLASS_MAP_SELECTOR = {
54
78
  kind: 'variable',
55
79
  name: '^(?:[A-Z][A-Z0-9_]*_(?:STYLES|CLASSES)|[a-z][a-zA-Z0-9]*(?:Styles|Classes))$',
@@ -229,3 +253,24 @@ export function tokenValues({ audience = 'app' }: Pick<TokenLintOptions, 'audien
229
253
  },
230
254
  }
231
255
  }
256
+
257
+ /**
258
+ * The token lint on its own — an app's `eslint.tokens.config.js`, `npm run
259
+ * lint:tokens`, the CI step. It fails on the token contract and on nothing
260
+ * else, so a backlog in the full lint never blocks it. Peek built this file by
261
+ * hand until UIG-37.
262
+ */
263
+ export function tokenConfig({ quiet = {}, ignores = [], ...options }: TokenConfigOptions = {}): Linter.Config[] {
264
+ return [
265
+ { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
266
+ {
267
+ plugins: { ...KNOWN_PLUGINS, ...quiet },
268
+ // Every other lint's directives are unused here, because their rules are
269
+ // off here. Reporting them would be this config complaining about the
270
+ // others' business.
271
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
272
+ },
273
+ tokenLint(options),
274
+ tokenValues({ audience: options.audience }),
275
+ ]
276
+ }