@estiva-app/ui 0.19.0 → 0.21.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 (112) hide show
  1. package/README.md +38 -2
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Divider.d.ts +7 -0
  10. package/dist/Divider.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +12 -2
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/IdentityMenu.d.ts.map +1 -1
  14. package/dist/Link.d.ts +6 -1
  15. package/dist/Link.d.ts.map +1 -1
  16. package/dist/Menu.d.ts.map +1 -1
  17. package/dist/Popover.d.ts.map +1 -1
  18. package/dist/ReactionPicker.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts +6 -1
  20. package/dist/SectionLabel.d.ts.map +1 -1
  21. package/dist/Tooltip.d.ts.map +1 -1
  22. package/dist/eslint/index.d.ts +2 -0
  23. package/dist/eslint/index.d.ts.map +1 -1
  24. package/dist/eslint/index.js +514 -6
  25. package/dist/eslint/index.js.map +4 -4
  26. package/dist/eslint/no-restyled-part.d.ts +81 -0
  27. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  28. package/dist/gates/app-checks.d.ts +40 -0
  29. package/dist/gates/app-checks.d.ts.map +1 -0
  30. package/dist/gates/chunk-AWO7SZSA.js +477 -0
  31. package/dist/gates/chunk-AWO7SZSA.js.map +7 -0
  32. package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
  33. package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
  34. package/dist/gates/cli.d.ts +2 -0
  35. package/dist/gates/cli.d.ts.map +1 -0
  36. package/dist/gates/cli.js +46 -0
  37. package/dist/gates/cli.js.map +7 -0
  38. package/dist/gates/count.d.ts +20 -0
  39. package/dist/gates/count.d.ts.map +1 -0
  40. package/dist/gates/create-app-cli.d.ts +2 -0
  41. package/dist/gates/create-app-cli.d.ts.map +1 -0
  42. package/dist/gates/create-app.d.ts +23 -0
  43. package/dist/gates/create-app.d.ts.map +1 -0
  44. package/dist/gates/create-app.js +30 -0
  45. package/dist/gates/create-app.js.map +7 -0
  46. package/dist/gates/gate-config.d.ts +44 -0
  47. package/dist/gates/gate-config.d.ts.map +1 -0
  48. package/dist/gates/hook.d.ts +18 -0
  49. package/dist/gates/hook.d.ts.map +1 -0
  50. package/dist/gates/index.d.ts +30 -0
  51. package/dist/gates/index.d.ts.map +1 -0
  52. package/dist/gates/index.js +415 -0
  53. package/dist/gates/index.js.map +7 -0
  54. package/dist/gates/status.d.ts +84 -0
  55. package/dist/gates/status.d.ts.map +1 -0
  56. package/dist/gates/token-lint.d.ts +26 -0
  57. package/dist/gates/token-lint.d.ts.map +1 -0
  58. package/dist/index.js +290 -266
  59. package/dist/index.js.map +4 -4
  60. package/package.json +30 -4
  61. package/src/AttachmentCard.tsx +7 -2
  62. package/src/Banner.tsx +2 -2
  63. package/src/Breadcrumb.tsx +9 -4
  64. package/src/Button.mdx +7 -0
  65. package/src/Button.stories.tsx +4 -1
  66. package/src/Button.tsx +10 -2
  67. package/src/Card.mdx +7 -0
  68. package/src/Card.stories.tsx +11 -0
  69. package/src/Card.tsx +8 -0
  70. package/src/CommandPalette.tsx +1 -1
  71. package/src/ConfirmDialog.tsx +2 -2
  72. package/src/DialogShell.tsx +6 -7
  73. package/src/Divider.mdx +3 -0
  74. package/src/Divider.tsx +12 -2
  75. package/src/EditableText.mdx +5 -0
  76. package/src/IconButton.mdx +9 -0
  77. package/src/IconButton.stories.tsx +10 -1
  78. package/src/IconButton.tsx +22 -3
  79. package/src/IdentityMenu.tsx +4 -1
  80. package/src/Link.mdx +6 -0
  81. package/src/Link.stories.tsx +12 -0
  82. package/src/Link.tsx +7 -2
  83. package/src/Menu.stories.tsx +1 -1
  84. package/src/Menu.test.tsx +2 -2
  85. package/src/Menu.tsx +6 -3
  86. package/src/Person.stories.tsx +5 -5
  87. package/src/Popover.test.tsx +2 -2
  88. package/src/Popover.tsx +3 -2
  89. package/src/PreviewCard.tsx +1 -1
  90. package/src/Property.stories.tsx +2 -2
  91. package/src/ReactionPicker.tsx +6 -3
  92. package/src/ScrollArea.stories.tsx +21 -7
  93. package/src/SectionLabel.mdx +3 -2
  94. package/src/SectionLabel.stories.tsx +3 -0
  95. package/src/SectionLabel.tsx +7 -2
  96. package/src/Tooltip.tsx +12 -14
  97. package/src/eslint/index.test.ts +32 -7
  98. package/src/eslint/index.ts +10 -1
  99. package/src/eslint/no-restyled-part.test.ts +301 -0
  100. package/src/eslint/no-restyled-part.ts +647 -0
  101. package/src/gates/app-checks.ts +224 -0
  102. package/src/gates/cli.ts +55 -0
  103. package/src/gates/count.ts +76 -0
  104. package/src/gates/create-app-cli.ts +25 -0
  105. package/src/gates/create-app.test.ts +72 -0
  106. package/src/gates/create-app.ts +797 -0
  107. package/src/gates/gate-config.ts +78 -0
  108. package/src/gates/gates.test.ts +183 -0
  109. package/src/gates/hook.ts +111 -0
  110. package/src/gates/index.ts +30 -0
  111. package/src/gates/status.ts +532 -0
  112. package/src/gates/token-lint.ts +231 -0
