@estiva-app/ui 0.35.2 → 0.37.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 (143) hide show
  1. package/dist/Panel.d.ts +44 -0
  2. package/dist/Panel.d.ts.map +1 -0
  3. package/dist/ScrollArea.d.ts +7 -2
  4. package/dist/ScrollArea.d.ts.map +1 -1
  5. package/dist/eslint/escape.d.ts +23 -6
  6. package/dist/eslint/escape.d.ts.map +1 -1
  7. package/dist/eslint/index.js +86 -12
  8. package/dist/eslint/index.js.map +3 -3
  9. package/dist/eslint/no-handmade-header.d.ts +5 -0
  10. package/dist/eslint/no-handmade-header.d.ts.map +1 -1
  11. package/dist/eslint/no-native-title.d.ts +1 -1
  12. package/dist/eslint/no-native-title.d.ts.map +1 -1
  13. package/dist/eslint/no-raw-element.d.ts +7 -0
  14. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  15. package/dist/eslint/no-restyled-part.d.ts +8 -0
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/gates/app-checks.d.ts +1 -1
  18. package/dist/gates/app-checks.d.ts.map +1 -1
  19. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  20. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  21. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  22. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  23. package/dist/gates/ci.d.ts +18 -0
  24. package/dist/gates/ci.d.ts.map +1 -0
  25. package/dist/gates/cli.js +20 -3
  26. package/dist/gates/cli.js.map +2 -2
  27. package/dist/gates/create-app.d.ts.map +1 -1
  28. package/dist/gates/create-app.js +1 -1
  29. package/dist/gates/escapes.d.ts +23 -0
  30. package/dist/gates/escapes.d.ts.map +1 -0
  31. package/dist/gates/gate-config.d.ts.map +1 -1
  32. package/dist/gates/hook.d.ts +2 -0
  33. package/dist/gates/hook.d.ts.map +1 -1
  34. package/dist/gates/index.d.ts +5 -1
  35. package/dist/gates/index.d.ts.map +1 -1
  36. package/dist/gates/index.js +42 -185
  37. package/dist/gates/index.js.map +4 -4
  38. package/dist/gates/name-escapes.d.ts +14 -0
  39. package/dist/gates/name-escapes.d.ts.map +1 -0
  40. package/dist/gates/status.d.ts +3 -1
  41. package/dist/gates/status.d.ts.map +1 -1
  42. package/dist/gates/token-lint.d.ts.map +1 -1
  43. package/dist/gates/token-style.d.ts +21 -0
  44. package/dist/gates/token-style.d.ts.map +1 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +76 -49
  48. package/dist/index.js.map +4 -4
  49. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  50. package/dist/registry/app.d.ts.map +1 -1
  51. package/dist/registry/behaviours.d.ts +71 -0
  52. package/dist/registry/behaviours.d.ts.map +1 -0
  53. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  54. package/dist/registry/build.d.ts.map +1 -1
  55. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  56. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  57. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  58. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  59. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  60. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  61. package/dist/registry/cli.js +7 -6
  62. package/dist/registry/cli.js.map +2 -2
  63. package/dist/registry/contract.d.ts +13 -1
  64. package/dist/registry/contract.d.ts.map +1 -1
  65. package/dist/registry/index.d.ts +1 -1
  66. package/dist/registry/index.d.ts.map +1 -1
  67. package/dist/registry/index.js +11 -5
  68. package/package.json +4 -1
  69. package/registry.json +369 -19
  70. package/skill/estiva-ui.md +2 -1
  71. package/src/AppShell.mdx +2 -1
  72. package/src/AttachmentCard.tsx +7 -7
  73. package/src/Button.tsx +1 -1
  74. package/src/Card.tsx +2 -2
  75. package/src/ChipInput.mdx +2 -0
  76. package/src/ChipInput.tsx +1 -1
  77. package/src/CommandPalette.tsx +3 -3
  78. package/src/DialogShell.tsx +1 -1
  79. package/src/EmptyState.mdx +4 -3
  80. package/src/Lightbox.tsx +1 -1
  81. package/src/NavItem.tsx +1 -1
  82. package/src/Panel.mdx +78 -0
  83. package/src/Panel.stories.tsx +98 -0
  84. package/src/Panel.tsx +80 -0
  85. package/src/PersonTrigger.tsx +1 -1
  86. package/src/Reaction.tsx +1 -1
  87. package/src/ScrollArea.mdx +6 -2
  88. package/src/ScrollArea.stories.tsx +1 -1
  89. package/src/ScrollArea.tsx +9 -4
  90. package/src/Select.tsx +1 -1
  91. package/src/TopBar.mdx +1 -1
  92. package/src/eslint/escape.ts +43 -14
  93. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  94. package/src/eslint/index.test.ts +2 -2
  95. package/src/eslint/no-copied-look.test.ts +1 -1
  96. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  97. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  98. package/src/eslint/no-handmade-header.test.ts +11 -2
  99. package/src/eslint/no-handmade-header.ts +24 -2
  100. package/src/eslint/no-native-title.test.ts +2 -2
  101. package/src/eslint/no-native-title.ts +1 -1
  102. package/src/eslint/no-raw-element.test.ts +47 -15
  103. package/src/eslint/no-raw-element.ts +53 -3
  104. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  105. package/src/eslint/no-restyled-part.test.ts +21 -2
  106. package/src/eslint/no-restyled-part.ts +30 -0
  107. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  108. package/src/gates/app-checks.ts +9 -6
  109. package/src/gates/ci.ts +152 -0
  110. package/src/gates/cli.ts +19 -3
  111. package/src/gates/count.ts +1 -1
  112. package/src/gates/create-app.test.ts +2 -2
  113. package/src/gates/create-app.ts +22 -16
  114. package/src/gates/escapes.ts +111 -0
  115. package/src/gates/gate-config.ts +2 -0
  116. package/src/gates/gates.test.ts +86 -1
  117. package/src/gates/hook.ts +38 -4
  118. package/src/gates/index.ts +5 -1
  119. package/src/gates/name-escapes.ts +77 -0
  120. package/src/gates/status.ts +45 -2
  121. package/src/gates/token-lint.ts +11 -4
  122. package/src/gates/token-style.ts +79 -0
  123. package/src/how.test.ts +282 -0
  124. package/src/index.ts +1 -0
  125. package/src/pages.test.ts +6 -0
  126. package/src/registry/app.test.ts +37 -1
  127. package/src/registry/app.ts +52 -2
  128. package/src/registry/behaviours.ts +223 -0
  129. package/src/registry/build.ts +36 -0
  130. package/src/registry/cli.ts +5 -3
  131. package/src/registry/contract.test.ts +27 -2
  132. package/src/registry/contract.ts +28 -3
  133. package/src/registry/index.ts +1 -1
  134. package/src/registry/registry.test.ts +18 -4
  135. package/stories/Choosing.mdx +4 -3
  136. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  137. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  138. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  139. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  140. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  141. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  142. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  143. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -89,7 +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
