@estiva-app/ui 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/ListColumn.d.ts +41 -0
  7. package/dist/ListColumn.d.ts.map +1 -0
  8. package/dist/PreviewCard.d.ts +9 -2
  9. package/dist/PreviewCard.d.ts.map +1 -1
  10. package/dist/SectionHeader.d.ts +7 -1
  11. package/dist/SectionHeader.d.ts.map +1 -1
  12. package/dist/Select.d.ts +12 -3
  13. package/dist/Select.d.ts.map +1 -1
  14. package/dist/Toolbar.d.ts +18 -0
  15. package/dist/Toolbar.d.ts.map +1 -1
  16. package/dist/Tooltip.d.ts +13 -1
  17. package/dist/Tooltip.d.ts.map +1 -1
  18. package/dist/eslint/index.js +2 -1
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +189 -143
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/registry.json +214 -11
  27. package/src/AppShell.mdx +18 -1
  28. package/src/AppShell.test.tsx +16 -0
  29. package/src/AppShell.tsx +4 -1
  30. package/src/AttachmentCard.mdx +12 -1
  31. package/src/AttachmentCard.test.tsx +10 -0
  32. package/src/AttachmentCard.tsx +8 -5
  33. package/src/Avatar.mdx +20 -5
  34. package/src/AvatarGroup.mdx +4 -0
  35. package/src/Banner.mdx +17 -8
  36. package/src/Breadcrumb.mdx +12 -0
  37. package/src/Button.mdx +17 -5
  38. package/src/Button.tsx +2 -2
  39. package/src/Card.mdx +14 -1
  40. package/src/Checkbox.mdx +11 -0
  41. package/src/Chip.mdx +4 -0
  42. package/src/ChipInput.mdx +13 -1
  43. package/src/CollapsibleSection.mdx +12 -3
  44. package/src/CommandPalette.mdx +14 -2
  45. package/src/ConfirmDialog.mdx +13 -2
  46. package/src/ContainerHeader.mdx +15 -2
  47. package/src/DialogShell.mdx +15 -1
  48. package/src/Divider.mdx +12 -1
  49. package/src/EditableText.mdx +17 -1
  50. package/src/EmptyState.mdx +17 -6
  51. package/src/Field.mdx +13 -5
  52. package/src/FieldLine.mdx +9 -1
  53. package/src/FilePicker.mdx +8 -0
  54. package/src/Form.mdx +11 -1
  55. package/src/Form.test.tsx +26 -0
  56. package/src/Form.tsx +7 -0
  57. package/src/IconButton.mdx +17 -3
  58. package/src/IconButton.stories.tsx +3 -0
  59. package/src/IconButton.test.tsx +25 -0
  60. package/src/IconButton.tsx +61 -46
  61. package/src/IdentityMenu.mdx +10 -1
  62. package/src/InlineChip.mdx +11 -0
  63. package/src/Kbd.mdx +6 -2
  64. package/src/Link.mdx +13 -1
  65. package/src/ListColumn.mdx +87 -0
  66. package/src/ListColumn.stories.tsx +136 -0
  67. package/src/ListColumn.test.tsx +50 -0
  68. package/src/ListColumn.tsx +63 -0
  69. package/src/Menu.mdx +18 -1
  70. package/src/MenuItem.mdx +15 -2
  71. package/src/NavItem.mdx +12 -0
  72. package/src/Person.mdx +7 -0
  73. package/src/PersonTrigger.mdx +11 -1
  74. package/src/PersonTrigger.tsx +1 -1
  75. package/src/Popover.mdx +23 -0
  76. package/src/PreviewCard.mdx +19 -4
  77. package/src/PreviewCard.tsx +11 -4
  78. package/src/ProgressBar.mdx +8 -0
  79. package/src/Property.mdx +4 -0
  80. package/src/Rail.mdx +12 -1
  81. package/src/RailItem.mdx +12 -0
  82. package/src/RailItem.tsx +1 -1
  83. package/src/Reaction.mdx +10 -0
  84. package/src/Reaction.tsx +1 -1
  85. package/src/ReactionPicker.mdx +8 -0
  86. package/src/ScrollArea.mdx +16 -2
  87. package/src/SearchInput.mdx +9 -0
  88. package/src/SectionHeader.mdx +18 -1
  89. package/src/SectionHeader.stories.tsx +9 -0
  90. package/src/SectionHeader.test.tsx +9 -0
  91. package/src/SectionHeader.tsx +9 -3
  92. package/src/SectionLabel.mdx +10 -2
  93. package/src/Select.mdx +24 -4
  94. package/src/Select.stories.tsx +4 -1
  95. package/src/Select.test.tsx +29 -0
  96. package/src/Select.tsx +36 -14
  97. package/src/Sidebar.mdx +8 -0
  98. package/src/Skeleton.mdx +8 -0
  99. package/src/Skeleton.tsx +1 -1
  100. package/src/Tabs.mdx +12 -1
  101. package/src/TextInput.mdx +9 -0
  102. package/src/TextInput.tsx +1 -1
  103. package/src/Textarea.mdx +8 -0
  104. package/src/Toast.mdx +12 -1
  105. package/src/Toolbar.mdx +20 -2
  106. package/src/Toolbar.stories.tsx +19 -2
  107. package/src/Toolbar.test.tsx +24 -1
  108. package/src/Toolbar.tsx +22 -0
  109. package/src/Tooltip.mdx +27 -2
  110. package/src/Tooltip.stories.tsx +26 -0
  111. package/src/Tooltip.test.tsx +51 -0
  112. package/src/Tooltip.tsx +17 -4
  113. package/src/TopBar.mdx +11 -0
  114. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  115. package/src/eslint/no-restyled-part.ts +1 -0
  116. package/src/heights.test.tsx +78 -0
  117. package/src/index.ts +2 -1
  118. package/src/pages.test.ts +142 -0
  119. package/src/registry/registry.test.ts +16 -8
  120. package/tailwind-preset.js +22 -1
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A part with a set height keeps it in a scrolling column (UIG-15; UIG-14 cards
4
+ * T1, M4, M6). A scrolling flex column squashes any child that may shrink:
5
+ * measured in Chrome, a 32px SectionHeader went to 24px, a Skeleton row to 18px,
6
+ * a Button to 18px. jsdom draws nothing, so this pins the class that prevents it;
7
+ * the measurement is in the record.
8
+ *
9
+ * Rows that span their column refuse to shrink (`shrink-0`). Controls, which
10
+ * mostly sit in rows, keep a minimum height instead: `shrink-0` would also stop
11
+ * a row from narrowing them.
12
+ */
13
+ import { afterEach, describe, expect, it } from 'vitest'
14
+ import { cleanup, render, screen } from '@testing-library/react'
15
+ import { Button } from './Button'
16
+ import { PersonTrigger } from './PersonTrigger'
17
+ import { RailItem } from './RailItem'
18
+ import { Reaction } from './Reaction'
19
+ import { SectionHeader } from './SectionHeader'
20
+ import { Select } from './Select'
21
+ import { SkeletonRow } from './Skeleton'
22
+ import { TextInput } from './TextInput'
23
+
24
+ afterEach(cleanup)
25
+
26
+ const classes = (el: Element | null) => (el?.getAttribute('class') ?? '').split(' ')
27
+
28
+ describe('a part with a set height keeps it in a scrolling column', () => {
29
+ it('SectionHeader: shrink-0', () => {
30
+ const { container } = render(<SectionHeader title="Group" />)
31
+ expect(classes(container.querySelector('[class*="h-[32px]"]'))).toContain('shrink-0')
32
+ })
33
+
34
+ it('SkeletonRow: shrink-0', () => {
35
+ const { container } = render(<SkeletonRow />)
36
+ expect(classes(container.firstElementChild)).toContain('shrink-0')
37
+ })
38
+
39
+ it('RailItem: a set 48px, and shrink-0', () => {
40
+ render(<RailItem href="#" label="Item" icon={<span />} />)
41
+ const tile = classes(screen.getByRole('link'))
42
+ expect(tile).toContain('h-12')
43
+ expect(tile).toContain('shrink-0')
44
+ })
45
+
46
+ it('Button: a minimum height at each size', () => {
47
+ render(
48
+ <>
49
+ <Button>Default</Button>
50
+ <Button size="small">Small</Button>
51
+ </>,
52
+ )
53
+ expect(classes(screen.getByRole('button', { name: 'Default' }))).toContain('min-h-8')
54
+ expect(classes(screen.getByRole('button', { name: 'Small' }))).toContain('min-h-6')
55
+ })
56
+
57
+ it('Select and TextInput, small: a minimum height', () => {
58
+ render(
59
+ <>
60
+ <Select size="small" value="a" onChange={() => {}} options={[{ value: 'a', label: 'A' }]} ariaLabel="Pick" />
61
+ <TextInput size="small" aria-label="Type" />
62
+ </>,
63
+ )
64
+ expect(classes(screen.getByRole('combobox', { name: 'Pick' }))).toContain('min-h-6')
65
+ expect(classes(screen.getByRole('textbox', { name: 'Type' }))).toContain('min-h-6')
66
+ })
67
+
68
+ it('PersonTrigger and Reaction: a minimum height', () => {
69
+ render(
70
+ <>
71
+ <PersonTrigger name="Ana Duarte" />
72
+ <Reaction emoji="👍" count={2} aria-label="Thumbs up" />
73
+ </>,
74
+ )
75
+ expect(classes(screen.getByRole('button', { name: /Ana Duarte/ }))).toContain('min-h-8')
76
+ expect(classes(screen.getByRole('button', { name: 'Thumbs up' }))).toContain('min-h-6')
77
+ })
78
+ })
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ export { NavItem, type NavItemProps } from './NavItem'
68
68
  export { Popover, type PopoverProps } from './Popover'
