@estiva-app/ui 0.33.0 → 0.35.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 (126) hide show
  1. package/README.md +8 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Card.d.ts.map +1 -1
  5. package/dist/Chip.d.ts.map +1 -1
  6. package/dist/ChipInput.d.ts.map +1 -1
  7. package/dist/CommandPalette.d.ts.map +1 -1
  8. package/dist/DialogShell.d.ts.map +1 -1
  9. package/dist/Lightbox.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/PersonTrigger.d.ts.map +1 -1
  13. package/dist/Reaction.d.ts.map +1 -1
  14. package/dist/SearchInput.d.ts.map +1 -1
  15. package/dist/SectionLabel.d.ts +9 -2
  16. package/dist/SectionLabel.d.ts.map +1 -1
  17. package/dist/Select.d.ts.map +1 -1
  18. package/dist/TextInput.d.ts.map +1 -1
  19. package/dist/Textarea.d.ts.map +1 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.d.ts +2 -0
  22. package/dist/eslint/index.d.ts.map +1 -1
  23. package/dist/eslint/index.js +392 -14
  24. package/dist/eslint/index.js.map +4 -4
  25. package/dist/eslint/looks-of.d.ts +54 -0
  26. package/dist/eslint/looks-of.d.ts.map +1 -0
  27. package/dist/eslint/no-copied-look.d.ts +3 -0
  28. package/dist/eslint/no-copied-look.d.ts.map +1 -0
  29. package/dist/gates/app-checks.d.ts +1 -1
  30. package/dist/gates/app-checks.d.ts.map +1 -1
  31. package/dist/gates/{chunk-JZE7TIQY.js → chunk-D466XXVL.js} +12 -6
  32. package/dist/gates/chunk-D466XXVL.js.map +7 -0
  33. package/dist/gates/{chunk-XAPFC37F.js → chunk-JWOU4UQZ.js} +43 -8
  34. package/dist/gates/chunk-JWOU4UQZ.js.map +7 -0
  35. package/dist/gates/cli.js +12 -4
  36. package/dist/gates/cli.js.map +2 -2
  37. package/dist/gates/create-app.d.ts.map +1 -1
  38. package/dist/gates/create-app.js +1 -1
  39. package/dist/gates/gate-config.d.ts +1 -1
  40. package/dist/gates/hook.d.ts +1 -1
  41. package/dist/gates/hook.d.ts.map +1 -1
  42. package/dist/gates/index.js +3 -3
  43. package/dist/gates/index.js.map +2 -2
  44. package/dist/gates/status.d.ts +9 -3
  45. package/dist/gates/status.d.ts.map +1 -1
  46. package/dist/index.js +171 -133
  47. package/dist/index.js.map +3 -3
  48. package/dist/looks.d.ts +34 -0
  49. package/dist/looks.d.ts.map +1 -0
  50. package/dist/registry/app-3ALZE6XO.js +10 -0
  51. package/dist/registry/app.d.ts.map +1 -1
  52. package/dist/registry/{build-LGFCCOLR.js → build-NDXMVFQ5.js} +3 -3
  53. package/dist/registry/build.d.ts.map +1 -1
  54. package/dist/registry/{chunk-XCBN2EKW.js → chunk-3WJ7ZZ7Z.js} +8 -4
  55. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +7 -0
  56. package/dist/registry/{chunk-E4JNV7PC.js → chunk-PZO4BVV3.js} +2 -1
  57. package/dist/registry/chunk-PZO4BVV3.js.map +7 -0
  58. package/dist/registry/chunk-XVII7P6G.js +595 -0
  59. package/dist/registry/chunk-XVII7P6G.js.map +7 -0
  60. package/dist/registry/cli.js +106 -18
  61. package/dist/registry/cli.js.map +4 -4
  62. package/dist/registry/index.js +3 -3
  63. package/dist/registry/schema.d.ts +8 -0
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/dist/registry/storymap.d.ts +12 -0
  66. package/dist/registry/storymap.d.ts.map +1 -0
  67. package/package.json +1 -1
  68. package/registry.json +347 -90
  69. package/src/AttachmentCard.tsx +2 -0
  70. package/src/Button.tsx +1 -0
  71. package/src/Card.stories.tsx +2 -2
  72. package/src/Card.tsx +1 -0
  73. package/src/Chip.tsx +2 -1
  74. package/src/ChipInput.tsx +4 -2
  75. package/src/CommandPalette.tsx +2 -0
  76. package/src/ContainerHeader.stories.tsx +2 -1
  77. package/src/DialogShell.tsx +1 -0
  78. package/src/Divider.stories.tsx +12 -10
  79. package/src/Lightbox.tsx +1 -0
  80. package/src/Link.stories.tsx +5 -4
  81. package/src/Menu.tsx +3 -1
  82. package/src/NavItem.tsx +1 -0
  83. package/src/PersonTrigger.tsx +1 -0
  84. package/src/Reaction.tsx +3 -1
  85. package/src/ReactionPicker.stories.tsx +3 -2
  86. package/src/ScrollArea.stories.tsx +13 -12
  87. package/src/SearchInput.tsx +5 -4
  88. package/src/SectionLabel.mdx +4 -2
  89. package/src/SectionLabel.stories.tsx +13 -0
  90. package/src/SectionLabel.tsx +19 -2
  91. package/src/Select.tsx +6 -4
  92. package/src/TextInput.tsx +6 -6
  93. package/src/Textarea.tsx +6 -4
  94. package/src/Tooltip.stories.tsx +3 -2
  95. package/src/Tooltip.tsx +2 -1
  96. package/src/eslint/index.test.ts +12 -8
  97. package/src/eslint/index.ts +13 -5
  98. package/src/eslint/looks-of.ts +302 -0
  99. package/src/eslint/no-copied-look.test.ts +155 -0
  100. package/src/eslint/no-copied-look.ts +203 -0
  101. package/src/eslint/no-restyled-part.ts +1 -1
  102. package/src/gates/app-checks.ts +2 -2
  103. package/src/gates/cli.ts +15 -4
  104. package/src/gates/create-app.test.ts +57 -2
  105. package/src/gates/create-app.ts +11 -5
  106. package/src/gates/gate-config.ts +1 -1
  107. package/src/gates/gates.test.ts +36 -3
  108. package/src/gates/hook.ts +1 -1
  109. package/src/gates/status.ts +62 -17
  110. package/src/looks.ts +42 -0
  111. package/src/registry/app.ts +3 -0
  112. package/src/registry/build.ts +8 -0
  113. package/src/registry/cli.ts +21 -3
  114. package/src/registry/registry.test.ts +1 -1
  115. package/src/registry/schema.ts +9 -0
  116. package/src/registry/storymap.test.ts +96 -0
  117. package/src/registry/storymap.ts +107 -0
  118. package/dist/gates/chunk-JZE7TIQY.js.map +0 -7
  119. package/dist/gates/chunk-XAPFC37F.js.map +0 -7
  120. package/dist/registry/app-FXN4EI34.js +0 -10
  121. package/dist/registry/chunk-E4JNV7PC.js.map +0 -7
  122. package/dist/registry/chunk-NJN4MQAM.js +0 -397
  123. package/dist/registry/chunk-NJN4MQAM.js.map +0 -7
  124. package/dist/registry/chunk-XCBN2EKW.js.map +0 -7
  125. /package/dist/registry/{app-FXN4EI34.js.map → app-3ALZE6XO.js.map} +0 -0
  126. /package/dist/registry/{build-LGFCCOLR.js.map → build-NDXMVFQ5.js.map} +0 -0