+ // @estiva-escape(no-copied-look): A6 (Katerina, 24 September: record only): a 12px medium name, the text style InputChip's label also uses, not a copy of a part
93
93
  const NAME_CLASSES = 'text-caption font-medium text-text-primary truncate'
94
94
  const NOTE_CLASSES = 'text-small tracking-wide leading-tight truncate'
95
95
 
@@ -359,7 +359,7 @@ export function AttachmentCard({
359
359
  const failed = state === 'failed'
360
360
  const warning = state === 'warning'
361
361
  return (
362
- // @estiva-escape: a failed or warning file's strong hairline is AttachmentCard's own state; Card's attention hairlines are the soft ones (UIG-9, Katerina 17 September)
362
+ // @estiva-escape(no-restyled-part): a failed or warning file's strong hairline is AttachmentCard's own state; Card's attention hairlines are the soft ones (UIG-9, Katerina 17 September)
363
363
  <Card
364
364
  fill="elevated"
365
365
  className={cn('group relative flex items-center gap-2 w-[200px] p-1.5 pr-3', failed ? 'border-error-default' : warning && 'border-warning-default', className)}
@@ -390,7 +390,7 @@ export function AttachmentCard({
390
390
  {/* On Base UI's Button, as InputChip's ✕ is (Katerina, 2026-09-14): IconButton is a 24px square
391
391
  that fills on hover, and this is Peek's 20px round badge on the card's corner. */}
392
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
393
+ // @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button Lightbox's close also draws; a part if a third appears
394
394
  <BaseButton
395
395
  type="button"
396
396
  aria-label={`Remove ${name}`}
@@ -411,7 +411,7 @@ export function AttachmentCard({
411
411
 
412
412
  // Fades in with the card's hover or keyboard focus, and keeps its slot when hidden, so revealing it never shifts the row.
413
413
  const downloadControl = saving ? (
414
- // @estiva-escape: Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
414
+ // @estiva-escape(no-restyled-part): Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
415
415
  <IconButton
416
416
  variant="muted"
417
417
  tooltip="Download"
@@ -430,7 +430,7 @@ export function AttachmentCard({
430
430
  return (
431
431
  // `role="status"`: a plain box may not carry a name (axe, aria-prohibited-attr), and a
432
432
  // status says what it is doing — loading — to a reader that cannot see the pulse.
433
- // @estiva-escape: the loading pulse is AttachmentCard's own state; Card has no loading state (UIG-9, Katerina 17 September)
433
+ // @estiva-escape(no-restyled-part): the loading pulse is AttachmentCard's own state; Card has no loading state (UIG-9, Katerina 17 September)
434
434
  <Card role="status" fill="inset" aria-busy="true" aria-label={`Loading ${name}`} className={cn('w-[180px] h-28 animate-pulse', className)} {...props}>
435
435
  {null}
436
436
  </Card>
@@ -439,7 +439,7 @@ export function AttachmentCard({
439
439
 
440
440
  if (drawnState === 'unreadable') {
441
441
  return (
442
- // @estiva-escape: a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
442
+ // @estiva-escape(no-restyled-part): a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
443
443
  <Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
444
444
  <div className={TILE_CLASSES}>
445
445
  <TypeIcon name={name} />
@@ -494,7 +494,7 @@ export function AttachmentCard({
494
494
  </>
495
495
  )
496
496
  return (
497
- // @estiva-escape: a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
497
+ // @estiva-escape(no-restyled-part): a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
498
498
  <Card fill="inset" hover={address ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !address && 'opacity-70', className)} {...props}>
499
499
  {address ? (
500
500
  <Link href={address} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
package/src/Button.tsx CHANGED
@@ -92,7 +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
+ // @estiva-escape(no-copied-look): W2 (Katerina, 24 September: leave alone): an outlined button shares four words with Reaction's pill by coincidence
96
96
  !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
97
97
  !state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
98
98
  !state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
package/src/Card.tsx CHANGED
@@ -127,14 +127,14 @@ export function Card({
127
127
  if (href !== undefined) {
128
128
  const { ref, ...anchorProps } = props as ComponentPropsWithRef<'a'>
129
129
  return (
130
- // @estiva-escape: a card that leads somewhere is drawn on Link's anchor (UIG-27 ruling 1), so this frame is Card's own look, not Link restyled
130
+ // @estiva-escape(no-copied-look, no-restyled-part): a card that leads somewhere is drawn on Link's anchor (UIG-27 ruling 1), so this frame is Card's own look, not Link restyled
131
131
  <Link href={href} variant="plain" ref={ref} className={classes} {...anchorProps}>
132
132
  {children}
133
133
  </Link>
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
+ // @estiva-escape(no-copied-look): 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
138
138
  <div className={classes} {...props}>
139
139
  {children}
140
140
  </div>
package/src/ChipInput.mdx CHANGED
@@ -32,6 +32,8 @@ leading slots.
32
32
  ```tsx
33
33
  import { ChipInput } from '@estiva-app/ui'
34
34
 
35
+ const directory: { id: string; label: string; picture?: string }[] = people
36
+
35
37
  <ChipInput
36
38
  value={chosen}
37
39
  onChange={setChosen}
package/src/ChipInput.tsx CHANGED
@@ -62,7 +62,7 @@ export function InputChip({ label, leading, onRemove, removeLabel, truncate, cla
62
62
  return (
63
63
  <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
64
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 */}
65
+ {/* @estiva-escape(no-copied-look): A6 (Katerina, 24 September: record only): a 12px medium label, the text style AttachmentCard's name also uses, not a copy of a part */}
66
66
  <span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
67
67
  {/* Base UI's `Button`, as every button in the package is (D6). Base UI
68
68
  has no chip of its own — its only chips are `Combobox.Chip` and
@@ -123,7 +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
+ {/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
127
127
  <Dialog.Popup
128
128
  ref={popupRef}
129
129
  aria-label={label}
@@ -374,7 +374,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
374
374
  <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
375
375
  <IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
376
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 */}
377
+ {/* @estiva-escape(no-copied-look): A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
378
378
  <Autocomplete.Input
379
379
  ref={inputRef}
380
380
  data-command-palette-field=""
@@ -555,7 +555,7 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
555
555
 
556
556
  return (
557
557
  <div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
558
- {/* @estiva-escape: the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
558
+ {/* @estiva-escape(no-handmade-header): the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
559
559
  <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
560
560
  {icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
561
561
  <LevelChip chip={chip} onBack={back} />
@@ -86,7 +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
+ {/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
90
90
  <Parts.Popup
91
91
  ref={popupRef}
92
92
  /*
@@ -36,9 +36,10 @@ need a height from the caller (`className="h-full"`).
36
36
 
37
37
  ## When not
38
38
 
39
- - Data is still on its way → **Skeleton**; empty and loading are different
40
- truths. When the shape of what arrives is unknown, an EmptyState that says so
41
- — "Opening…" — is fine.
39
+ - Data is still on its way → **Skeleton**: **SkeletonList**, or a shape of
40
+ your own from **SkeletonBar**; empty and loading are different truths.
41
+ When the shape of what arrives is unknown, an EmptyState that says so —
42
+ "Opening…" — is fine.
42
43
  - Something went wrong → say what failed, in words. An EmptyState can carry
43
44
  it, in its usual look — never red. An empty state that hides a failure
44
45
  misleads.
package/src/Lightbox.tsx CHANGED
@@ -74,7 +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
+ {/* @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
78
78
  <Dialog.Close
79
79
  aria-label="Close"
80
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"
package/src/NavItem.tsx CHANGED
@@ -28,7 +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
+ // @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
32
32
  <a
33
33
  href={href}
34
34
  aria-current={active ? 'page' : undefined}
package/src/Panel.mdx ADDED
@@ -0,0 +1,78 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PanelStories from './Panel.stories'
3
+
4
+ <Meta of={PanelStories} />
5
+
6
+ # Panel
7
+
8
+ A panel of a page: its name in a header, a body that scrolls whenever it is
9
+ taller than the room it has, and a row fixed at the bottom when there is one.
10
+
11
+ <Canvas of={PanelStories.ALongBody} />
12
+
13
+ ## When
14
+
15
+ - **Any panel of a page that is not a list**: a side column's details, a
16
+ thread, another app's card beside a file, the contents of what is open.
17
+ Its body scrolls by itself, so it is never cut off at the fold, even when
18
+ today's content fits.
19
+ - `footer` for a row that stays put under the body while it scrolls: a reply
20
+ box.
21
+ - Two panels in one column share it. Cap the first with a `max-h-*` in
22
+ `className`, and each scrolls in its own room.
23
+
24
+ <Canvas of={PanelStories.WithAFooter} />
25
+
26
+ <Canvas of={PanelStories.TwoInAColumn} />
27
+
28
+ ## When not
29
+
30
+ - A list of things to open beside the page → **ListColumn**, which is a panel
31
+ with the list's own width and insets.
32
+ - A window over the page → **DialogShell**.
33
+ - A box inside a panel or a card that should scroll on its own → **ScrollArea**.
34
+
35
+ ## How
36
+
37
+ ```tsx
38
+ import { Panel, IconButton } from '@estiva-app/ui'
39
+
40
+ <Panel
41
+ title="Details"
42
+ actions={
43
+ <IconButton tooltip="More" aria-label="More" onClick={openMenu}>
44
+ <IconDotsVertical size={16} stroke={1.5} />
45
+ </IconButton>
46
+ }
47
+ bodyClassName="flex flex-col gap-3 px-4 pt-3 pb-4"
48
+ >
49
+ {sections}
50
+ </Panel>
51
+ ```
52
+
53
+ - It takes the room its column gives it, never its content's height, so the
54
+ body is what scrolls. Put it straight into the column.
55
+ - `bodyClassName` is the body's padding, gap and layout. The header is
56
+ ContainerHeader's: 48px, a hairline under it.
57
+ - A panel that follows its scroll (a thread that opens at its newest reply)
58
+ reads it with `bodyRef` and `onBodyScroll`.
59
+ - **A crash stays inside it.** If its body, its actions or its footer break,
60
+ it keeps its title and shows "Something went wrong in the panel… try again"
61
+ in the room the body had. It is **ErrorBoundary**'s.
62
+
63
+ <Canvas of={PanelStories.WhenTheBodyBreaks} />
64
+
65
+ ## What it owns
66
+
67
+ It does these by itself. Write none of them around it.
68
+
69
+ | It does | So you never write | Refused when written by hand |
70
+ |---|---|---|
71
+ | Draws the header: a ContainerHeader with the title and the actions | a header row of your own over a box | ContainerHeader used on its own in an app |
72
+ | Scrolls its body in a ScrollArea whenever it is taller than the panel, with the bar showing | `overflow-auto`, or a box that cuts off what does not fit | |
73
+ | Keeps the footer fixed under the body | a flex column of your own around the body and the footer | |
74
+ | Keeps a crash inside itself: its title stays, the message and Try again sit in the room the body had | an error boundary of your own around it | |
75
+
76
+ ## Props
77
+
78
+ <Controls of={PanelStories.Default} />
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconDotsVertical } from '@tabler/icons-react'
3
+ import { IconButton } from './IconButton'
4
+ import { Panel } from './Panel'
5
+
6
+ /**
7
+ * A panel of a page: a header, a body that scrolls whenever it is taller than
8
+ * the panel, and a row fixed under it when there is one. The body's words are
9
+ * stand-ins.
10
+ */
11
+ const meta = {
12
+ title: 'Layout/Panel',
13
+ component: Panel,
14
+ parameters: { layout: 'fullscreen' },
15
+ args: { title: 'Details', children: null, bodyClassName: 'flex flex-col gap-3 px-4 pt-3 pb-4' },
16
+ argTypes: { children: { control: false }, actions: { control: false }, footer: { control: false } },
17
+ // A side column of the frame: 380px wide and a fixed height, where a panel sits beside a page.
18
+ decorators: [
19
+ (Story) => (
20
+ <div className="flex h-[560px] w-[380px] flex-col border-l border-border-subtle bg-bg-surface">
21
+ {Story()}
22
+ </div>
23
+ ),
24
+ ],
25
+ } satisfies Meta<typeof Panel>
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof meta>
29
+
30
+ const paragraph = (n: number) => (
31
+ <p key={n} className="text-body-2 text-text-secondary">
32
+ Paragraph {n}. What this item is for, who it is for, and what it leaves for later, written out in full the way a real description is.
33
+ </p>
34
+ )
35
+ const paragraphs = (count: number) => Array.from({ length: count }, (_, i) => paragraph(i + 1))
36
+ const menu = <IconButton tooltip="More" aria-label="More"><IconDotsVertical size={16} stroke={1.5} /></IconButton>
37
+
38
+ /** A short body: it fits, so there is no bar. */
39
+ export const Default: Story = {
40
+ args: { actions: menu },
41
+ render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
42
+ }
43
+
44
+ /** A title that opens something: the chevron after it, as ContainerHeader draws it. */
45
+ export const WithChevron: Story = {
46
+ args: { chevron: true },
47
+ render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
48
+ }
49
+
50
+ /** A body taller than the panel: it scrolls, and the bar shows at rest because there is more below. */
51
+ export const ALongBody: Story = {
52
+ args: { actions: menu },
53
+ render: (args) => <Panel {...args}>{paragraphs(14)}</Panel>,
54
+ }
55
+
56
+ /** A row fixed under the body, such as a reply box: the body scrolls above it, and it stays put. */
57
+ export const WithAFooter: Story = {
58
+ render: (args) => (
59
+ <Panel
60
+ {...args}
61
+ footer={
62
+ // A stand-in for whatever the footer holds: the part only keeps it in place.
63
+ <div className="border-t border-border-subtle p-3">
64
+ <div className="flex h-10 items-center justify-center rounded-md bg-bg-elevated text-caption text-text-muted">Footer</div>
65
+ </div>
66
+ }
67
+ >
68
+ {paragraphs(12)}
69
+ </Panel>
70
+ ),
71
+ }
72
+
73
+ /** Two panels in one column: the first capped at three fifths, each scrolls in its own room. */
74
+ export const TwoInAColumn: Story = {
75
+ render: (args) => (
76
+ <>
77
+ <Panel {...args} title="Details" className="max-h-[60%]">
78
+ {paragraphs(10)}
79
+ </Panel>
80
+ <Panel {...args} title="Activity">
81
+ {paragraphs(6)}
82
+ </Panel>
83
+ </>
84
+ ),
85
+ }
86
+
87
+ function Broken(): never {
88
+ throw new Error('The body could not draw itself')
89
+ }
90
+
91
+ /** A crash inside it stays inside it: the title stays, and the message takes the body's room. */
92
+ export const WhenTheBodyBreaks: Story = {
93
+ render: (args) => (
94
+ <Panel {...args}>
95
+ <Broken />
96
+ </Panel>
97
+ ),
98
+ }
package/src/Panel.tsx ADDED
@@ -0,0 +1,80 @@
1
+ import type { ReactNode, Ref, UIEventHandler } from 'react'
2
+ import { cn } from './cn'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { ErrorBoundary } from './ErrorBoundary'
5
+ import { ScrollArea } from './ScrollArea'
6
+
7
+ /**
8
+ * A panel of a page: its name in a `ContainerHeader`, a body under it that
9
+ * scrolls whenever it is taller than the room it has, and, when there is one,
10
+ * a row fixed at the bottom that never scrolls — a reply box.
11
+ *
12
+ * Made so that scrolling comes from the package, not from each app remembering
13
+ * it (Katerina, 26 September 2026). The apps built their panels from the
14
+ * package's header and a box of their own, and a box without a `ScrollArea`
15
+ * simply cut off what did not fit: Peek's widget panel on a Ship file lost the
16
+ * end of a long description and the status control under it, with no bar and
17
+ * nothing to scroll. Here the body is always a `ScrollArea`, so the bar shows
18
+ * as soon as there is more, and an empty panel or a short one has none.
19
+ *
20
+ * It takes the room its column gives it (`flex-1 min-h-0`), never its content's
21
+ * height, so the body is what scrolls, not the page. Two panels in one column
22
+ * share it; a `max-h-*` in `className` caps one of them.
23
+ *
24
+ * It keeps a crash inside itself, as ListColumn does: if its body, its actions
25
+ * or its footer break, the panel keeps its title and shows `ErrorBoundary`'s
26
+ * message in the room the body had.
27
+ */
28
+ export interface PanelProps {
29
+ /** The panel's name, in its header. A node for a title that is more than words. */
30
+ title: ReactNode
31
+ /** A chevron after the title, as `ContainerHeader` draws it. */
32
+ chevron?: boolean
33
+ /** The panel's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
34
+ actions?: ReactNode
35
+ /** The body. It scrolls when it is taller than the panel. */
36
+ children: ReactNode
37
+ /** A row fixed under the body that never scrolls — a reply box. */
38
+ footer?: ReactNode
39
+ /** The body's own box: its padding, gap and layout — `px-4 pt-3 pb-4`, `flex flex-col gap-2`. */
40
+ bodyClassName?: string
41
+ /** The body's scrolling box, for a panel that reads or drives its scroll: a thread that opens at its newest reply. */
42
+ bodyRef?: Ref<HTMLDivElement>
43
+ /** Fires as the body scrolls. */
44
+ onBodyScroll?: UIEventHandler<HTMLDivElement>
45
+ /** Placement only: a cap such as `max-h-[60%]` when it shares a column. */
46
+ className?: string
47
+ }
48
+
49
+ function Frame({ title, chevron = false, actions, children, footer, bodyClassName, bodyRef, onBodyScroll, className, broken = false }: PanelProps & { broken?: boolean }) {
50
+ return (
51
+ <div className={cn('flex min-h-0 flex-1 flex-col', className)}>
52
+ <ContainerHeader title={title} chevron={chevron} actions={actions} />
53
+ {broken ? (
54
+ // The message takes the room the body had and centres itself in it.
55
+ <div className="flex min-h-0 flex-1 flex-col px-4">{children}</div>
56
+ ) : (
57
+ <ScrollArea className="min-h-0 flex-1" contentClassName={bodyClassName} viewportRef={bodyRef} onScroll={onBodyScroll}>
58
+ {children}
59
+ </ScrollArea>
60
+ )}
61
+ {!broken && footer && <div className="shrink-0">{footer}</div>}
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export function Panel(props: PanelProps) {
67
+ return (
68
+ <ErrorBoundary
69
+ label="panel"
70
+ frame={(message) => (
71
+ // Only the name comes back with the message: the actions and the footer may be what threw.
72
+ <Frame title={props.title} chevron={props.chevron} className={props.className} broken>
73
+ {message}
74
+ </Frame>
75
+ )}
76
+ >
77
+ <Frame {...props} />
78
+ </ErrorBoundary>
79
+ )
80
+ }
@@ -60,7 +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
+ // @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
64
64
  <BaseButton
65
65
  type="button"
66
66
  aria-haspopup="menu"
package/src/Reaction.tsx CHANGED
@@ -63,7 +63,7 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
63
63
 
64
64
  export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
65
65
  return (
66
- // @estiva-escape: W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
66
+ // @estiva-escape(no-copied-look): W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
67
67
  <Toggle
68
68
  // Controlled: a reaction is yours when the data says so, not when the
69
69
  // pill was last pressed — a write can fail, and another client can
@@ -6,8 +6,8 @@ import * as ScrollAreaStories from './ScrollArea.stories'
6
6
  # ScrollArea
7
7
 
8
8
  A region that scrolls without taking width for its scrollbar. The bar is
9
- drawn over the content and shows while the pointer is over the region or
10
- the content is moving; nothing moves when it appears.
9
+ drawn over the content and shows whenever there is more to scroll to, so a
10
+ long list says so before anyone hovers it; nothing moves when it appears.
11
11
 
12
12
  <Canvas of={ScrollAreaStories.Default} />
13
13
 
@@ -27,6 +27,10 @@ the content is moving; nothing moves when it appears.
27
27
  - A box that never overflows. A ScrollArea around it changes nothing, and
28
28
  is one more element.
29
29
  - The page itself. The browser owns that bar.
30
+ - A whole list column → **ListColumn**, which scrolls its rows itself.
31
+
32
+ No alternative for the first two: a box that never overflows needs nothing
33
+ around it, and the page's bar is the browser's.
30
34
 
31
35
  ## How
32
36
 
@@ -15,7 +15,7 @@ type Story = StoryObj<typeof meta>
15
15
 
16
16
  const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
17
17
 
18
- /** A list taller than its box. The bar shows while the pointer is over it or the list is moving. */
18
+ /** A list taller than its box. The bar shows at rest, because there is more to scroll to. */
19
19
  export const Default: Story = {
20
20
  render: () => (
21
21
  <Card className="h-[240px] w-[280px]">
@@ -10,8 +10,13 @@ import { cn } from './cn'
10
10
  * right-hand padding looks wider than the left. Katerina saw it in every
11
11
  * scrolling surface of both apps (2026-09-08). Base UI's `ScrollArea` hides
12
12
  * the native bar and draws its own over the content, so nothing moves when
13
- * it appears; the bar shows while the pointer is over the region or the
14
- * content is moving, and fades otherwise.
13
+ * it appears.
14
+ *
15
+ * The bar shows whenever the region has more to scroll to, at rest too
16
+ * (Katerina, 26 September: a long folder list in Peek's side column looked
17
+ * complete, because the bar waited for the pointer). Base UI writes
18
+ * `data-has-overflow-y` / `-x` on the bar while that axis overflows; a region
19
+ * with nothing more to show keeps no bar at all.
15
20
  *
16
21
  * Three boxes, and each class prop lands on one:
17
22
  *
@@ -99,12 +104,12 @@ export function ScrollArea({ orientation = 'vertical', className, viewportClassN
99
104
  </BaseScrollArea.Content>
100
105
  </BaseScrollArea.Viewport>
101
106
  {vertical && (
102
- <BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5')}>
107
+ <BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5 data-[has-overflow-y]:opacity-100')}>
103
108
  <BaseScrollArea.Thumb className={cn(THUMB, 'w-full')} />
104
109
  </BaseScrollArea.Scrollbar>
105
110
  )}
106
111
  {horizontal && (
107
- <BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5')}>
112
+ <BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5 data-[has-overflow-x]:opacity-100')}>
108
113
  <BaseScrollArea.Thumb className={cn(THUMB, 'h-full')} />
109
114
  </BaseScrollArea.Scrollbar>
110
115
  )}
package/src/Select.tsx CHANGED
@@ -74,7 +74,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
74
74
  // a native `disabled` button, which Tab skips and a tooltip cannot open on.
75
75
  const held = Boolean(disabledReason)
76
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
77
+ // @estiva-escape(no-copied-look): N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
78
78
  <BaseSelect.Trigger
79
79
  aria-label={ariaLabel}
80
80
  aria-required={aria['aria-required']}
package/src/TopBar.mdx CHANGED
@@ -33,7 +33,7 @@ reachable.
33
33
  ## When not
34
34
 
35
35
  - Page-level headers inside the content → not this; this is the frame's
36
- bar, once per app.
36
+ bar, once per app. The top of a column is **ContainerHeader**.
37
37
  - Anything the bar would need to know — who you are, what is searched —
38
38
  stays outside: pass composed slots.
39
39
 
@@ -4,15 +4,21 @@ import type { AST, Rule } from 'eslint'
4
4
  * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
5
5
  * why one element stays as it is.
6
6
  *
7
- * A line comment directly above the element,
7
+ * A line comment directly above the element, naming the rule it keeps off,
8
8
  *
9
- * // @estiva-escape: <reason>
9
+ * // @estiva-escape(no-raw-element): <reason>
10
10
  *
11
11
  * or, where the element is a JSX child, the JSX comment on the line above,
12
12
  *
13
- * {/* @estiva-escape: <reason> *\/}
13
+ * {/* @estiva-escape(no-raw-element): <reason> *\/}
14
14
  *
15
- * with at least ten characters of reason, spaces not counted.
15
+ * with at least ten characters of reason, spaces not counted. It escapes only
16
+ * the rules it names (Katerina's ruling B3, 25 September: one reason used to
17
+ * switch off every rule on the element, so a raw button that also wrote a
18
+ * dialog's role by hand passed under one reason). Two rules on one element are
19
+ * named together, `@estiva-escape(no-raw-element, no-rebuilt-behaviour)`, each
20
+ * on purpose. A marker that names no rule escapes nothing, and says which name
21
+ * to write.
16
22
  *
17
23
  * Not `eslint-disable`. A directive switches a rule off without saying it was
18
24
  * meant, and the count of escapes (`.gates-count.json`) could not see it. A
@@ -37,11 +43,29 @@ export const SETTINGS_KEY = 'estiva'
37
43
  * reports through the rule that called it.
38
44
  */
39
45
  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.`,
46
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
47
+ escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}({{rule}}): <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
48
+ escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
42
49
  escaped: 'Escaped: {{reason}}',
43
50
  } as const
44
51
 
52
+ /** A rule's name as a marker writes it: `no-raw-element` for `estiva/no-raw-element`. */
53
+ export const shortRuleName = (id: string) => id.slice(id.lastIndexOf('/') + 1)
54
+
55
+ /**
56
+ * What a marker says, read from a comment's text: the rules it names (none when
57
+ * it names none) and its reason. Null when the comment carries no marker.
58
+ */
59
+ export function readMarker(text: string): { rules: string[]; reason: string } | null {
60
+ const at = text.indexOf(ESCAPE_MARKER)
61
+ if (at === -1) return null
62
+ const rest = text.slice(at + ESCAPE_MARKER.length)
63
+ const named = /^\s*\(([^)]*)\)/.exec(rest)
64
+ const rules = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : []
65
+ const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, '').trim()
66
+ return { rules, reason }
67
+ }
68
+
45
69
  export interface EstivaSettings {
46
70
  /**
47
71
  * Report every sanctioned escape as a message with the id `escaped`, so a
@@ -85,24 +109,29 @@ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
85
109
  .find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
86
110
  if (!comment?.loc) return false
87
111
 
88
- const at = comment.value.indexOf(ESCAPE_MARKER)
89
- if (at === -1) return false
112
+ const marker = readMarker(comment.value)
113
+ if (!marker) return false
114
+ const rule = shortRuleName(context.id)
90
115
 
91
116
  const directive = DIRECTIVE.exec(comment.value)
92
117
  if (directive) {
93
118
  const rules = directiveRules(directive[1])
94
119
  // A directive for other rules (the token lint's notes) is not an escape of this one.
95
120
  if (rules.length > 0 && !rules.includes(context.id)) return false
96
- context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
121
+ context.report({ loc: comment.loc, messageId: 'escapeInDirective', data: { rule } })
122
+ return false
123
+ }
124
+
125
+ // Named, but not this rule: another rule on the element may be the one it keeps off.
126
+ if (marker.rules.length > 0 && !marker.rules.includes(rule)) return false
127
+ if (marker.rules.length === 0) {
128
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutRule', data: { rule } })
97
129
  return false
98
130
  }
99
131
 
100
- const reason = comment.value
101
- .slice(at + ESCAPE_MARKER.length)
102
- .replace(/^:/, '')
103
- .trim()
132
+ const { reason } = marker
104
133
  if (reason.replace(/\s/g, '').length < MIN_REASON) {
105
- context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
134
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutReason', data: { rule } })
106
135
  return false
107
136
  }
108
137