69
69
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
70
70
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
71
- export { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps } from './Toolbar'
71
+ export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
72
72
  export { PreviewCard, type PreviewCardProps } from './PreviewCard'
73
73
  export { Person, type PersonProps } from './Person'
74
74
  export { Rail, type RailProps } from './Rail'
@@ -81,6 +81,7 @@ export { Reaction, type ReactionProps } from './Reaction'
81
81
  export { SearchInput, type SearchInputProps } from './SearchInput'
82
82
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
83
83
  export { ContainerHeader, type ContainerHeaderProps } from './ContainerHeader'
84
+ export { ListColumn, type ListColumnProps } from './ListColumn'
84
85
  export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
85
86
  export { SectionLabel } from './SectionLabel'
86
87
  export { Tabs, type TabDef, type TabsProps } from './Tabs'
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The contract every component page keeps (UIG-14, with UIG-15 and UIG-16 folded in).
3
+ *
4
+ * A page opens with a line saying what the part is, then has `When`, `When not`,
5
+ * `How` (with code) and `What it owns`, in that order, before `Keys` and `Props`.
6
+ *
7
+ * `What it owns` reads the checker's own list back. A row ticked ✓ in its third
8
+ * column is a behaviour from `OWNED_BEHAVIOURS` that one of the page's parts owns,
9
+ * worded as `ROW` below; every behaviour the page's parts own has one; and a row
10
+ * worded as a behaviour the parts do not own fails. So the page and the checker
11
+ * cannot say different things. A part that owns nothing says so in one line.
12
+ *
13
+ * A `When not` line names only parts that exist: a bold name is a value export of
14
+ * `index.ts`, or a page.
15
+ */
16
+ import { readdirSync, readFileSync } from 'node:fs'
17
+ import { describe, expect, it } from 'vitest'
18
+ import { OWNED_BEHAVIOURS } from './eslint/no-rebuilt-behaviour'
19
+
20
+ /** How a page words each behaviour. `base-ui` is how a part is built, not what it does for a caller. */
21
+ const ROW: Record<string, RegExp> = {
22
+ portal: /^Floats on top of the page\b/,
23
+ 'press-outside': /^Closes on a press outside\b/,
24
+ 'page-keys': /^Takes its keys by itself\b/,
25
+ focus: /^Holds focus inside while open\b/,
26
+ 'scroll-lock': /^Stops the page behind it scrolling\b/,
27
+ follow: /^Stays attached to its anchor\b/,
28
+ walking: /^Moves through its items with the arrow keys\b/,
29
+ role: /^Says what it is to assistive technology\b/,
30
+ 'tab-stop': /^Is reachable with Tab\b/,
31
+ scroll: /^Scrolls with our scrollbar\b/,
32
+ }
33
+ const NOT_A_ROW = ['base-ui']
34
+
35
+ /** A page whose part another ticket is changing, while it is; empty when none is. */
36
+ const WAITING: string[] = []
37
+
38
+ const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
39
+
40
+ const dir = new URL('.', import.meta.url)
41
+ const read = (file: string) => readFileSync(new URL(file, dir), 'utf8').replace(/\r\n/g, '\n')
42
+ const pages = readdirSync(dir)
43
+ .filter((f) => f.endsWith('.mdx'))
44
+ .map((f) => f.slice(0, -4))
45
+
46
+ const index = read('index.ts')
47
+ /** Value exports of index.ts, by the module they come from. */
48
+ const byModule = new Map<string, string[]>()
49
+ for (const m of index.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([\w/]+)'/g)) {
50
+ const names = m[1]
51
+ .split(',')
52
+ .map((s) => s.trim())
53
+ .filter((s) => s && !s.startsWith('type '))
54
+ .map((s) => s.split(/\s+as\s+/).pop()!)
55
+ byModule.set(m[2], [...(byModule.get(m[2]) ?? []), ...names])
56
+ }
57
+ const exported = new Set([...byModule.values()].flat())
58
+
59
+ /** The parts a page documents: its own name, and its module's exports that have no page of their own. */
60
+ const partsOf = (page: string) => [page, ...(byModule.get(page) ?? []).filter((n) => n !== page && !pages.includes(n))]
61
+
62
+ const section = (t: string, name: string) => {
63
+ const head = `\n## ${name}\n`
64
+ const at = t.indexOf(head)
65
+ if (at < 0) return null
66
+ const rest = t.slice(at + head.length)
67
+ const end = rest.search(/^## /m)
68
+ return end < 0 ? rest : rest.slice(0, end)
69
+ }
70
+
71
+ const owns = (t: string) =>
72
+ (section(t, 'What it owns') ?? '')
73
+ .split('\n')
74
+ .filter((l) => l.startsWith('| ') && !l.startsWith('| It does |'))
75
+ .map((l) => l.split('|').map((c) => c.trim()))
76
+ .map(([, does, never, ticked]) => ({ does, never, ticked: ticked === '✓' }))
77
+
78
+ describe('the page contract', () => {
79
+ it('words every behaviour the checker knows', () => {
80
+ const ids = OWNED_BEHAVIOURS.map((b) => b.id).filter((id) => !NOT_A_ROW.includes(id))
81
+ expect(Object.keys(ROW).sort()).toEqual(ids.sort())
82
+ })
83
+
84
+ it('finds the pages', () => {
85
+ expect(pages.length).toBeGreaterThanOrEqual(54)
86
+ })
87
+
88
+ for (const page of pages) {
89
+ describe(page, () => {
90
+ const t = read(`${page}.mdx`)
91
+ const headings = [...t.matchAll(/^## (.+)$/gm)].map((m) => m[1])
92
+
93
+ it('opens with a line saying what it is', () => {
94
+ const opening = t.split(/^# .*$/m)[1]?.split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('<'))
95
+ expect(opening, 'no opening line under the title').toBeTruthy()
96
+ })
97
+
98
+ it('has each section once', () => {
99
+ expect(headings.filter((h, i) => headings.indexOf(h) !== i)).toEqual([])
100
+ })
101
+
102
+ it('has its sections in order', () => {
103
+ const want = WAITING.includes(page) ? ['When', 'When not', 'How'] : ['When', 'When not', 'How', 'What it owns']
104
+ const at = want.map((h) => headings.indexOf(h))
105
+ expect(at.filter((i) => i < 0).length, `missing: ${want.filter((_, i) => at[i] < 0).join(', ')}`).toBe(0)
106
+ expect([...at].sort((a, b) => a - b)).toEqual(at)
107
+ for (const after of ['Keys', 'Props']) {
108
+ const i = headings.indexOf(after)
109
+ if (i >= 0) expect(at[at.length - 1], `${want[want.length - 1]} comes before ${after}`).toBeLessThan(i)
110
+ }
111
+ })
112
+
113
+ it('shows code under How', () => {
114
+ expect(section(t, 'How')).toMatch(/```tsx?\n/)
115
+ })
116
+
117
+ it('names only parts that exist under When not', () => {
118
+ const names = [...(section(t, 'When not') ?? '').matchAll(/\*\*`?([A-Z][A-Za-z0-9]*)`?\*\*/g)].map((m) => m[1])
119
+ expect(names.filter((n) => !exported.has(n) && !pages.includes(n))).toEqual([])
120
+ })
121
+
122
+ if (WAITING.includes(page)) return
123
+
124
+ it('says what it owns, as the checker does', () => {
125
+ const parts = partsOf(page)
126
+ const owned = OWNED_BEHAVIOURS.filter((b) => !NOT_A_ROW.includes(b.id) && b.owners.some((o) => parts.includes(o))).map((b) => b.id)
127
+ const rows = owns(t)
128
+ const body = (section(t, 'What it owns') ?? '').trim()
129
+ if (!rows.length) {
130
+ expect(owned, `${page} owns ${owned.join(', ')}; the page says nothing`).toEqual([])
131
+ expect(body).toBe(NOTHING)
132
+ return
133
+ }
134
+ const said = rows.flatMap((r) => Object.entries(ROW).filter(([, re]) => re.test(r.does)).map(([id]) => ({ id, ticked: r.ticked, does: r.does })))
135
+ expect(said.filter((s) => !s.ticked).map((s) => s.does), 'a checker behaviour, not ticked').toEqual([])
136
+ expect(rows.filter((r) => r.ticked && !Object.values(ROW).some((re) => re.test(r.does))).map((r) => r.does), 'ticked, but not worded as a checker behaviour').toEqual([])
137
+ expect(said.map((s) => s.id).sort()).toEqual([...owned].sort())
138
+ expect(rows.every((r) => r.does && r.never), 'every row says what it does and what you never write').toBe(true)
139
+ })
140
+ })
141
+ }
142
+ })
@@ -45,17 +45,17 @@ describe('the registry builds', () => {
45
45
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
46
46
  })
47
47
 
48
- it('counts 82 names over 54 files: 75 components, 6 helpers and 1 hook (ContainerHeader joined in UIG-13)', () => {
48
+ it('counts 84 names over 55 files: 77 components, 6 helpers and 1 hook (ListColumn and ToolbarLink joined in UIG-14)', () => {
49
49
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
50
50
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
51
- expect(kinds).toEqual({ component: 75, helper: 6, hook: 1 })
52
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(54)
51
+ expect(kinds).toEqual({ component: 77, helper: 6, hook: 1 })
52
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(55)
53
53
  })
54
54
 
55
55
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
56
56
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
57
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(54)
58
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(28)
57
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(55)
58
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(29)
59
59
  })
60
60
 
61
61
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {
@@ -278,7 +278,7 @@ describe('the ids point at something', () => {
278
278
  describe('a second parser agrees', async () => {
279
279
  const docgen = await import('react-docgen').catch(() => null)
280
280
 
281
- it.skipIf(!docgen)('finds the same props on all 75 components', async () => {
281
+ it.skipIf(!docgen)('finds the same props on all 77 components', async () => {
282
282
  if (!docgen) return
283
283
  const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
284
284
  const byName = new Map(registry.entries.map((one) => [one.name, one]))
@@ -321,12 +321,14 @@ describe('a second parser agrees', async () => {
321
321
  const INHERITED_WITH_A_DEFAULT = new Set(['Button.type', 'IconButton.type', 'TextInput.type', 'SearchInput.placeholder'])
322
322
 
323
323
  /**
324
- * The twelve where **this registry is right and `react-docgen` is not**.
324
+ * The eighteen where **this registry is right and `react-docgen` is not**.
325
325
  *
326
326
  * `IdentityPanelProps extends Omit<IdentityMenuProps, 'compact'>` and
327
327
  * `PersonTriggerProps extends Omit<PersonProps, 'className'>`: docgen does not
328
328
  * follow a heritage clause wrapped in `Omit`, so it reports neither `me` nor
329
- * `signedIn`, which `IdentityPanel` *requires*. Checked against the source —
329
+ * `signedIn`, which `IdentityPanel` *requires*. `ToolbarLinkProps extends
330
+ * Omit<IconButtonProps, …>` (UIG-14, F3) is the same: docgen reports none of
331
+ * the IconButton props it keeps. Checked against the source —
330
332
  * `IdentityMenuProps` declares both, and `IdentityPanel` spreads `...rest`
331
333
  * straight into `IdentityRows`, which destructures them.
332
334
  *
@@ -346,6 +348,12 @@ const DOCGEN_STOPS_AT_OMIT = new Set([
346
348
  'PersonTrigger.picture',
347
349
  'PersonTrigger.fallback',
348
350
  'PersonTrigger.size',
351
+ 'ToolbarLink.variant',
352
+ 'ToolbarLink.glow',
353
+ 'ToolbarLink.tooltip',
354
+ 'ToolbarLink.tooltipShortcut',
355
+ 'ToolbarLink.tooltipPlacement',
356
+ 'ToolbarLink.children',
349
357
  ])
350
358
 
351
359
  describe('ui:find', () => {
@@ -67,9 +67,30 @@ export default {
67
67
  // see tokens.css). Both exist so a treatment can be given to the two apps
68
68
  // without changing the plain light/dark themes the docs render in.
69
69
  plugins: [
70
- plugin(({ addVariant }) => {
70
+ plugin(({ addVariant, addComponents }) => {
71
71
  addVariant('signal', '.signal &')
72
72
  addVariant('ship', "[data-theme='ship'] &")
73
+ // The Signal canvas: the control-room dot grid on the app's dark ground,
74
+ // in the Signal theme only. AppShell's floating frame carries
75
+ // `signal-canvas`. Peek's, verbatim (UIG-14, Katerina, 19 September: the
76
+ // canvas is the theme's, so it moved with the frame). A component here,
77
+ // not base.css, so it reaches every app on the preset and the class lint
78
+ // knows the name. `isolation` and `z-index: -1` keep the grid above
79
+ // the ground and below every child: never over the card, a dialog, a menu.
80
+ addComponents({
81
+ '.signal .signal-canvas': { isolation: 'isolate' },
82
+ '.signal .signal-canvas::before': {
83
+ content: "''",
84
+ position: 'absolute',
85
+ inset: '0',
86
+ zIndex: '-1',
87
+ pointerEvents: 'none',
88
+ backgroundImage: 'radial-gradient(circle, rgba(255, 255, 255, 0.045) 1px, transparent 1px)',
89
+ backgroundSize: '26px 26px',
90
+ maskImage: 'radial-gradient(1100px 700px at 50% 45%, transparent 25%, #000 100%)',
91
+ WebkitMaskImage: 'radial-gradient(1100px 700px at 50% 45%, transparent 25%, #000 100%)',
92
+ },
93
+ })
73
94
  }),
74
95
  ],
75
96
  theme: {