@@ -89,6 +89,7 @@ const formatBytes = (bytes: number) => {
89
89
  const typeLabelOf = (name: string) => extensionOf(name).toUpperCase() || 'FILE'
90
90
 
91
91
  const TILE_CLASSES = 'size-9 rounded-md bg-bg-active flex items-center justify-center shrink-0 text-text-secondary'
92
+ // @estiva-escape: A6 (Katerina, 24 September: record only): a 12px medium name, the text style InputChip's label also uses, not a copy of a part
92
93
  const NAME_CLASSES = 'text-caption font-medium text-text-primary truncate'
93
94
  const NOTE_CLASSES = 'text-small tracking-wide leading-tight truncate'
94
95
 
@@ -389,6 +390,7 @@ export function AttachmentCard({
389
390
  {/* On Base UI's Button, as InputChip's ✕ is (Katerina, 2026-09-14): IconButton is a 24px square
390
391
  that fills on hover, and this is Peek's 20px round badge on the card's corner. */}
391
392
  {onRemove && (
393
+ // @estiva-escape: N4 (Katerina, 24 September: record only): the small round button Lightbox's close also draws; a part if a third appears
392
394
  <BaseButton
393
395
  type="button"
394
396
  aria-label={`Remove ${name}`}
package/src/Button.tsx CHANGED
@@ -92,6 +92,7 @@ export function Button({
92
92
  size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
93
93
  size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
94
94
  !state.disabled && (variant === 'primary' || variant === 'resolve') && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
95
+ // @estiva-escape: W2 (Katerina, 24 September: leave alone): an outlined button shares four words with Reaction's pill by coincidence
95
96
  !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
96
97
  !state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
97
98
  !state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
@@ -30,10 +30,10 @@ export const Surface: Story = {}
30
30
  export const Elevated: Story = {
31
31
  args: { fill: 'elevated', className: 'p-2' },
32
32
  render: (args) => (
33
- <div className="flex w-[300px] flex-col gap-2 rounded-lg border border-border-default bg-bg-surface p-2">
33
+ <Card className="flex w-[300px] flex-col gap-2 p-2">
34
34
  <Card {...args} />
35
35
  <Card {...args} children={<Body title="Item two" />} />
36
- </div>
36
+ </Card>
37
37
  ),
38
38
  }
39
39
 
package/src/Card.tsx CHANGED
@@ -134,6 +134,7 @@ export function Card({
134
134
  )
135
135
  }
136
136
  return (
137
+ // @estiva-escape: N6 (Katerina, 24 September: leave alone): a card that cuts its content to its corners shares four words with the palette's window by coincidence
137
138
  <div className={classes} {...props}>
138
139
  {children}
139
140
  </div>
package/src/Chip.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { CHIP_TEXT_CLASSES } from './looks'
3
4
 
4
5
  /**
5
6
  * Peek's Chip (2026-08-28), verbatim: a 20px pill in one of six colour
@@ -44,7 +45,7 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
44
45
  // the screenshot diff caught every descender cut off. `clip` is the
45
46
  // one overflow that leaves the other axis visible. A chip with no cap
46
47
  // draws exactly as before.
47
- <span className="min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap text-chip signal:font-mono signal:text-small signal:font-semibold signal:tracking-wide signal:tabular-nums">{label}</span>
48
+ <span className={cn('min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide', CHIP_TEXT_CLASSES)}>{label}</span>
48
49
  )}
49
50
  {trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
50
51
  </div>
package/src/ChipInput.tsx CHANGED
@@ -5,6 +5,7 @@ import { IconX } from '@tabler/icons-react'
5
5
  import { cn } from './cn'
6
6
  import { MenuItem, MenuPanel } from './Menu'
7
7
  import { ScrollArea } from './ScrollArea'
8
+ import { BARE_INPUT_CLASSES, FIELD_SHELL_CLASSES } from './looks'
8
9
 
9
10
  /* The chip's own look, written once: `InputChip` draws it for a caller who
10
11
  wants a chip on its own, and `ChipInput` gives the same classes to Base UI's
@@ -61,6 +62,7 @@ export function InputChip({ label, leading, onRemove, removeLabel, truncate, cla
61
62
  return (
62
63
  <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
63
64
  {leading && <span className="flex shrink-0 items-center">{leading}</span>}
65
+ {/* @estiva-escape: A6 (Katerina, 24 September: record only): a 12px medium label, the text style AttachmentCard's name also uses, not a copy of a part */}
64
66
  <span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
65
67
  {/* Base UI's `Button`, as every button in the package is (D6). Base UI
66
68
  has no chip of its own — its only chips are `Combobox.Chip` and
@@ -244,7 +246,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
244
246
  >
245
247
  {/* The box the chips and the input share. `Combobox.Chips` is what makes
246
248
  the two one control for the keyboard; the look is what it always was. */}
247
- <Combobox.Chips ref={setBox} className="bg-bg-inset border border-border-default hover:border-border-strong focus-within:border-border-focus focus-within:hover:border-border-focus rounded-lg px-3 py-1.5 flex flex-wrap items-center gap-1.5 transition-colors min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring">
249
+ <Combobox.Chips ref={setBox} className={cn(FIELD_SHELL_CLASSES, 'focus-within:border-border-focus focus-within:hover:border-border-focus px-3 py-1.5 flex flex-wrap items-center gap-1.5 min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring')}>
248
250
  {value.map((option) => (
249
251
  <Combobox.Chip key={option.id} className={cn(CHIP_BOX, chipPadding(!!chipLeading, true))}>
250
252
  {chipLeading && <span className="flex shrink-0 items-center">{chipLeading(option)}</span>}
@@ -260,7 +262,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
260
262
  aria-required={aria['aria-required']}
261
263
  {...naming}
262
264
  onKeyDown={onInputKeyDown}
263
- className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
265
+ className={cn('flex-1 min-w-[120px] text-body-2 border-none', BARE_INPUT_CLASSES)}
264
266
  />
265
267
  </Combobox.Chips>
266
268
 
@@ -123,6 +123,7 @@ export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctr
123
123
  <Dialog.Portal>
124
124
  <Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim" />
125
125
  <Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center pt-[16vh]">
126
+ {/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
126
127
  <Dialog.Popup
127
128
  ref={popupRef}
128
129
  aria-label={label}
@@ -373,6 +374,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
373
374
  <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
374
375
  <IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
375
376
  {chip && <LevelChip chip={chip} onBack={back} />}
377
+ {/* @estiva-escape: A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
376
378
  <Autocomplete.Input
377
379
  ref={inputRef}
378
380
  data-command-palette-field=""
@@ -2,9 +2,10 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
3
3
  import { ContainerHeader } from './ContainerHeader'
4
4
  import { IconButton } from './IconButton'
5
+ import { Card } from './Card'
5
6
 
6
7
  // The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
7
- const inColumn: Decorator = (Story) => <div className="w-96 overflow-hidden rounded-lg border border-border-default bg-bg-surface">{Story()}</div>
8
+ const inColumn: Decorator = (Story) => <Card clip className="w-96">{Story()}</Card>
8
9
 
9
10
  const meta = {
10
11
  title: 'Navigation/ContainerHeader',
@@ -86,6 +86,7 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
86
86
 
87
87
  {/* Dialog */}
88
88
  <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
89
+ {/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
89
90
  <Parts.Popup
90
91
  ref={popupRef}
91
92
  /*
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { Divider } from './Divider'
3
+ import { MenuPanel } from './Menu'
4
+ import { Card } from './Card'
3
5
 
4
6
  /** A hairline in `border-subtle`, inset 12px each side — or standing up between two columns. */
5
7
  const meta = {
@@ -13,22 +15,22 @@ type Story = StoryObj<typeof meta>
13
15
  /** Between two blocks on the surface. */
14
16
  export const Default: Story = {
15
17
  render: () => (
16
- <div className="w-80 rounded-lg border border-border-default bg-bg-surface py-3">
18
+ <Card className="w-80 py-3">
17
19
  <p className="px-3 pb-2 text-body-2 text-text-primary">Above the divider</p>
18
20
  <Divider />
19
21
  <p className="px-3 pt-2 text-body-2 text-text-primary">Below the divider</p>
20
- </div>
22
+ </Card>
21
23
  ),
22
24
  }
23
25
 
24
26
  /** Inside a menu — on the elevated surface, full width (`mx-0`). */
25
27
  export const InAMenu: Story = {
26
28
  render: () => (
27
- <div className="w-72 rounded-lg border border-border-default bg-bg-elevated p-2 text-body-2 text-text-primary shadow-lg">
28
- <div className="px-2 py-1.5">First action</div>
29
+ <MenuPanel className="w-72">
30
+ <div className="px-2 py-1.5 text-body-2 text-text-primary">First action</div>
29
31
  <Divider className="mx-0 my-2" />
30
- <div className="px-2 py-1.5">Second action</div>
31
- </div>
32
+ <div className="px-2 py-1.5 text-body-2 text-text-primary">Second action</div>
33
+ </MenuPanel>
32
34
  ),
33
35
  }
34
36
 
@@ -49,21 +51,21 @@ export const WithALabel: Story = {
49
51
  // the label is muted caption text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
50
52
  parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
51
53
  render: () => (
52
- <div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
54
+ <Card className="w-96 py-3"><div className="text-body-2 text-text-primary">
53
55
  <p className="px-3 pb-2">Yesterday's last message</p>
54
56
  <Divider label="Today" />
55
57
  <p className="px-3 pt-2">Today's first</p>
56
- </div>
58
+ </div></Card>
57
59
  ),
58
60
  }
59
61
 
60
62
  /** Asking for attention: where "new since you last read this" begins. The warning colour, because the accent could not be read on Ship's background. */
61
63
  export const Warning: Story = {
62
64
  render: () => (
63
- <div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
65
+ <Card className="w-96 py-3"><div className="text-body-2 text-text-primary">
64
66
  <p className="px-3 pb-2">Yes — it re-reads the folder union and the archived one lands last.</p>
65
67
  <Divider label="New since you last read this" tone="warning" />
66
68
  <p className="px-3 pt-2">Same here, from Peek.</p>
67
- </div>
69
+ </div></Card>
68
70
  ),
69
71
  }
package/src/Lightbox.tsx CHANGED
@@ -74,6 +74,7 @@ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
74
74
  onClick={(e) => e.stopPropagation()}
75
75
  className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
76
76
  />
77
+ {/* @estiva-escape: N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
77
78
  <Dialog.Close
78
79
  aria-label="Close"
79
80
  className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { Link } from './Link'
3
+ import { Card } from './Card'
3
4
 
4
5
  const meta = {
5
6
  title: 'Navigation/Link',
@@ -52,9 +53,9 @@ export const Plain: Story = {
52
53
  args: { variant: 'plain', children: undefined },
53
54
  render: (args) => (
54
55
  <Link {...args} className="block w-[280px]">
55
- <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
56
+ <Card hover="hairline" className="p-3"><div className="text-body-2 text-text-primary">
56
57
  Item one
57
- </div>
58
+ </div></Card>
58
59
  </Link>
59
60
  ),
60
61
  }
@@ -92,9 +93,9 @@ export const AllVariants: Story = {
92
93
  <div className="flex flex-col gap-1">
93
94
  <span className="text-body-2 text-text-secondary">plain —</span>
94
95
  <Link href="#" variant="plain" className="block w-[280px]">
95
- <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
96
+ <Card hover="hairline" className="p-3"><div className="text-body-2 text-text-primary">
96
97
  Item one
97
- </div>
98
+ </div></Card>
98
99
  </Link>
99
100
  </div>
100
101
  </div>
package/src/Menu.tsx CHANGED
@@ -7,6 +7,7 @@ import { triggerDisabled } from './triggerDisabled'
7
7
  import { ScrollArea } from './ScrollArea'
8
8
  import { Kbd } from './Kbd'
9
9
  import { SectionLabel } from './SectionLabel'
10
+ import { FLOATING_SURFACE_CLASSES } from './looks'
10
11
 
11
12
  /**
12
13
  * THE menu shell (2026-09-01) — extracted once, for every menu in every app.
@@ -94,7 +95,8 @@ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
94
95
  return (
95
96
  <div
96
97
  className={cn(
97
- 'flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
98
+ 'flex flex-col p-2',
99
+ FLOATING_SURFACE_CLASSES,
98
100
  /*
99
101
  * A divider in a menu runs the width of the rows it separates.
100
102
  *
package/src/NavItem.tsx CHANGED
@@ -28,6 +28,7 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
28
28
 
29
29
  export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
30
30
  return (
31
+ // @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
31
32
  <a
32
33
  href={href}
33
34
  aria-current={active ? 'page' : undefined}
@@ -60,6 +60,7 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
60
60
  )
61
61
  }
62
62
  return (
63
+ // @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
63
64
  <BaseButton
64
65
  type="button"
65
66
  aria-haspopup="menu"
package/src/Reaction.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
2
  import { Toggle } from '@base-ui/react/toggle'
3
3
  import { cn } from './cn'
4
+ import { CHIP_TEXT_CLASSES } from './looks'
4
5
 
5
6
  /**
6
7
  * A reaction: an emoji, how many people chose it, and whether you are one of
@@ -62,6 +63,7 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
62
63
 
63
64
  export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
64
65
  return (
66
+ // @estiva-escape: W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
65
67
  <Toggle
66
68
  // Controlled: a reaction is yours when the data says so, not when the
67
69
  // pill was last pressed — a write can fail, and another client can
@@ -94,7 +96,7 @@ export function Reaction({ emoji, count, pressed = false, className, ...props }:
94
96
  The old note said it must NEVER be merged; that stopped being true
95
97
  when `cn()` was taught the ramp — `chip` is in it, and `cn.test.ts`
96
98
  pins that. */}
97
- <span className="text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums">
99
+ <span className={CHIP_TEXT_CLASSES}>
98
100
  {count}
99
101
  </span>
100
102
  </Toggle>
@@ -6,6 +6,7 @@ import { Reaction } from './Reaction'
6
6
  import { ReactionPicker, type ReactionOption } from './ReactionPicker'
7
7
  import { Toolbar, ToolbarButton } from './Toolbar'
8
8
  import { EmptyState } from './EmptyState'
9
+ import { Card } from './Card'
9
10
 
10
11
  /**
11
12
  * The reactions on offer, to choose one from. **A `Reaction` is the answer;
@@ -52,9 +53,9 @@ export const FromATrigger: Story = {
52
53
  const [chosen, setChosen] = useState<string | null>(null)
53
54
  return (
54
55
  <div className="flex w-[420px] flex-col items-end gap-3">
55
- <div className="w-full rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary">
56
+ <Card className="w-full p-3"><div className="text-body-2 text-text-primary">
56
57
  A card. Its actions sit at the corner, and the picker opens above them.
57
- </div>
58
+ </div></Card>
58
59
  <Toolbar aria-label="Card actions">
59
60
  <ToolbarButton aria-label="Reply" tooltip="Reply">
60
61
  <IconMessage2 size={16} stroke={1.5} />
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Card } from './Card'
2
3
  import { ScrollArea } from './ScrollArea'
3
4
 
4
5
  /** A region that scrolls without taking width for its scrollbar. */
@@ -17,7 +18,7 @@ const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
17
18
  /** A list taller than its box. The bar shows while the pointer is over it or the list is moving. */
18
19
  export const Default: Story = {
19
20
  render: () => (
20
- <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
21
+ <Card className="h-[240px] w-[280px]">
21
22
  <ScrollArea className="h-full" contentClassName="p-2">
22
23
  <ul className="flex flex-col gap-px">
23
24
  {rows.map((row) => (
@@ -27,14 +28,14 @@ export const Default: Story = {
27
28
  ))}
28
29
  </ul>
29
30
  </ScrollArea>
30
- </div>
31
+ </Card>
31
32
  ),
32
33
  }
33
34
 
34
35
  /** Rows that stick to the top as the list moves under them. The bar stays above them. */
35
36
  export const StickyHeadings: Story = {
36
37
  render: () => (
37
- <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
38
+ <Card className="h-[240px] w-[280px]">
38
39
  <ScrollArea className="h-full">
39
40
  {['Group one', 'Group two'].map((group) => (
40
41
  <div key={group} className="flex flex-col">
@@ -47,14 +48,14 @@ export const StickyHeadings: Story = {
47
48
  </div>
48
49
  ))}
49
50
  </ScrollArea>
50
- </div>
51
+ </Card>
51
52
  ),
52
53
  }
53
54
 
54
55
  /** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
55
56
  export const Fits: Story = {
56
57
  render: () => (
57
- <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
58
+ <Card className="h-[240px] w-[280px]">
58
59
  <ScrollArea className="h-full" contentClassName="p-2">
59
60
  <ul className="flex flex-col gap-px">
60
61
  {rows.slice(0, 4).map((row) => (
@@ -64,14 +65,14 @@ export const Fits: Story = {
64
65
  ))}
65
66
  </ul>
66
67
  </ScrollArea>
67
- </div>
68
+ </Card>
68
69
  ),
69
70
  }
70
71
 
71
72
  /** A row wider than its box — a table, a board. */
72
73
  export const Horizontal: Story = {
73
74
  render: () => (
74
- <div className="w-[280px] rounded-lg border border-border-default bg-bg-surface">
75
+ <Card className="w-[280px]">
75
76
  <ScrollArea orientation="horizontal" contentClassName="p-2">
76
77
  <div className="flex w-max gap-2">
77
78
  {rows.slice(0, 12).map((row) => (
@@ -81,14 +82,14 @@ export const Horizontal: Story = {
81
82
  ))}
82
83
  </div>
83
84
  </ScrollArea>
84
- </div>
85
+ </Card>
85
86
  ),
86
87
  }
87
88
 
88
89
  /** Both ways, with the corner where the two bars would meet. */
89
90
  export const Both: Story = {
90
91
  render: () => (
91
- <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
92
+ <Card className="h-[240px] w-[280px]">
92
93
  <ScrollArea orientation="both" className="h-full" contentClassName="p-2">
93
94
  <div className="flex w-max flex-col gap-px">
94
95
  {rows.map((row) => (
@@ -98,14 +99,14 @@ export const Both: Story = {
98
99
  ))}
99
100
  </div>
100
101
  </ScrollArea>
101
- </div>
102
+ </Card>
102
103
  ),
103
104
  }
104
105
 
105
106
  /** 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. */
106
107
  export const SidewaysInsideAPage: Story = {
107
108
  render: () => (
108
- <div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
109
+ <Card className="h-[240px] w-[280px]">
109
110
  <ScrollArea className="h-full" contentClassName="p-2">
110
111
  <div className="flex flex-col gap-px">
111
112
  {rows.slice(0, 3).map((row) => (
@@ -131,6 +132,6 @@ export const SidewaysInsideAPage: Story = {
131
132
  ))}
132
133
  </div>
133
134
  </ScrollArea>
134
- </div>
135
+ </Card>
135
136
  ),
136
137
  }
@@ -2,6 +2,7 @@ import { type InputHTMLAttributes } from 'react'
2
2
  import { Input } from '@base-ui/react/input'
3
3
  import { cn } from './cn'
4
4
  import { Kbd } from './Kbd'
5
+ import { BARE_INPUT_CLASSES, FIELD_SHELL_CLASSES } from './looks'
5
6
 
6
7
  /**
7
8
  * Peek's SearchInput (2026-09-01), verbatim: an inset field with a hairline
@@ -31,16 +32,16 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
31
32
  return (
32
33
  <div
33
34
  className={cn(
34
- 'flex gap-2 items-center px-3 py-2 rounded-lg',
35
- 'bg-bg-inset border border-border-default',
35
+ 'flex gap-2 items-center px-3 py-2',
36
+ FIELD_SHELL_CLASSES,
36
37
  // Hover strengthens the border as it does on every other field (16 September). Here that
37
38
  // is also the focus look, which was the stronger border before hover existed.
38
- 'hover:border-border-strong focus-within:border-border-strong transition-colors',
39
+ 'focus-within:border-border-strong',
39
40
  className,
40
41
  )}
41
42
  >
42
43
  <Input
43
- className="flex-1 min-w-0 bg-transparent text-input-value text-text-primary placeholder:text-text-muted outline-none"
44
+ className={cn('flex-1 min-w-0 text-input-value', BARE_INPUT_CLASSES)}
44
45
  placeholder={placeholder}
45
46
  {...props}
46
47
  />
@@ -38,11 +38,13 @@ import { SectionLabel } from '@estiva-app/ui'
38
38
 
39
39
  It is a bare span: the caller owns the row it sits in and the spacing
40
40
  around it. Read it `tone="secondary"` where it labels rather than leads — menu
41
- headings and the command palette's groups do. A colour
41
+ headings and the command palette's groups do. `tone="muted"` where it marks a
42
+ place in a list rather than heading it — a date between messages. `truncate`
43
+ cuts a long label short with an ellipsis where its row has no room. A colour
42
44
  passed in `className` is refused by the lint.
43
45
 
44
46
  - Import it — never copy its classes. A copied class list drifts the first time
45
- the label changes.
47
+ the label changes, and the lint warns on one (`estiva/no-copied-look`).
46
48
 
47
49
  ## What it owns
48
50
 
@@ -32,3 +32,16 @@ export const HeadingAList: Story = {
32
32
 
33
33
  /** `tone="secondary"`: a heading inside a menu or a list of results labels the rows, it is not one of them. */
34
34
  export const Secondary: Story = { args: { tone: 'secondary', children: 'Section' } }
35
+
36
+ /** `tone="muted"`: a label that marks a place in a list rather than heading it, a date between messages. */
37
+ export const Muted: Story = { args: { tone: 'muted', children: 'Yesterday' } }
38
+
39
+ /** `truncate`: a long label in a narrow row ends in an ellipsis instead of spilling. */
40
+ export const Truncated: Story = {
41
+ args: { truncate: true, children: 'Promoted to a topic whose title is far longer than its row' },
42
+ render: (args) => (
43
+ <div className="flex w-48">
44
+ <SectionLabel {...args} />
45
+ </div>
46
+ ),
47
+ }
@@ -13,17 +13,34 @@ import { cn } from './cn'
13
13
  * the rows, it is not one of them (Katerina, 2026-09-01). `MenuSection` and
14
14
  * `CommandPalette` passed it as a class before UIG-9 (17 September).
15
15
  *
16
+ * `tone="muted"` is a label that marks a place in a list rather than heading
17
+ * it: a date between messages (Peek's `DateDivider`, UIG-25). `truncate` cuts a
18
+ * long label short with an ellipsis where its row has no room, as a date line in
19
+ * a narrow panel must.
20
+ *
16
21
  * Deliberately NOT the same thing as Property's stacked field label (the
17
22
  * 9px `menu`-token one): they were merged for a day and unmerged by
18
23
  * Katerina's ruling (2026-09-01) — a section title and a field label are
19
24
  * different voices, at different sizes, on purpose.
20
25
  */
21
- export function SectionLabel({ children, tone = 'primary', className }: { children: ReactNode; tone?: 'primary' | 'secondary'; className?: string }) {
26
+ export function SectionLabel({
27
+ children,
28
+ tone = 'primary',
29
+ truncate = false,
30
+ className,
31
+ }: {
32
+ children: ReactNode
33
+ tone?: 'primary' | 'secondary' | 'muted'
34
+ /** Cut a long label short with an ellipsis instead of letting it wrap or spill. */
35
+ truncate?: boolean
36
+ className?: string
37
+ }) {
22
38
  return (
23
39
  <span
24
40
  className={cn(
25
41
  'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
26
- tone === 'secondary' ? 'text-text-secondary' : 'text-text-primary',
42
+ tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
43
+ truncate && 'min-w-0 truncate',
27
44
  className,
28
45
  )}
29
46
  >
package/src/Select.tsx CHANGED
@@ -5,6 +5,7 @@ import { cn } from './cn'
5
5
  import { ScrollArea } from './ScrollArea'
6
6
  import { MenuPanel, menuItemClassName } from './Menu'
7
7
  import { TooltipTrigger } from './Tooltip'
8
+ import { FIELD_DISABLED_CLASSES, FIELD_SIZE_CLASSES, FIELD_TEXT_CLASSES } from './looks'
8
9
 
9
10
  /**
10
11
  * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
@@ -73,6 +74,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
73
74
  // a native `disabled` button, which Tab skips and a tooltip cannot open on.
74
75
  const held = Boolean(disabledReason)
75
76
  const trigger = (
77
+ // @estiva-escape: N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
76
78
  <BaseSelect.Trigger
77
79
  aria-label={ariaLabel}
78
80
  aria-required={aria['aria-required']}
@@ -87,17 +89,17 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
87
89
  * the cap. Full-width callers are unaffected.
88
90
  */
89
91
  'flex w-full min-w-0 max-w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
90
- 'border-border-default text-text-primary outline-none transition-colors',
92
+ 'border-border-default outline-none transition-colors',
93
+ FIELD_TEXT_CLASSES,
91
94
  // The focused border survives a hover: hover alone strengthens the
92
95
  // hairline, but hover while focused must not grey the focus colour —
93
96
  // the stacked variant outranks plain hover by specificity.
94
97
  !held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
95
- 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
98
+ FIELD_DISABLED_CLASSES,
96
99
  held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
97
100
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
98
101
  'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
99
- size === 'default' && 'px-3 py-2 text-input-value',
100
- size === 'small' && 'h-6 min-h-6 px-2 text-caption',
102
+ FIELD_SIZE_CLASSES[size],
101
103
  className,
102
104
  )}
103
105
  >
package/src/TextInput.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { forwardRef, type InputHTMLAttributes } from 'react'
2
2
  import { Input } from '@base-ui/react/input'
3
3
  import { cn } from './cn'
4
+ import { FIELD_BOX_CLASSES, FIELD_DISABLED_CLASSES, FIELD_FOCUS_RING_CLASSES, FIELD_SIZE_CLASSES, FIELD_TEXT_CLASSES } from './looks'
4
5
 
5
6
  /**
6
7
  * Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
@@ -35,14 +36,13 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
35
36
  // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
36
37
  // "aren't there hover states in text input and text area?"). Focus comes after hover in
37
38
  // Tailwind's order, so a focused field keeps the focus border under the pointer.
38
- 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg',
39
+ FIELD_BOX_CLASSES,
39
40
  // The small size is the small Select's trigger, class for class.
40
- size === 'default' && 'px-3 py-2 text-input-value',
41
- size === 'small' && 'h-6 min-h-6 px-2 text-caption',
42
- 'text-text-primary placeholder:text-text-muted',
41
+ FIELD_SIZE_CLASSES[size],
42
+ FIELD_TEXT_CLASSES,
43
43
  'outline-none transition-colors',
44
- 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
45
- 'signal:transition-shadow signal:focus:shadow-focus-ring',
44
+ FIELD_DISABLED_CLASSES,
45
+ FIELD_FOCUS_RING_CLASSES,
46
46
  className,
47
47
  )}
48
48
  {...props}
package/src/Textarea.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { forwardRef, type TextareaHTMLAttributes } from 'react'
2
2
  import { Field as BaseField } from '@base-ui/react/field'
3
3
  import { cn } from './cn'
4
+ import { FIELD_BOX_CLASSES, FIELD_DISABLED_CLASSES, FIELD_FOCUS_RING_CLASSES, FIELD_SIZE_CLASSES, FIELD_TEXT_CLASSES } from './looks'
4
5
 
5
6
  /**
6
7
  * Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
@@ -24,11 +25,12 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
24
25
  // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
25
26
  // "aren't there hover states in text input and text area?"). Focus comes after hover in
26
27
  // Tailwind's order, so a focused field keeps the focus border under the pointer.
27
- 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg px-3 py-2',
28
- 'text-input-value text-text-primary placeholder:text-text-muted',
28
+ FIELD_BOX_CLASSES,
29
+ FIELD_SIZE_CLASSES.default,
30
+ FIELD_TEXT_CLASSES,
29
31
  'resize-none outline-none transition-colors',
30
- 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
31
- 'signal:transition-shadow signal:focus:shadow-focus-ring',
32
+ FIELD_DISABLED_CLASSES,
33
+ FIELD_FOCUS_RING_CLASSES,
32
34
  className,
33
35
  )}
34
36
  {...(props as object)}