@estiva-app/ui 0.14.0 → 0.16.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 (56) hide show
  1. package/README.md +28 -0
  2. package/dist/Breadcrumb.d.ts.map +1 -1
  3. package/dist/ChipInput.d.ts +14 -1
  4. package/dist/ChipInput.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts +114 -0
  6. package/dist/CommandPalette.d.ts.map +1 -0
  7. package/dist/Menu.d.ts +4 -0
  8. package/dist/Menu.d.ts.map +1 -1
  9. package/dist/Toast.d.ts.map +1 -1
  10. package/dist/eslint/escape.d.ts +61 -0
  11. package/dist/eslint/escape.d.ts.map +1 -0
  12. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  13. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  14. package/dist/eslint/index.d.ts +59 -0
  15. package/dist/eslint/index.d.ts.map +1 -0
  16. package/dist/eslint/index.js +286 -0
  17. package/dist/eslint/index.js.map +7 -0
  18. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  19. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  20. package/dist/eslint/no-raw-button.d.ts +11 -0
  21. package/dist/eslint/no-raw-button.d.ts.map +1 -0
  22. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  23. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +614 -292
  27. package/dist/index.js.map +4 -4
  28. package/package.json +9 -1
  29. package/src/Avatar.tsx +1 -1
  30. package/src/AvatarGroup.tsx +1 -1
  31. package/src/Breadcrumb.tsx +6 -2
  32. package/src/ChipInput.mdx +9 -0
  33. package/src/ChipInput.stories.tsx +18 -0
  34. package/src/ChipInput.test.tsx +18 -0
  35. package/src/ChipInput.tsx +18 -4
  36. package/src/CommandPalette.mdx +133 -0
  37. package/src/CommandPalette.stories.tsx +416 -0
  38. package/src/CommandPalette.test.tsx +392 -0
  39. package/src/CommandPalette.tsx +643 -0
  40. package/src/Menu.tsx +4 -2
  41. package/src/Select.test.tsx +20 -3
  42. package/src/Toast.tsx +12 -24
  43. package/src/eslint/escape.ts +112 -0
  44. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  45. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  46. package/src/eslint/index.test.ts +114 -0
  47. package/src/eslint/index.ts +145 -0
  48. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  49. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  50. package/src/eslint/no-raw-button.test.ts +118 -0
  51. package/src/eslint/no-raw-button.ts +39 -0
  52. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  53. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  54. package/src/index.ts +17 -0
  55. package/stories/Choosing.mdx +1 -0
  56. package/stories/TokensPage.tsx +1 -1
@@ -32,6 +32,23 @@ function Controlled({ initial = 'todo', ...rest }: { initial?: string } & Partia
32
32
  return <Select value={value} onChange={setValue} options={STATUSES} ariaLabel="Status" {...rest} />
33
33
  }
34
34
 