@@ -26,15 +26,15 @@ export const OwnFallback: Story = {
26
26
  parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
27
27
  }
28
28
 
29
- /** The text size is the caller's, so face and words are set together. */
29
+ /** The text size is where it sits: the box around it sets the words, `size` sets the face. */
30
30
  export const Sizes: Story = {
31
31
  parameters: { controls: { disable: true } },
32
32
  render: () => (
33
33
  <div className="flex flex-col items-start gap-3">
34
- <Person name="Ana Duarte" size={16} className="text-caption" />
35
- <Person name="Ana Duarte" size={20} className="text-body-2" />
36
- <Person name="Ravi Mehta" size={22} className="text-body-2" />
37
- <Person name="Ravi Mehta" size={28} className="text-body-1" />
34
+ <div className="flex text-caption"><Person name="Ana Duarte" size={16} /></div>
35
+ <div className="flex text-body-2"><Person name="Ana Duarte" size={20} /></div>
36
+ <div className="flex text-body-2"><Person name="Ravi Mehta" size={22} /></div>
37
+ <div className="flex text-body-1"><Person name="Ravi Mehta" size={28} /></div>
38
38
  </div>
39
39
  ),
40
40
  }
@@ -233,8 +233,8 @@ describe('Popover, centred on its anchor', () => {
233
233
  * as on 0.12.5.
234
234
  */
235
235
  describe('Popover, padding', () => {
236
- // The scrolling content is the box that carries the separator rule.
237
- const contentOf = (child: HTMLElement) => child.closest('[class*="role=separator"]') as HTMLElement
236
+ // The scrolling content is the box the children sit in directly.
237
+ const contentOf = (child: HTMLElement) => child.parentElement as HTMLElement
238
238
 
239
239
  it('pads its content 8px by default', async () => {
240
240
  render(
package/src/Popover.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'react'
2
2
  import { Popover as BasePopover } from '@base-ui/react/popover'
3
3
  import { cn } from './cn'
4
+ import { DividerInPanel } from './Divider'
4
5
  import { triggerDisabled } from './triggerDisabled'
5
6
  import { ScrollArea } from './ScrollArea'
6
7
  import { MenuPanel } from './Menu'
@@ -200,9 +201,9 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
200
201
  — see the props. */}
201
202
  <ScrollArea
202
203
  viewportClassName={maxHeight ?? 'max-h-[var(--available-height)]'}
203
- contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}
204
+ contentClassName={cn('flex flex-col p-2', contentClassName)}
204
205
  >
205
- {children}
206
+ <DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
206
207
  </ScrollArea>
207
208
  </BasePopover.Popup>
208
209
  </BasePopover.Positioner>
@@ -83,7 +83,7 @@ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DE
83
83
  className={cn('w-[360px] p-3 outline-none', className)}
84
84
  render={<MenuPanel />}
85
85
  >
86
- <ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3 [&>*]:shrink-0">
86
+ <ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3">
87
87
  {content}
88
88
  </ScrollArea>
89
89
  </BasePreviewCard.Popup>
@@ -28,7 +28,7 @@ export const APanel: Story = {
28
28
  <Chip type="success" label="Done" />
29
29
  </Property>
30
30
  <Property label="Lead">
31
- <Person name="Ana Duarte" size={16} className="text-caption text-text-primary" />
31
+ <div className="flex text-caption text-text-primary"><Person name="Ana Duarte" size={16} /></div>
32
32
  </Property>
33
33
  <Property label="Updated">
34
34
  <span className="text-caption text-text-secondary">2d ago</span>
@@ -42,7 +42,7 @@ export const Stacked: Story = {
42
42
  args: {
43
43
  label: 'Assignee',
44
44
  layout: 'stacked',
45
- children: <Person name="Ravi Mehta" size={20} className="text-body-2 text-text-primary" />,
45
+ children: <div className="flex text-body-2 text-text-primary"><Person name="Ravi Mehta" size={20} /></div>,
46
46
  },
47
47
  }
48
48
 
@@ -77,10 +77,13 @@ export function ReactionPicker({ options, onSelect, 'aria-label': ariaLabel = 'R
77
77
  /* Peek's geometry, verbatim: a 28px square rather than the
78
78
  IconButton's 24, and the emoji at 18px — larger than a `Reaction`
79
79
  pill's 16, because here the emoji is the whole control. */
80
- className="size-7 text-h3 leading-none"
80
+ className="size-7"
81
81
  >
82
- {/* Decorative, as on `Reaction`: the control is named above. */}
83
- <span aria-hidden="true">{option.emoji}</span>
82
+ {/* Decorative, as on `Reaction`: the control is named above. Its size is
83
+ on the emoji itself, not pushed into the button (UIG-9). */}
84
+ <span aria-hidden="true" className="text-h3 leading-none">
85
+ {option.emoji}
86
+ </span>
84
87
  </ToolbarButton>
85
88
  ))}
86
89
  </Toolbar>
@@ -17,7 +17,8 @@ const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
17
17
  /** A list taller than its box. The bar shows while the pointer is over it or the list is moving. */
18
18
  export const Default: Story = {
19
19
  render: () => (
20
- <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
20
+ <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
21
+ <ScrollArea className="h-full" viewportClassName="p-2">
21
22
  <ul className="flex flex-col gap-px">
22
23
  {rows.map((row) => (
23
24
  <li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
@@ -26,13 +27,15 @@ export const Default: Story = {
26
27
  ))}
27
28
  </ul>
28
29
  </ScrollArea>
30
+ </div>
29
31
  ),
30
32
  }
31
33
 
32
34
  /** Rows that stick to the top as the list moves under them. The bar stays above them. */
33
35
  export const StickyHeadings: Story = {
34
36
  render: () => (
35
- <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
37
+ <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
38
+ <ScrollArea className="h-full">
36
39
  {['Group one', 'Group two'].map((group) => (
37
40
  <div key={group} className="flex flex-col">
38
41
  <p className="sticky top-0 z-10 bg-bg-surface px-4 py-2 text-caption text-text-secondary">{group}</p>
@@ -44,13 +47,15 @@ export const StickyHeadings: Story = {
44
47
  </div>
45
48
  ))}
46
49
  </ScrollArea>
50
+ </div>
47
51
  ),
48
52
  }
49
53
 
50
54
  /** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
51
55
  export const Fits: Story = {
52
56
  render: () => (
53
- <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
57
+ <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
58
+ <ScrollArea className="h-full" viewportClassName="p-2">
54
59
  <ul className="flex flex-col gap-px">
55
60
  {rows.slice(0, 4).map((row) => (
56
61
  <li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
@@ -59,13 +64,15 @@ export const Fits: Story = {
59
64
  ))}
60
65
  </ul>
61
66
  </ScrollArea>
67
+ </div>
62
68
  ),
63
69
  }
64
70
 
65
71
  /** A row wider than its box — a table, a board. */
66
72
  export const Horizontal: Story = {
67
73
  render: () => (
68
- <ScrollArea orientation="horizontal" className="w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
74
+ <div className="w-[280px] rounded-lg border border-border-default bg-bg-surface">
75
+ <ScrollArea orientation="horizontal" viewportClassName="p-2">
69
76
  <div className="flex w-max gap-2">
70
77
  {rows.slice(0, 12).map((row) => (
71
78
  <div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
@@ -74,13 +81,15 @@ export const Horizontal: Story = {
74
81
  ))}
75
82
  </div>
76
83
  </ScrollArea>
84
+ </div>
77
85
  ),
78
86
  }
79
87
 
80
88
  /** Both ways, with the corner where the two bars would meet. */
81
89
  export const Both: Story = {
82
90
  render: () => (
83
- <ScrollArea orientation="both" className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
91
+ <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
92
+ <ScrollArea orientation="both" className="h-full" viewportClassName="p-2">
84
93
  <div className="flex w-max flex-col gap-px">
85
94
  {rows.map((row) => (
86
95
  <div key={row} className="w-[480px] rounded-md px-2 py-1.5 text-body-2 text-text-primary">
@@ -89,20 +98,23 @@ export const Both: Story = {
89
98
  ))}
90
99
  </div>
91
100
  </ScrollArea>
101
+ </div>
92
102
  ),
93
103
  }
94
104
 
95
105
  /** A sideways region inside a scrolling page — a table in a content column. A wheel down over it moves the page; a swipe sideways moves the region. */
96
106
  export const SidewaysInsideAPage: Story = {
97
107
  render: () => (
98
- <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
108
+ <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
109
+ <ScrollArea className="h-full" viewportClassName="p-2">
99
110
  <div className="flex flex-col gap-px">
100
111
  {rows.slice(0, 3).map((row) => (
101
112
  <p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
102
113
  {row}
103
114
  </p>
104
115
  ))}
105
- <ScrollArea orientation="horizontal" className="my-1 rounded-md border border-border-default" viewportClassName="p-2">
116
+ <div className="my-1 rounded-md border border-border-default">
117
+ <ScrollArea orientation="horizontal" viewportClassName="p-2">
106
118
  <div className="flex w-max gap-2">
107
119
  {rows.slice(0, 12).map((row) => (
108
120
  <div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
@@ -111,6 +123,7 @@ export const SidewaysInsideAPage: Story = {
111
123
  ))}
112
124
  </div>
113
125
  </ScrollArea>
126
+ </div>
114
127
  {rows.slice(3).map((row) => (
115
128
  <p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
116
129
  {row}
@@ -118,5 +131,6 @@ export const SidewaysInsideAPage: Story = {
118
131
  ))}
119
132
  </div>
120
133
  </ScrollArea>
134
+ </div>
121
135
  ),
122
136
  }
@@ -34,8 +34,9 @@ import { SectionLabel } from '@estiva-app/ui'
34
34
  ```
35
35
 
36
36
  It is a bare span: the caller owns the row it sits in and the spacing
37
- around it. Read it `secondary` (via `className`) where it labels rather
38
- than leads menu headings do.
37
+ around it. Read it `tone="secondary"` where it labels rather than leads — menu
38
+ headings and the command palette's groups do (Katerina, 2026-09-01). A colour
39
+ passed in `className` is refused by the lint (UIG-9).
39
40
 
40
41
  ## Props
41
42
 
@@ -29,3 +29,6 @@ export const HeadingAList: Story = {
29
29
  </div>
30
30
  ),
31
31
  }
32
+
33
+ /** `tone="secondary"`: a heading inside a menu or a list of results labels the rows, it is not one of them. */
34
+ export const Secondary: Story = { args: { tone: 'secondary', children: 'Section' } }
@@ -9,16 +9,21 @@ import { cn } from './cn'
9
9
  * micro-label under Signal. Metrics as arbitrary values for the tw-merge
10
10
  * reason the README records.
11
11
  *
12
+ * `tone="secondary"` is a heading inside a menu or a list of results: it labels
13
+ * the rows, it is not one of them (Katerina, 2026-09-01). `MenuSection` and
14
+ * `CommandPalette` passed it as a class before UIG-9 (17 September).
15
+ *
12
16
  * Deliberately NOT the same thing as Property's stacked field label (the
13
17
  * 9px `menu`-token one): they were merged for a day and unmerged by
14
18
  * Katerina's ruling (2026-09-01) — a section title and a field label are
15
19
  * different voices, at different sizes, on purpose.
16
20
  */
17
- export function SectionLabel({ children, className }: { children: ReactNode; className?: string }) {
21
+ export function SectionLabel({ children, tone = 'primary', className }: { children: ReactNode; tone?: 'primary' | 'secondary'; className?: string }) {
18
22
  return (
19
23
  <span
20
24
  className={cn(
21
- 'text-h5 leading-3 text-text-primary signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
25
+ 'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
26
+ tone === 'secondary' ? 'text-text-secondary' : 'text-text-primary',
22
27
  className,
23
28
  )}
24
29
  >
package/src/Tooltip.tsx CHANGED
@@ -111,11 +111,8 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
111
111
  className="pointer-events-none z-[9999]"
112
112
  >
113
113
  <BaseTooltip.Popup
114
- render={
115
- <Tooltip
116
- label={label}
117
- shortcut={shortcut}
118
- /*
114
+ render={<Tooltip label={label} shortcut={shortcut} />}
115
+ /*
119
116
  * The motion (Katerina, D26, 2026-09-07). It had none — it was
120
117
  * mounted and instantly there — and Base UI is what makes one
121
118
  * possible: `data-starting-style` on the opening frame,
@@ -132,16 +129,17 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
132
129
  * tooltip was dismissed. Sweeping a toolbar therefore moves the
133
130
  * pill without re-animating it, which is the whole point of the
134
131
  * shared delay (D23).
132
+ *
133
+ * On Base UI's popup, which hands its class to the pill it renders,
134
+ * rather than pushed into the pill from outside (UIG-9).
135
135
  */
136
- className={cn(
137
- 'transition-[opacity,transform] duration-[120ms] ease-out motion-reduce:transition-none',
138
- 'data-[ending-style]:duration-[80ms] data-[instant]:duration-0',
139
- 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
140
- 'data-[side=top]:data-[starting-style]:translate-y-[4px] data-[side=top]:data-[ending-style]:translate-y-[4px]',
141
- 'data-[side=bottom]:data-[starting-style]:-translate-y-[4px] data-[side=bottom]:data-[ending-style]:-translate-y-[4px]',
142
- )}
143
- />
144
- }
136
+ className={cn(
137
+ 'transition-[opacity,transform] duration-[120ms] ease-out motion-reduce:transition-none',
138
+ 'data-[ending-style]:duration-[80ms] data-[instant]:duration-0',
139
+ 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
140
+ 'data-[side=top]:data-[starting-style]:translate-y-[4px] data-[side=top]:data-[ending-style]:translate-y-[4px]',
141
+ 'data-[side=bottom]:data-[starting-style]:-translate-y-[4px] data-[side=bottom]:data-[ending-style]:-translate-y-[4px]',
142
+ )}
145
143
  />
146
144
  </BaseTooltip.Positioner>
147
145
  </BaseTooltip.Portal>
@@ -36,6 +36,7 @@ describe('the plugin object', () => {
36
36
  expect(Object.keys(estiva.rules)).toEqual([
37
37
  'no-raw-element',
38
38
  'no-rebuilt-behaviour',
39
+ 'no-restyled-part',
39
40
  'raw-element-outside-a-wrapper',
40
41
  'no-hand-rolled-behaviour',
41
42
  'component-has-a-page',
@@ -55,21 +56,27 @@ describe('the plugin object', () => {
55
56
  it('gives an app only the app rules, as errors, under estiva/', () => {
56
57
  for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
57
58
  expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
58
- expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error' })
59
+ expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error' })
59
60
  }
60
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour'])
61
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part'])
61
62
  })
62
63
 
63
- it('gives this package its own set, as errors, and it reaches no app config', () => {
64
+ /**
65
+ * `no-restyled-part` is the one rule in both sets (UIG-9): the package restyles
66
+ * none of its own parts either (Katerina, 17 September). Every other inward
67
+ * rule still reaches no app.
68
+ */
69
+ it('gives this package its own set, as errors, and only the shared rule reaches an app config', () => {
64
70
  expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
65
71
  expect(estiva.configs.package.rules).toEqual({
66
72
  'estiva/raw-element-outside-a-wrapper': 'error',
67
73
  'estiva/no-hand-rolled-behaviour': 'error',
68
74
  'estiva/component-has-a-page': 'error',
69
75
  'estiva/component-has-a-story': 'error',
76
+ 'estiva/no-restyled-part': 'error',
70
77
  })
71
78
  expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
72
- for (const id of PACKAGE_RULE_IDS) {
79
+ for (const id of PACKAGE_RULE_IDS.filter((id) => id !== 'estiva/no-restyled-part')) {
73
80
  expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
74
81
  expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
75
82
  }
@@ -110,6 +117,22 @@ describe('an app lint with configs.recommended', () => {
110
117
  expect(result.messages.filter((m) => m.messageId === 'role' || m.messageId === 'roleNoPart')).toEqual([])
111
118
  })
112
119
 
120
+ /**
121
+ * The probe is linted inside this package's folder, so its part is a sibling
122
+ * (`./Link`), the way the package's own files import one; in an app the same
123
+ * rule reads an import from `@estiva-app/ui` (no-restyled-part.test.ts).
124
+ */
125
+ it('reports a part restyled, naming its look props', async () => {
126
+ const [result] = await lint(`import { Link } from './Link'\n${component(' <Link href="/x" className="mt-2 text-h2">x</Link>')}`)
127
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
128
+ [
129
+ 'estiva/no-restyled-part',
130
+ 2,
131
+ '`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `truncate`. A look for what is around it goes on your own element around it.',
132
+ ],
133
+ ])
134
+ })
135
+
113
136
  it('passes the same element under an escape', async () => {
114
137
  const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
115
138
  expect(result.messages).toEqual([])
@@ -120,8 +143,8 @@ describe('countGates', () => {
120
143
  it('counts an error, and an escape only when the lint reports escapes', async () => {
121
144
  const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
122
145
  const none = { errors: 0, warnings: 0, escapes: 0 }
123
- expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none })
124
- expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none })
146
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
147
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
125
148
  })
126
149
 
127
150
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
@@ -140,7 +163,9 @@ describe('countGates', () => {
140
163
 
141
164
  it('lists every rule of the plugin, even with nothing found', () => {
142
165
  expect(countGates([])).toEqual({
143
- rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 } },
166
+ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 },
167
+ 'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 },
168
+ },
144
169
  disabled: [],
145
170
  })
146
171
  })
@@ -22,10 +22,12 @@ import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
22
22
  import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
23
23
  import { noRawElement } from './no-raw-element'
24
24
  import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
25
+ import { noRestyledPart } from './no-restyled-part'
25
26
  import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
26
27
 
27
28
  export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
28
29
  export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
30
+ export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
29
31
 
30
32
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
31
33
 
@@ -35,11 +37,13 @@ export const PLUGIN_KEY = 'estiva'
35
37
  /**
36
38
  * The rules an **app** runs: they say an app must not build what the package
37
39
  * already has — a raw control (UIG-7), or a behaviour one of its parts owns
38
- * (UIG-8). `recommended` and `strict` carry these and only these.
40
+ * (UIG-8) — nor restyle a part it uses (UIG-9). `recommended` and `strict`
41
+ * carry these and only these.
39
42
  */
40
43
  const appRules = {
41
44
  'no-raw-element': noRawElement,
42
45
  'no-rebuilt-behaviour': noRebuiltBehaviour,
46
+ 'no-restyled-part': noRestyledPart,
43
47
  }
44
48
 
45
49
  /**
@@ -53,12 +57,16 @@ const appRules = {
53
57
  * "buried inside a component" means nothing there (`no-raw-element` is the
54
58
  * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
55
59
  * apps' list to exactly the app rules.
60
+ *
61
+ * One rule is in both sets: `no-restyled-part` (UIG-9). The package restyles
62
+ * none of its own parts either (Katerina, 17 September).
56
63
  */
57
64
  const packageRules = {
58
65
  'raw-element-outside-a-wrapper': rawElementOutsideAWrapper,
59
66
  'no-hand-rolled-behaviour': noHandRolledBehaviour,
60
67
  'component-has-a-page': componentHasAPage,
61
68
  'component-has-a-story': componentHasAStory,
69
+ 'no-restyled-part': noRestyledPart,
62
70
  }
63
71
 
64
72
  const rules = { ...appRules, ...packageRules }
@@ -88,6 +96,7 @@ plugin.configs.recommended = {
88
96
  rules: {
89
97
  [`${PLUGIN_KEY}/no-raw-element`]: 'error',
90
98
  [`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error',
99
+ [`${PLUGIN_KEY}/no-restyled-part`]: 'error',
91
100
  },
92
101
  }
93
102
  plugin.configs.strict = {