35
+ /**
36
+ * The open list, once it has focus.
37
+ *
38
+ * The list is findable as soon as it opens, but Base UI moves focus into it an
39
+ * animation frame later. A key pressed before that frame goes to the trigger:
40
+ * Tab then moves focus into the list instead of closing it, and an arrow or End
41
+ * is lost. A person presses keys at a list that has focus, so these tests wait
42
+ * for it. CI failed "Tab closes the list" this way on 15 September (PR #36);
43
+ * with every frame 100ms late the three tests that use this failed 3 runs of 3,
44
+ * focus measured still on the trigger when Tab was pressed.
45
+ */
46
+ async function focusedList() {
47
+ const list = await screen.findByRole('listbox')
48
+ await waitFor(() => expect(list.contains(document.activeElement)).toBe(true))
49
+ return list
50
+ }
51
+
35
52
  describe('Select', () => {
36
53
  it('is a button naming itself, showing the chosen label', () => {
37
54
  render(<Controlled initial="in_progress" />)
@@ -65,7 +82,7 @@ describe('Select', () => {
65
82
  const trigger = screen.getByRole('combobox', { name: 'Status' })
66
83
  trigger.focus()
67
84
  await user.keyboard('{ArrowDown}')
68
- expect(await screen.findByRole('listbox')).toBeTruthy()
85
+ expect(await focusedList()).toBeTruthy()
69
86
  await user.keyboard('{ArrowDown}{Enter}')
70
87
  expect(onChange).toHaveBeenCalledWith('in_progress')
71
88
  expect(document.activeElement).toBe(trigger)
@@ -130,7 +147,7 @@ describe('Select', () => {
130
147
  render(<Controlled initial="in_progress" />)
131
148
  screen.getByRole('combobox', { name: 'Status' }).focus()
132
149
  await user.keyboard('{ArrowDown}')
133
- expect(await screen.findByRole('listbox')).toBeTruthy()
150
+ expect(await focusedList()).toBeTruthy()
134
151
  const highlighted = () => screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)?.textContent
135
152
  await user.keyboard('{End}')
136
153
  expect(highlighted()).toContain('Done')
@@ -142,7 +159,7 @@ describe('Select', () => {
142
159
  const user = userEvent.setup()
143
160
  render(<Controlled />)
144
161
  await user.click(screen.getByRole('combobox', { name: 'Status' }))
145
- expect(await screen.findByRole('listbox')).toBeTruthy()
162
+ expect(await focusedList()).toBeTruthy()
146
163
  await user.tab()
147
164
  await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
148
165
  })
package/src/Toast.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
2
  import { Toast as BaseToast } from '@base-ui/react/toast'
3
3
  import { IconAlertCircle, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
4
+ import { Button } from './Button'
4
5
  import { cn } from './cn'
5
6
 
6
7
  /**
@@ -78,14 +79,6 @@ const ICON_STYLES: Record<ToastType, string> = {
78
79
  error: 'signal:text-error-default',
79
80
  }
80
81
 
81
- const ACTION_BORDER_STYLES: Record<ToastType, string> = {
82
- success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
83
- brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
84
- neutral: 'border border-border-default',
85
- warning: 'signal:border signal:border-border-default signal:hover:border-border-strong',
86
- error: 'signal:border signal:border-border-default signal:hover:border-border-strong',
87
- }
88
-
89
82
  const LABEL_CLASSES = 'text-body-2 text-text-primary whitespace-nowrap'
90
83
 
91
84
  const pillClassName = (type: ToastType, hasAction: boolean, className?: string) =>
@@ -98,20 +91,11 @@ const pillClassName = (type: ToastType, hasAction: boolean, className?: string)
98
91
  className,
99
92
  )
100
93
 
101
- const actionClassName = (type: ToastType) =>
102
- cn(
103
- 'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
104
- ACTION_BORDER_STYLES[type],
105
- type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
106
- )
107
-
108
94
  function LeadingIcon({ type }: { type: ToastType }) {
109
95
  const Icon = ICONS[type]
110
96
  return <Icon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
111
97
  }
112
98
 
113
- const ACTION_LABEL_CLASSES = 'text-btn-small text-text-primary whitespace-nowrap'
114
-
115
99
  /**
116
100
  * One toast, drawn in place. What the provider shows is this pill; draw it
117
101
  * yourself only where a page needs a toast's look without its timing — a
@@ -126,9 +110,9 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
126
110
  <span className={LABEL_CLASSES}>{label}</span>
127
111
  </div>
128
112
  {hasAction && (
129
- <button type="button" onClick={onAction} className={actionClassName(type)}>
130
- <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
131
- </button>
113
+ <Button variant="outlined" size="small" onClick={onAction}>
114
+ {actionLabel}
115
+ </Button>
132
116
  )}
133
117
  </div>
134
118
  )
@@ -229,10 +213,14 @@ function ToastList() {
229
213
  onAction?.()
230
214
  close(toast.id)
231
215
  }}
232
- className={actionClassName(type)}
233
- >
234
- <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
235
- </BaseToast.Action>
216
+ // The same Button the toast drawn in place uses, told to *be* the
217
+ // Base UI action rather than sit inside one (Katerina, 16 September).
218
+ render={
219
+ <Button variant="outlined" size="small">
220
+ {actionLabel}
221
+ </Button>
222
+ }
223
+ />
236
224
  )}
237
225
  </BaseToast.Root>
238
226
  )
@@ -0,0 +1,112 @@
1
+ import type { AST, Rule } from 'eslint'
2
+
3
+ /**
4
+ * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
5
+ * why one element stays as it is.
6
+ *
7
+ * A line comment directly above the element,
8
+ *
9
+ * // @estiva-escape: <reason>
10
+ *
11
+ * or, where the element is a JSX child, the JSX comment on the line above,
12
+ *
13
+ * {/* @estiva-escape: <reason> *\/}
14
+ *
15
+ * with at least ten characters of reason, spaces not counted.
16
+ *
17
+ * Not `eslint-disable`. A directive switches a rule off without saying it was
18
+ * meant, and the count of escapes (`.gates-count.json`) could not see it. A
19
+ * marker written inside a directive that switches one of these rules off is
20
+ * reported for that reason.
21
+ *
22
+ * The token lint's older notes (`eslint-disable-next-line <rule> -- @estiva-escape:
23
+ * <reason>`, Katerina's ruling A2 of 15 September) are not read here: they
24
+ * escape the token lint's rules, which are not this plugin's, and they stay as
25
+ * they are (Katerina, 15 September).
26
+ */
27
+ export const ESCAPE_MARKER = '@estiva-escape'
28
+
29
+ /** Characters of reason, spaces not counted. */
30
+ export const MIN_REASON = 10
31
+
32
+ /** The key under ESLint's `settings` this plugin reads. */
33
+ export const SETTINGS_KEY = 'estiva'
34
+
35
+ /**
36
+ * Every rule spreads these into its `meta.messages`, because `isEscaped`
37
+ * reports through the rule that called it.
38
+ */
39
+ export const ESCAPE_MESSAGES = {
40
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
41
+ escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}: <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
42
+ escaped: 'Escaped: {{reason}}',
43
+ } as const
44
+
45
+ export interface EstivaSettings {
46
+ /**
47
+ * Report every sanctioned escape as a message with the id `escaped`, so a
48
+ * count can read them. Off in the lint anyone runs; on only in the count.
49
+ */
50
+ reportEscapes?: boolean
51
+ }
52
+
53
+ interface Located {
54
+ loc?: AST.SourceLocation | null
55
+ range?: [number, number]
56
+ }
57
+
58
+ const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?(?=\s|$)([^]*)$/
59
+
60
+ /** The rules a directive names: what comes before its ` -- ` description. None means every rule. */
61
+ function directiveRules(rest: string): string[] {
62
+ const dashes = rest.search(/(?:^|\s)--(?:\s|$)/)
63
+ return (dashes === -1 ? rest : rest.slice(0, dashes)).split(/[\s,]+/).filter(Boolean)
64
+ }
65
+
66
+ /** Only spaces and a JSX expression's closing brace between the marker and the element. */
67
+ const BETWEEN = /^[\s}]*$/
68
+
69
+ /**
70
+ * Whether the element at `node` carries a valid escape on the line above.
71
+ *
72
+ * Every rule of this plugin calls it before it reports. It reports, through
73
+ * that rule, a marker with too short a reason and a marker inside an
74
+ * eslint-disable directive for this rule; either way the element is not
75
+ * escaped, so the rule reports it too.
76
+ */
77
+ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
78
+ const { sourceCode } = context
79
+ if (!node.loc || !node.range) return false
80
+ const line = node.loc.start.line
81
+ const nodeStart = node.range[0]
82
+
83
+ const comment = sourceCode
84
+ .getAllComments()
85
+ .find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
86
+ if (!comment?.loc) return false
87
+
88
+ const at = comment.value.indexOf(ESCAPE_MARKER)
89
+ if (at === -1) return false
90
+
91
+ const directive = DIRECTIVE.exec(comment.value)
92
+ if (directive) {
93
+ const rules = directiveRules(directive[1])
94
+ // A directive for other rules (the token lint's notes) is not an escape of this one.
95
+ if (rules.length > 0 && !rules.includes(context.id)) return false
96
+ context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
97
+ return false
98
+ }
99
+
100
+ const reason = comment.value
101
+ .slice(at + ESCAPE_MARKER.length)
102
+ .replace(/^:/, '')
103
+ .trim()
104
+ if (reason.replace(/\s/g, '').length < MIN_REASON) {
105
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
106
+ return false
107
+ }
108
+
109
+ const settings = context.settings[SETTINGS_KEY] as EstivaSettings | undefined
110
+ if (settings?.reportEscapes) context.report({ loc: comment.loc, messageId: 'escaped', data: { reason } })
111
+ return true
112
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { RuleTester } from 'eslint'
4
+ import { parser } from 'typescript-eslint'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
7
+
8
+ RuleTester.describe = describe
9
+ RuleTester.it = it
10
+ RuleTester.itOnly = it.only
11
+
12
+ const tester = new RuleTester({
13
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
14
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
15
+ })
16
+
17
+ /** Real files of this package, so the rules are tested against what they read. */
18
+ const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
19
+ const code = 'export function Probe() {\n return null\n}\n'
20
+ const escaped = '// @estiva-escape: a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
21
+
22
+ describe('the files these rules read', () => {
23
+ it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
24
+ expect(existsSync(src('Button.mdx'))).toBe(true)
25
+ expect(existsSync(src('Button.stories.tsx'))).toBe(true)
26
+ for (const name of ['FieldLine', 'MenuItem']) {
27
+ expect(existsSync(src(`${name}.mdx`))).toBe(true)
28
+ expect(existsSync(src(`${name}.stories.tsx`))).toBe(true)
29
+ // The false positive to avoid: no component file of their own.
30
+ expect(existsSync(src(`${name}.tsx`))).toBe(false)
31
+ }
32
+ })
33
+ })
34
+
35
+ tester.run('component-has-a-page', componentHasAPage, {
36
+ valid: [
37
+ { name: 'a component with its page beside it', code, filename: src('Button.tsx') },
38
+ { name: 'a story file, which needs no page of its own', code, filename: src('Button.stories.tsx') },
39
+ { name: 'a test file', code, filename: src('Button.test.tsx') },
40
+ { name: 'a file that is not a component file at all', code, filename: src('index.ts') },
41
+ { name: 'a component with its reason at the top', code: escaped, filename: src('NoPageProbe.tsx') },
42
+ ],
43
+ invalid: [
44
+ { name: 'a component with no page', code, filename: src('NoPageProbe.tsx'), errors: [{ messageId: 'missing' }] },
45
+ ],
46
+ })
47
+
48
+ tester.run('component-has-a-story', componentHasAStory, {
49
+ valid: [
50
+ { name: 'a component with its story beside it', code, filename: src('Button.tsx') },
51
+ { name: 'a story file, which is not itself a component', code, filename: src('Button.stories.tsx') },
52
+ { name: 'a component with its reason at the top', code: escaped, filename: src('NoStoryProbe.tsx') },
53
+ ],
54
+ invalid: [
55
+ { name: 'a component with no story', code, filename: src('NoStoryProbe.tsx'), errors: [{ messageId: 'missing' }] },
56
+ ],
57
+ })
@@ -0,0 +1,97 @@
1
+ import { existsSync } from 'node:fs'
2
+ import type { Rule } from 'eslint'
3
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
4
+
5
+ /**
6
+ * A component of the package with no page, and one with no story (UIG-5, P2 and
7
+ * P3).
8
+ *
9
+ * A component nobody can read about is a component nobody uses correctly, and
10
+ * one nobody can look at is one nobody reviews. Both were at zero when these
11
+ * were switched on; they are here so the next component cannot arrive without
12
+ * them.
13
+ *
14
+ * The pair is by file name, beside the component, which is how this package is
15
+ * laid out: `Button.tsx`, `Button.mdx`, `Button.stories.tsx`, `Button.test.tsx`
16
+ * side by side in `src/`.
17
+ *
18
+ * **The false positive to avoid** (UIG-1 confirmed it is real): `FieldLine` and
19
+ * `MenuItem` have a page and a story but no `.tsx` of their own — they are
20
+ * exported from a sibling. These rules read a component file and ask for its
21
+ * page and its story, never the other way round, so a page without a component
22
+ * is not this rule's business.
23
+ *
24
+ * A file that stays without one says why at its top: `// @estiva-escape: <why>`.
25
+ */
26
+ interface ProgramNode {
27
+ body: { loc?: Rule.Node['loc']; range?: [number, number] }[]
28
+ loc: NonNullable<Rule.Node['loc']>
29
+ range: [number, number]
30
+ }
31
+
32
+ /** A component file: a `.tsx` in the package's source that is not a story or a test. */
33
+ function componentFile(filename: string): boolean {
34
+ return filename.endsWith('.tsx') && !/\.(stories|test)\.tsx$/.test(filename)
35
+ }
36
+
37
+ function sibling(filename: string, extension: string): string {
38
+ return filename.replace(/\.tsx$/, extension)
39
+ }
40
+
41
+ /**
42
+ * Report on the file's first statement, so the escape is a comment at the top of
43
+ * the file — the only place a reason for a missing page could go.
44
+ */
45
+ function reportOnFile(context: Rule.RuleContext, program: ProgramNode, messageId: string, data: Record<string, string>): void {
46
+ const anchor = program.body[0] ?? program
47
+ if (anchor.loc && anchor.range && isEscaped(context, { loc: anchor.loc, range: anchor.range })) return
48
+ context.report({ loc: anchor.loc ?? program.loc, messageId, data })
49
+ }
50
+
51
+ export const componentHasAPage: Rule.RuleModule = {
52
+ meta: {
53
+ type: 'problem',
54
+ docs: { description: 'Every component of the package has a usage page beside it' },
55
+ schema: [],
56
+ messages: {
57
+ missing: '{{name}} has no page. Write {{page}} beside it — what it is, when, when not, how, and what it owns — or say why not at the top of the file.',
58
+ ...ESCAPE_MESSAGES,
59
+ },
60
+ },
61
+ create(context) {
62
+ return {
63
+ Program(node) {
64
+ const filename = context.filename
65
+ if (!componentFile(filename)) return
66
+ const page = sibling(filename, '.mdx')
67
+ if (existsSync(page)) return
68
+ const name = filename.split(/[\\/]/).pop() ?? filename
69
+ reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, page: page.split(/[\\/]/).pop() ?? page })
70
+ },
71
+ }
72
+ },
73
+ }
74
+
75
+ export const componentHasAStory: Rule.RuleModule = {
76
+ meta: {
77
+ type: 'problem',
78
+ docs: { description: 'Every component of the package has a story beside it' },
79
+ schema: [],
80
+ messages: {
81
+ missing: '{{name}} has no story. Write {{story}} beside it, so it can be seen and reviewed, or say why not at the top of the file.',
82
+ ...ESCAPE_MESSAGES,
83
+ },
84
+ },
85
+ create(context) {
86
+ return {
87
+ Program(node) {
88
+ const filename = context.filename
89
+ if (!componentFile(filename)) return
90
+ const story = sibling(filename, '.stories.tsx')
91
+ if (existsSync(story)) return
92
+ const name = filename.split(/[\\/]/).pop() ?? filename
93
+ reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, story: story.split(/[\\/]/).pop() ?? story })
94
+ },
95
+ }
96
+ },
97
+ }
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { ESLint, type Linter } from 'eslint'
3
+ import { parser } from 'typescript-eslint'
4
+ import { describe, expect, it } from 'vitest'
5
+ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from './index'
6
+
7
+ /**
8
+ * The plugin as an app uses it: a flat config with `configs.recommended`,
9
+ * linting text the way Peek's editor hook does (`lintText`). The rule's own
10
+ * cases are in no-raw-button.test.ts.
11
+ */
12
+ const tsx: Linter.Config = {
13
+ files: ['**/*.tsx'],
14
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
15
+ }
16
+
17
+ async function lint(code: string, extra: Linter.Config[] = []) {
18
+ const eslint = new ESLint({
19
+ cwd: process.cwd(),
20
+ overrideConfigFile: true,
21
+ overrideConfig: [tsx, estiva.configs.recommended, ...extra],
22
+ })
23
+ return eslint.lintText(code, { filePath: 'src/Probe.tsx' })
24
+ }
25
+
26
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
27
+ const countMode: Linter.Config = { settings: { estiva: { reportEscapes: true } } }
28
+
29
+ describe('the plugin object', () => {
30
+ it('names itself and carries the package version', () => {
31
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
32
+ expect(estiva.meta).toEqual({ name: '@estiva-app/ui/eslint', version: pkg.version })
33
+ })
34
+
35
+ it('carries every rule, the app ones and the inward ones', () => {
36
+ expect(Object.keys(estiva.rules)).toEqual([
37
+ 'no-raw-button',
38
+ 'raw-element-outside-a-wrapper',
39
+ 'no-hand-rolled-behaviour',
40
+ 'component-has-a-page',
41
+ 'component-has-a-story',
42
+ ])
43
+ })
44
+
45
+ /**
46
+ * The apps spread `recommended`. A rule added for the package (UIG-5) must not
47
+ * arrive in Peek or Ship with the next version bump: an app is full of raw
48
+ * elements it may keep until UIG-7, and has no `.mdx` pages at all. This is
49
+ * the test that holds that line — if you add an app-facing rule on purpose,
50
+ * change it deliberately, here.
51
+ */
52
+ it('gives an app only the app rules, as errors, under estiva/', () => {
53
+ for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
54
+ expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
55
+ expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
56
+ }
57
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-button'])
58
+ })
59
+
60
+ it('gives this package its own set, as errors, and it reaches no app config', () => {
61
+ expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
62
+ expect(estiva.configs.package.rules).toEqual({
63
+ 'estiva/raw-element-outside-a-wrapper': 'error',
64
+ 'estiva/no-hand-rolled-behaviour': 'error',
65
+ 'estiva/component-has-a-page': 'error',
66
+ 'estiva/component-has-a-story': 'error',
67
+ })
68
+ expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
69
+ for (const id of PACKAGE_RULE_IDS) {
70
+ expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
71
+ expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
72
+ }
73
+ })
74
+ })
75
+
76
+ describe('an app lint with configs.recommended', () => {
77
+ it('reports a raw <button>, naming Button', async () => {
78
+ const [result] = await lint(component(' <button type="button">x</button>'))
79
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
80
+ ['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
81
+ ])
82
+ })
83
+
84
+ it('passes the same element under an escape', async () => {
85
+ const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
86
+ expect(result.messages).toEqual([])
87
+ })
88
+ })
89
+
90
+ describe('countGates', () => {
91
+ it('counts an error, and an escape only when the lint reports escapes', async () => {
92
+ const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
93
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
94
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
95
+ })
96
+
97
+ it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
98
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
99
+ const count = countGates(results)
100
+ expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
101
+ expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
102
+ })
103
+
104
+ it('counts a marker inside that directive as an error of the rule', async () => {
105
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
106
+ const count = countGates(results)
107
+ expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
108
+ expect(count.disabled).toHaveLength(1)
109
+ })
110
+
111
+ it('lists every rule of the plugin, even with nothing found', () => {
112
+ expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
113
+ })
114
+ })
@@ -0,0 +1,145 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `@estiva-app/ui/eslint` — the UI Guardrails' lint rules, as a plugin (UIG-3,
4
+ * seam S1 in docs/GATES.md §16).
5
+ *
6
+ * A plugin rather than config, so every app — Peek, Ship, Leaf — gets a new
7
+ * rule through an ordinary version bump, and an app's editor hook runs the
8
+ * very rule code its CI runs.
9
+ *
10
+ * import estiva from '@estiva-app/ui/eslint'
11
+ * export default [{ files: ['src/**\/*.tsx'], ...estiva.configs.recommended }]
12
+ *
13
+ * Register it under `estiva` (the configs do): the rule ids are
14
+ * `estiva/<rule>`, and `countGates` counts those ids.
15
+ *
16
+ * Built on its own by build.mjs, for Node, into `dist/eslint/`; nothing here
17
+ * reaches the components' browser bundle.
18
+ */
19
+ import { createRequire } from 'node:module'
20
+ import type { ESLint, Linter } from 'eslint'
21
+ import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
22
+ import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
23
+ import { noRawButton } from './no-raw-button'
24
+ import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
25
+
26
+ export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
27
+
28
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
29
+
30
+ /** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
31
+ export const PLUGIN_KEY = 'estiva'
32
+
33
+ /**
34
+ * The rules an **app** runs: they say an app must not build what the package
35
+ * already has. `recommended` and `strict` carry these and only these.
36
+ */
37
+ const appRules = {
38
+ 'no-raw-button': noRawButton,
39
+ }
40
+
41
+ /**
42
+ * The rules the **package itself** runs, pointed inward (UIG-5): don't bury a
43
+ * raw element inside a component, don't rebuild what Base UI owns, don't ship a
44
+ * component without a page or a story.
45
+ *
46
+ * They are in `configs.package`, never in `recommended`, on purpose. Peek and
47
+ * Ship spread `recommended`, so a rule added here must not arrive in an app
48
+ * with the next version bump: an app is full of raw elements it is allowed to
49
+ * have until UIG-7, and has no `.mdx` pages at all. `index.test.ts` holds the
50
+ * apps' list to exactly the app rules.
51
+ */
52
+ const packageRules = {
53
+ 'raw-element-outside-a-wrapper': rawElementOutsideAWrapper,
54
+ 'no-hand-rolled-behaviour': noHandRolledBehaviour,
55
+ 'component-has-a-page': componentHasAPage,
56
+ 'component-has-a-story': componentHasAStory,
57
+ }
58
+
59
+ const rules = { ...appRules, ...packageRules }
60
+
61
+ const plugin = {
62
+ meta: { name: '@estiva-app/ui/eslint', version },
63
+ rules,
64
+ configs: {} as { recommended: Linter.Config; strict: Linter.Config; package: Linter.Config },
65
+ } satisfies ESLint.Plugin
66
+
67
+ /** The ids `recommended` and `strict` carry — what an app's gate runs and counts. */
68
+ export const APP_RULE_IDS = Object.keys(appRules).map((name) => `${PLUGIN_KEY}/${name}`)
69
+ /** The ids `package` carries — what this package's own gate runs and counts (UIG-5). */
70
+ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGIN_KEY}/${name}`)
71
+
72
+ /**
73
+ * `recommended` switches every **app** rule on at the level it was ruled at: an
74
+ * error blocks, a warning is reported and never blocks. `strict` makes every app
75
+ * rule an error. With one rule, an error, the two are the same today; they part
76
+ * when the first warning-level rule arrives (UIG-25).
77
+ *
78
+ * `package` is the inward set (UIG-5), which only this package runs.
79
+ */
80
+ plugin.configs.recommended = {
81
+ name: '@estiva-app/ui/recommended',
82
+ plugins: { [PLUGIN_KEY]: plugin },
83
+ rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
84
+ }
85
+ plugin.configs.strict = {
86
+ name: '@estiva-app/ui/strict',
87
+ plugins: { [PLUGIN_KEY]: plugin },
88
+ rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, 'error'])),
89
+ }
90
+ plugin.configs.package = {
91
+ name: '@estiva-app/ui/package',
92
+ plugins: { [PLUGIN_KEY]: plugin },
93
+ rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, 'error'])),
94
+ }
95
+
96
+ export default plugin
97
+
98
+ export interface GateRuleCount {
99
+ errors: number
100
+ warnings: number
101
+ escapes: number
102
+ }
103
+
104
+ export interface GateCount {
105
+ /** Per rule id, including rules with nothing to report. */
106
+ rules: Record<string, GateRuleCount>
107
+ /**
108
+ * Reports of these rules that an `eslint-disable` directive silenced. Not
109
+ * an escape: a gate fails on any of these (docs/GATES.md §16, S4).
110
+ */
111
+ disabled: { filePath: string; line: number; ruleId: string }[]
112
+ }
113
+
114
+ /**
115
+ * Count what a lint of these rules found, for `.gates-count.json` (seam S3).
116
+ *
117
+ * Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
118
+ * escapes to be counted; without it they are silent and count 0. A marker
119
+ * with no reason, or inside a directive, counts as an error of its rule.
120
+ *
121
+ * `seed` is which rules the count lists when they found nothing, and it is the
122
+ * app rules unless a caller says otherwise: an app's count file must not gain
123
+ * rows for the inward rules (UIG-5) that its gate does not run. This package's
124
+ * own count script passes `PACKAGE_RULE_IDS`.
125
+ */
126
+ export function countGates(results: ESLint.LintResult[], seed: readonly string[] = APP_RULE_IDS): GateCount {
127
+ const counts: Record<string, GateRuleCount> = Object.fromEntries(seed.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
128
+ const disabled: GateCount['disabled'] = []
129
+ const ours = (ruleId: string | null | undefined): ruleId is string => typeof ruleId === 'string' && ruleId.startsWith(`${PLUGIN_KEY}/`)
130
+ for (const result of results) {
131
+ for (const message of result.messages) {
132
+ // Seeded or not: every rule of this plugin that reported is counted, so a
133
+ // lint of a set the caller did not name is still counted in full.
134
+ if (!ours(message.ruleId)) continue
135
+ const count = (counts[message.ruleId] ??= { errors: 0, warnings: 0, escapes: 0 })
136
+ if (message.messageId === 'escaped') count.escapes += 1
137
+ else if (message.severity === 2) count.errors += 1
138
+ else count.warnings += 1
139
+ }
140
+ for (const message of result.suppressedMessages ?? []) {
141
+ if (ours(message.ruleId)) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })
142
+ }
143
+ }
144
+ return { rules: counts, disabled }
145
+ }