@estiva-app/ui 0.37.2 → 0.39.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 (50) hide show
  1. package/dist/AppShell.d.ts +5 -2
  2. package/dist/AppShell.d.ts.map +1 -1
  3. package/dist/AttachmentCard.d.ts.map +1 -1
  4. package/dist/Kbd.d.ts +7 -1
  5. package/dist/Kbd.d.ts.map +1 -1
  6. package/dist/Lightbox.d.ts.map +1 -1
  7. package/dist/SectionLabel.d.ts.map +1 -1
  8. package/dist/Sidebar.d.ts +7 -1
  9. package/dist/Sidebar.d.ts.map +1 -1
  10. package/dist/Toast.d.ts.map +1 -1
  11. package/dist/TopBar.d.ts +5 -2
  12. package/dist/TopBar.d.ts.map +1 -1
  13. package/dist/cn.d.ts.map +1 -1
  14. package/dist/eslint/index.js +1 -0
  15. package/dist/eslint/index.js.map +2 -2
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/index.js +486 -477
  18. package/dist/index.js.map +4 -4
  19. package/dist/looks.d.ts +2 -2
  20. package/dist/looks.d.ts.map +1 -1
  21. package/package.json +2 -1
  22. package/registry.json +62 -43
  23. package/src/AppShell.mdx +11 -3
  24. package/src/AppShell.stories.tsx +23 -12
  25. package/src/AppShell.test.tsx +20 -0
  26. package/src/AppShell.tsx +43 -24
  27. package/src/AttachmentCard.tsx +0 -1
  28. package/src/CommandPalette.tsx +1 -1
  29. package/src/ErrorBoundary.mdx +2 -2
  30. package/src/Kbd.tsx +10 -1
  31. package/src/Lightbox.tsx +4 -2
  32. package/src/Menu.tsx +1 -1
  33. package/src/MenuItem.stories.tsx +4 -9
  34. package/src/Popover.stories.tsx +3 -0
  35. package/src/SectionLabel.tsx +2 -0
  36. package/src/Select.tsx +2 -2
  37. package/src/Sidebar.tsx +10 -2
  38. package/src/TextInput.test.tsx +1 -1
  39. package/src/Toast.tsx +7 -6
  40. package/src/Tooltip.stories.tsx +9 -9
  41. package/src/Tooltip.test.tsx +1 -1
  42. package/src/Tooltip.tsx +3 -3
  43. package/src/TopBar.mdx +6 -4
  44. package/src/TopBar.tsx +6 -3
  45. package/src/cn.ts +1 -1
  46. package/src/eslint/no-restyled-part.ts +1 -0
  47. package/src/looks.ts +2 -2
  48. package/stories/TokensPage.tsx +17 -3
  49. package/tailwind-preset.js +10 -0
  50. package/tokens.css +144 -0
@@ -48,15 +48,10 @@ export const WithAShortcut: Story = {
48
48
  args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
49
49
  }
50
50
 
51
- /** The row opens another menu. */
52
- export const Submenu: Story = {
53
- args: { label: 'Move to…', submenu: true },
54
- }
55
-
56
51
  /**
57
- * The submenu, working. The story above draws the row at rest — the chevron is
58
- * the whole affordance — but a row that opens another menu can only be tried
59
- * inside a real one, so here is one.
52
+ * A row that opens another menu, working: such a row can only be tried inside
53
+ * a real menu, so this is one (Katerina, 28 September: one story, the working
54
+ * one; the chevron is the row's whole affordance).
60
55
  *
61
56
  * Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
62
57
  * row, flips to the other side at a screen edge, and stays open while you
@@ -66,7 +61,7 @@ export const Submenu: Story = {
66
61
  * into it, and ← closes it and puts the highlight back on the row. Measured in
67
62
  * that order — → then ← alone does nothing, because focus has not entered yet.
68
63
  */
69
- export const SubmenuLive: Story = {
64
+ export const Submenu: Story = {
70
65
  parameters: { controls: { disable: true } },
71
66
  decorators: [(Story) => <Story />],
72
67
  render: () => (
@@ -190,6 +190,9 @@ export const FromASelection: Story = {
190
190
  side="top"
191
191
  ariaLabel="Formatting"
192
192
  className="w-auto min-w-0 p-1"
193
+ /* No padding of the popup's own: the panel's 4px is the Toolbar's, and
194
+ the default 8px on top of it drew the panel 16px taller (Katerina, 28 September). */
195
+ contentClassName="p-0"
193
196
  >
194
197
  <Toolbar aria-label="Formatting" surface={false}>
195
198
  <ToolbarButton aria-label="Bold" tooltip="Bold">
@@ -39,6 +39,8 @@ export function SectionLabel({
39
39
  <span
40
40
  className={cn(
41
41
  'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
42
+ // Leaf's section labels are Signal's type (Katerina, 28 September).
43
+ 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
42
44
  tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
43
45
  truncate && 'min-w-0 truncate',
44
46
  className,
package/src/Select.tsx CHANGED
@@ -88,8 +88,8 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
88
88
  * `truncate` from ever engaging; in a block container max-w-full is
89
89
  * the cap. Full-width callers are unaffected.
90
90
  */
91
- 'flex w-full min-w-0 max-w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
92
- 'border-border-default outline-none transition-colors',
91
+ 'flex w-full min-w-0 max-w-full items-center justify-between gap-2 rounded-lg border bg-bg-field shadow-field text-left',
92
+ 'border-border-field outline-none transition-colors',
93
93
  FIELD_TEXT_CLASSES,
94
94
  // The focused border survives a hover: hover alone strengthens the
95
95
  // hairline, but hover while focused must not grey the focus colour —
package/src/Sidebar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { ScrollArea } from './ScrollArea'
4
4
 
@@ -10,6 +10,10 @@ import { ScrollArea } from './ScrollArea'
10
10
  * What goes inside is the caller's: NavItem rows, a SectionLabel heading
11
11
  * in a 32px row over a group. Desktop only — there is no narrow-screen
12
12
  * drawer.
13
+ *
14
+ * In AppShell's `inset` frame it stands on the ground instead: no hairline,
15
+ * no surface, the content's card beside it (Katerina, 28 September). The
16
+ * frame tells it so; the caller passes nothing.
13
17
  */
14
18
  export interface SidebarProps {
15
19
  /** Names the navigation region for assistive tech. */
@@ -18,11 +22,15 @@ export interface SidebarProps {
18
22
  className?: string
19
23
  }
20
24
 
25
+ /** Set by AppShell's `inset` frame: the sidebar stands on the ground, with no line or surface of its own. */
26
+ export const SidebarOnGround = createContext(false)
27
+
21
28
  export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, className }: SidebarProps) {
29
+ const onGround = useContext(SidebarOnGround)
22
30
  return (
23
31
  <nav
24
32
  aria-label={ariaLabel}
25
- className={cn('flex w-60 shrink-0 flex-col border-r border-border-default bg-bg-surface', className)}
33
+ className={cn('flex w-60 shrink-0 flex-col', !onGround && 'border-r border-border-default bg-bg-surface', className)}
26
34
  >
27
35
  {/* The rows scroll in a ScrollArea: the bar takes no width, so the
28
36
  column's padding reads the same with a long list as with a short
@@ -40,6 +40,6 @@ describe('TextInput', () => {
40
40
  render(<TextInput aria-label="Title" />)
41
41
  const classes = screen.getByRole('textbox').className.split(' ')
42
42
  // Both survive cn(); Tailwind emits focus after hover, so the order in CSS decides, not here.
43
- for (const c of ['border-border-default', 'hover:border-border-strong', 'focus:border-border-focus']) expect(classes).toContain(c)
43
+ for (const c of ['border-border-field', 'hover:border-border-strong', 'focus:border-border-focus']) expect(classes).toContain(c)
44
44
  })
45
45
  })
package/src/Toast.tsx CHANGED
@@ -44,13 +44,14 @@ export interface ToastProps {
44
44
  }
45
45
 
46
46
  // Signal: every toast is the same dark overlay pill (v3) — the type lives in
47
- // the icon color + glow, not the surface.
47
+ // the icon color + glow, not the surface. Leaf draws them the same way, on its
48
+ // own greys (Katerina, 28 September).
48
49
  const SURFACE_STYLES: Record<ToastType, string> = {
49
- success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
50
- brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
51
- neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-md',
52
- warning: 'bg-warning-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
53
- error: 'bg-error-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
50
+ success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md leaf:bg-bg-inset leaf:border leaf:border-border-default leaf:shadow-md',
51
+ brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md leaf:bg-bg-inset leaf:border leaf:border-border-default leaf:shadow-md',
52
+ neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-md leaf:bg-bg-inset leaf:border leaf:border-border-default leaf:shadow-md',
53
+ warning: 'bg-warning-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md leaf:bg-bg-inset leaf:border leaf:border-border-default leaf:shadow-md',
54
+ error: 'bg-error-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md leaf:bg-bg-inset leaf:border leaf:border-border-default leaf:shadow-md',
54
55
  }
55
56
 
56
57
  /**
@@ -3,7 +3,7 @@ import { IconArchive, IconPin, IconTrash } from '@tabler/icons-react'
3
3
  import { Button } from './Button'
4
4
  import { IconButton } from './IconButton'
5
5
  import { Tooltip, WithTooltip } from './Tooltip'
6
- import { Card } from './Card'
6
+ import { Toolbar, ToolbarButton } from './Toolbar'
7
7
 
8
8
  const meta = {
9
9
  title: 'Primitives/Tooltip',
@@ -100,16 +100,16 @@ export const OnADisabledControl: Story = {
100
100
  export const InAToolbar: Story = {
101
101
  parameters: { controls: { disable: true } },
102
102
  render: () => (
103
- <Card fill="elevated" className="flex items-center gap-1 p-1">
104
- <IconButton aria-label="Pin" tooltip="Pin">
103
+ <Toolbar aria-label="Actions">
104
+ <ToolbarButton aria-label="Pin" tooltip="Pin">
105
105
  <IconPin size={16} stroke={1.5} />
106
- </IconButton>
107
- <IconButton aria-label="Archive" tooltip="Archive">
106
+ </ToolbarButton>
107
+ <ToolbarButton aria-label="Archive" tooltip="Archive">
108
108
  <IconArchive size={16} stroke={1.5} />
109
- </IconButton>
110
- <IconButton aria-label="Delete" tooltip="Delete" tooltipShortcut="Del">
109
+ </ToolbarButton>
110
+ <ToolbarButton aria-label="Delete" tooltip="Delete" tooltipShortcut="Del">
111
111
  <IconTrash size={16} stroke={1.5} />
112
- </IconButton>
113
- </Card>
112
+ </ToolbarButton>
113
+ </Toolbar>
114
114
  ),
115
115
  }
@@ -39,7 +39,7 @@ describe('Tooltip, the surface', () => {
39
39
  render(<Tooltip label="Bold" className="w-40" />)
40
40
  const tip = screen.getByRole('tooltip')
41
41
  expect(tip.className).toContain('w-40')
42
- expect(tip.className).toContain('bg-bg-elevated')
42
+ expect(tip.className).toContain('bg-bg-tooltip')
43
43
  })
44
44
  })
45
45
 
package/src/Tooltip.tsx CHANGED
@@ -73,9 +73,9 @@ export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
73
73
  */
74
74
  export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
75
75
  return (
76
- <div role="tooltip" className={cn(FLOATING_SURFACE_CLASSES, 'min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1', className)} {...props}>
77
- <span className="min-w-0 text-caption text-text-primary break-words">{label}</span>
78
- {shortcut && <Kbd>{shortcut}</Kbd>}
76
+ <div role="tooltip" className={cn(FLOATING_SURFACE_CLASSES, 'border-border-tooltip bg-bg-tooltip', 'min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1', className)} {...props}>
77
+ <span className="min-w-0 text-caption text-text-tooltip break-words">{label}</span>
78
+ {shortcut && <Kbd tone="tooltip">{shortcut}</Kbd>}
79
79
  </div>
80
80
  )
81
81
  }
package/src/TopBar.mdx CHANGED
@@ -6,10 +6,10 @@ import * as TopBarStories from './TopBar.stories'
6
6
  # TopBar
7
7
 
8
8
  The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
9
- centre, the identity cluster on the right. Two manners — **`solid`**, in
10
- flow with a hairline and a surface, and **`floating`**, an overlay with
11
- neither, whose bar ignores the pointer so the content underneath stays
12
- reachable.
9
+ centre, the identity cluster on the right. Three manners — **`solid`**,
10
+ in flow with a hairline and a surface; **`inset`**, in flow with neither,
11
+ standing on the ground; and **`floating`**, an overlay with neither, whose
12
+ bar ignores the pointer so the content underneath stays reachable.
13
13
 
14
14
  <Canvas of={TopBarStories.Solid} />
15
15
 
@@ -19,6 +19,8 @@ reachable.
19
19
 
20
20
  - **`solid`** — the structured frame's first row, above the sidebar
21
21
  (AppShell's `solid` variant wires exactly this).
22
+ - **`inset`** — the first row of AppShell's `inset` frame, on the ground
23
+ above a sidebar that is on the ground too.
22
24
  - **`floating`** — an app whose content runs to the top edge; the bar
23
25
  floats over it and only its clusters catch clicks (AppShell's
24
26
  `floating` variant).
package/src/TopBar.tsx CHANGED
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
 
4
4
  /**
5
- * The 52px top bar, in two manners:
5
+ * The 52px top bar, in three manners:
6
6
  *
7
7
  * - `solid` — in flow: a hairline under it, the surface behind it, the
8
8
  * frame's first row (2026-09-02, verbatim from the structured app; its
@@ -11,6 +11,9 @@ import { cn } from './cn'
11
11
  * background, and the bar itself ignores the pointer — only its clusters
12
12
  * catch clicks, so the content underneath stays reachable (2026-09-02,
13
13
  * verbatim from the floating app).
14
+ * - `inset` — in flow like `solid`, but standing on the ground: no hairline
15
+ * and no surface, above a sidebar on the ground and the content's card
16
+ * (Katerina, 28 September).
14
17
  *
15
18
  * Geometry both apps agreed on before extraction: 52px tall, `pl-5
16
19
  * pr-[26px]`, right cluster `gap-[6px]`. The left region is two slots
@@ -20,7 +23,7 @@ import { cn } from './cn'
20
23
  * searched; it only knows where those go.
21
24
  */
22
25
  export interface TopBarProps {
23
- variant?: 'solid' | 'floating'
26
+ variant?: 'solid' | 'floating' | 'inset'
24
27
  /** Far left: the menu button, in an app whose navigation collapses. Stays the caller's — the bar only places it. */
25
28
  menu?: ReactNode
26
29
  /** Beside the menu button: the app's logo mark, or its name as text. */
@@ -40,7 +43,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
40
43
  'flex h-[52px] items-center pl-5 pr-[26px]',
41
44
  floating
42
45
  ? 'pointer-events-none absolute left-0 right-0 top-0 z-10'
43
- : 'shrink-0 gap-4 border-b border-border-default bg-bg-surface',
46
+ : cn('shrink-0 gap-4', variant === 'solid' && 'border-b border-border-default bg-bg-surface'),
44
47
  className,
45
48
  )}
46
49
  >
package/src/cn.ts CHANGED
@@ -29,7 +29,7 @@ const FONT_SIZE_TOKENS = [
29
29
  * `shadow-glow-warning` beside `shadow-md` kept both (measured 2026-09-06).
30
30
  * `cn.test.ts` pins these to the preset too.
31
31
  */
32
- const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'glow-success', 'glow-accent', 'highlight-inset']
32
+ const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'field', 'glow-warning', 'glow-success', 'glow-accent', 'highlight-inset']
33
33
  const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent', 'glow-warning']
34
34
 
35
35
  const twMerge = extendTailwindMerge({
@@ -118,6 +118,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
118
118
  FieldLine: ['tone'],
119
119
  IconButton: ['variant', 'pressed', 'glow'],
120
120
  InputChip: ['truncate'],
121
+ Kbd: ['tone'],
121
122
  Link: ['variant', 'truncate'],
122
123
  MenuItem: ['size', 'selected', 'destructive'],
123
124
  MenuSub: ['selected'],
package/src/looks.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  /** A field's box: TextInput and Textarea. The hairline strengthens on hover; focus comes after it, so a focused field keeps its colour under the pointer. */
15
- export const FIELD_BOX_CLASSES = 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg'
15
+ export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg'
16
16
 
17
17
  /** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
18
18
  export const FIELD_TEXT_CLASSES = 'text-text-primary placeholder:text-text-muted'
@@ -30,7 +30,7 @@ export const FIELD_SIZE_CLASSES = {
30
30
  } as const
31
31
 
32
32
  /** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
33
- export const FIELD_SHELL_CLASSES = 'bg-bg-inset border border-border-default hover:border-border-strong rounded-lg transition-colors'
33
+ export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors'
34
34
 
35
35
  /** A panel that floats over the page: Menu's panel and Tooltip. */
36
36
  export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
@@ -55,14 +55,14 @@ function resolveVar(name: string): string {
55
55
 
56
56
  /* ── The vocabulary (mirrors tailwind-preset.js; tokens.test.ts holds the two together) ── */
57
57
 
58
- type Swatch = 'fill' | 'text' | 'text-inverse' | 'border' | 'outline' | 'shadow' | 'drop-shadow'
58
+ type Swatch = 'fill' | 'text' | 'text-inverse' | 'text-tooltip' | 'border' | 'outline' | 'shadow' | 'drop-shadow'
59
59
  type Token = { key: string; cssVar: string; cls: string; swatch: Swatch; note?: string }
60
60
  type Family = { label: string; blurb: string; utilities: string; tokens: Token[] }
61
61
 
62
62
  const colour = (prefix: string, key: string, swatch: Swatch, note?: string): Token => ({
63
63
  key: `${prefix}-${key}`,
64
64
  cssVar: `--${prefix}-${key}`,
65
- cls: `${swatch === 'text' || swatch === 'text-inverse' ? 'text' : swatch === 'border' || swatch === 'outline' ? 'border' : 'bg'}-${prefix}-${key}`,
65
+ cls: `${swatch === 'text' || swatch === 'text-inverse' || swatch === 'text-tooltip' ? 'text' : swatch === 'border' || swatch === 'outline' ? 'border' : 'bg'}-${prefix}-${key}`,
66
66
  swatch,
67
67
  note,
68
68
  })
@@ -86,6 +86,18 @@ const FAMILIES: Family[] = [
86
86
  utilities: COLOUR_UTILITIES,
87
87
  tokens: ['subtle', 'default', 'strong', 'focus'].map((k) => colour('border', k, 'border')),
88
88
  },
89
+ {
90
+ label: 'Tooltip',
91
+ blurb: "A tooltip's own colours, so a theme can draw it apart from its menus and popups: its ground, its text and its edge, then the same three for a shortcut key shown on it.",
92
+ utilities: COLOUR_UTILITIES,
93
+ tokens: [colour('bg', 'tooltip', 'fill'), colour('text', 'tooltip', 'text-tooltip'), colour('border', 'tooltip', 'border'), colour('bg', 'tooltip-key', 'fill'), colour('text', 'tooltip-key', 'text-tooltip'), colour('border', 'tooltip-key', 'border')],
94
+ },
95
+ {
96
+ label: 'Field',
97
+ blurb: "A field's own box, so a theme can draw its text boxes apart from the ground it uses for chips and keys: its fill, its line and its shadow.",
98
+ utilities: COLOUR_UTILITIES,
99
+ tokens: [colour('bg', 'field', 'fill'), colour('border', 'field', 'border')],
100
+ },
89
101
  {
90
102
  label: 'Accent',
91
103
  blurb: 'The brand colour: the primary button, the checked box, the brand chip. Muted is its opaque wash, wash the translucent one a row tints with on hover, outline its thin border.',
@@ -121,6 +133,7 @@ const FAMILIES: Family[] = [
121
133
  { key: 'md', cssVar: '--shadow-md', cls: 'shadow-md', swatch: 'shadow' },
122
134
  { key: 'lg', cssVar: '--shadow-lg', cls: 'shadow-lg', swatch: 'shadow' },
123
135
  { key: 'focus-ring', cssVar: '--focus-ring', cls: 'shadow-focus-ring', swatch: 'shadow' },
136
+ { key: 'field', cssVar: '--shadow-field', cls: 'shadow-field', swatch: 'shadow' },
124
137
  { key: 'glow-warning', cssVar: '--glow-warning', cls: 'shadow-glow-warning', swatch: 'shadow' },
125
138
  { key: 'highlight-inset', cssVar: '--highlight-inset', cls: 'shadow-highlight-inset', swatch: 'shadow' },
126
139
  { key: 'glow-success', cssVar: '--glow-success', cls: 'drop-shadow-glow-success', swatch: 'drop-shadow' },
@@ -180,8 +193,9 @@ function SwatchBox({ token }: { token: Token }) {
180
193
  return <div className={`${base} border border-border-subtle`} style={{ background: v }} />
181
194
  case 'text':
182
195
  case 'text-inverse':
196
+ case 'text-tooltip':
183
197
  return (
184
- <div className={`${base} flex items-center justify-center border border-border-subtle ${token.swatch === 'text-inverse' ? 'bg-accent-primary' : 'bg-bg-surface'}`}>
198
+ <div className={`${base} flex items-center justify-center border border-border-subtle ${token.swatch === 'text-inverse' ? 'bg-accent-primary' : token.swatch === 'text-tooltip' ? 'bg-bg-tooltip' : 'bg-bg-surface'}`}>
185
199
  <span className="text-body-2-strong leading-none" style={{ color: v }}>Ag</span>
186
200
  </div>
187
201
  )
@@ -70,6 +70,7 @@ export default {
70
70
  plugin(({ addVariant, addComponents }) => {
71
71
  addVariant('signal', '.signal &')
72
72
  addVariant('ship', "[data-theme='ship'] &")
73
+ addVariant('leaf', "[data-theme='leaf'] &")
73
74
  // The Signal canvas: the control-room dot grid on the app's dark ground,
74
75
  // in the Signal theme only. AppShell's floating frame carries
75
76
  // `signal-canvas`. Peek's, verbatim (UIG-14, Katerina, 19 September: the
@@ -149,6 +150,9 @@ export default {
149
150
  'bg-selected': 'var(--bg-selected)',
150
151
  'bg-active': 'var(--bg-active)',
151
152
  'bg-disabled': 'var(--bg-disabled)',
153
+ 'bg-tooltip': 'var(--bg-tooltip)',
154
+ 'bg-tooltip-key': 'var(--bg-tooltip-key)',
155
+ 'bg-field': 'var(--bg-field)',
152
156
  // text
153
157
  'text-primary': 'var(--text-primary)',
154
158
  'text-secondary': 'var(--text-secondary)',
@@ -156,11 +160,16 @@ export default {
156
160
  'text-disabled': 'var(--text-disabled)',
157
161
  'text-inverse': 'var(--text-inverse)',
158
162
  'text-interactive': 'var(--text-interactive)',
163
+ 'text-tooltip': 'var(--text-tooltip)',
164
+ 'text-tooltip-key': 'var(--text-tooltip-key)',
159
165
  // border
160
166
  'border-subtle': 'var(--border-subtle)',
161
167
  'border-default': 'var(--border-default)',
162
168
  'border-strong': 'var(--border-strong)',
163
169
  'border-focus': 'var(--border-focus)',
170
+ 'border-tooltip': 'var(--border-tooltip)',
171
+ 'border-tooltip-key': 'var(--border-tooltip-key)',
172
+ 'border-field': 'var(--border-field)',
164
173
  // accent
165
174
  'accent-primary': 'var(--accent-primary)',
166
175
  'accent-hover': 'var(--accent-hover)',
@@ -199,6 +208,7 @@ export default {
199
208
  // The ring a focused control wears where a theme wants one: Signal's
200
209
  // glow. Every theme defines it; the shared inputs use it under `signal:`.
201
210
  'focus-ring': 'var(--focus-ring)',
211
+ 'field': 'var(--shadow-field)',
202
212
  // Signal's glow and inner highlight (D16); the components use them under `signal:`.
203
213
  // All three glows are here as box shadows, and the two below are also
204
214
  // drop shadows: a glow on a surface is a box shadow, a glow on an icon
package/tokens.css CHANGED
@@ -80,6 +80,20 @@
80
80
  --highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
81
81
  --scrim: rgba(0, 0, 0, 0.5);
82
82
  --scrim-strong: rgba(0, 0, 0, 0.8);
83
+ /* Tooltips have their own colours (Katerina, 27 September); here, the ones they
84
+ always had: the elevated surface, primary text, the default border. */
85
+ --bg-tooltip: #FAFAFA;
86
+ --text-tooltip: #18181B;
87
+ --border-tooltip: #E4E4E7;
88
+ /* A shortcut key on a tooltip: what Kbd always drew here. */
89
+ --bg-tooltip-key: #F4F4F5;
90
+ --border-tooltip-key: #D4D4D8;
91
+ --text-tooltip-key: #52525B;
92
+ /* A field has its own colours (Katerina, 28 September); here, the ones it always had:
93
+ the inset ground, the default line, no shadow. */
94
+ --bg-field: #F4F4F5;
95
+ --border-field: #E4E4E7;
96
+ --shadow-field: 0 0 #0000;
83
97
  }
84
98
 
85
99
  /* ─── dark ─── Peek's dark theme (peek/src/index.css .dark, 2026-08-28). */
@@ -144,6 +158,20 @@
144
158
  --highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
145
159
  --scrim: rgba(0, 0, 0, 0.5);
146
160
  --scrim-strong: rgba(0, 0, 0, 0.8);
161
+ /* Tooltips have their own colours (Katerina, 27 September); here, the ones they
162
+ always had: the elevated surface, primary text, the default border. */
163
+ --bg-tooltip: #1C1C1F;
164
+ --text-tooltip: #FAFAFA;
165
+ --border-tooltip: #2D2D31;
166
+ /* A shortcut key on a tooltip: what Kbd always drew here. */
167
+ --bg-tooltip-key: #27272A;
168
+ --border-tooltip-key: #52525B;
169
+ --text-tooltip-key: #AEAEB6;
170
+ /* A field has its own colours (Katerina, 28 September); here, the ones it always had:
171
+ the inset ground, the default line, no shadow. */
172
+ --bg-field: #27272A;
173
+ --border-field: #2D2D31;
174
+ --shadow-field: 0 0 #0000;
147
175
  }
148
176
 
149
177
 
@@ -212,6 +240,20 @@
212
240
  --highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
213
241
  --scrim: rgba(0, 0, 0, 0.5);
214
242
  --scrim-strong: rgba(0, 0, 0, 0.8);
243
+ /* Tooltips have their own colours (Katerina, 27 September); here, the ones they
244
+ always had: the elevated surface, primary text, the default border. */
245
+ --bg-tooltip: #191d24;
246
+ --text-tooltip: #eef1f6;
247
+ --border-tooltip: rgba(255, 255, 255, 0.12);
248
+ /* A shortcut key on a tooltip: what Kbd always drew here. */
249
+ --bg-tooltip-key: rgba(255, 255, 255, 0.05);
250
+ --border-tooltip-key: rgba(255, 255, 255, 0.22);
251
+ --text-tooltip-key: #b2b9c6;
252
+ /* A field has its own colours (Katerina, 28 September); here, the ones it always had:
253
+ the inset ground, the default line, no shadow. */
254
+ --bg-field: #20242d;
255
+ --border-field: rgba(255, 255, 255, 0.12);
256
+ --shadow-field: 0 0 #0000;
215
257
  }
216
258
 
217
259
  /* ─── ship ─── Ship's palette (Katerina's ruling, 2026-08-28; ship/web/src/index.css). */
@@ -286,4 +328,106 @@
286
328
  --highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
287
329
  --scrim: rgba(0, 0, 0, 0.5);
288
330
  --scrim-strong: rgba(0, 0, 0, 0.8);
331
+ /* Tooltips have their own colours (Katerina, 27 September); here, the ones they
332
+ always had: the elevated surface, primary text, the default border. */
333
+ --bg-tooltip: #1c1f28;
334
+ --text-tooltip: #e6e8ee;
335
+ --border-tooltip: rgba(255, 255, 255, 0.12);
336
+ /* A shortcut key on a tooltip: what Kbd always drew here. */
337
+ --bg-tooltip-key: rgba(255, 255, 255, 0.05);
338
+ --border-tooltip-key: rgba(255, 255, 255, 0.22);
339
+ --text-tooltip-key: #b3b9c7;
340
+ /* A field has its own colours (Katerina, 28 September); here, the ones it always had:
341
+ the inset ground, the default line, no shadow. */
342
+ --bg-field: #1f2330;
343
+ --border-field: rgba(255, 255, 255, 0.12);
344
+ --shadow-field: 0 0 #0000;
345
+ }
346
+
347
+ /* ─── leaf ─── Leaf's theme: Linear's light theme on these same tokens (Katerina,
348
+ 27–28 September: "I like Linear's theme more", and "get it right, not by
349
+ screenshot"). Read from the Linear app itself: its named colours
350
+ (--color-bg-*, --color-border-*, --color-text-*, --focus-ring-color), dumped
351
+ from a signed-in tab on 28 September and turned from LCH into sRGB by Chrome.
352
+ Where Linear names no colour for one of these tokens, the light theme's stays
353
+ (marked "light"); menus, the toolbar and tooltips follow her rulings. */
354
+ :root[data-theme='leaf'] {
355
+ color-scheme: light;
356
+
357
+ /* bg */
358
+ --bg-base: #efeff0; /* --color-bg-quaternary, the app's ground and sidebar */
359
+ --bg-surface: #f9f9fa; /* --color-bg-primary, the content panel and header */
360
+ --bg-elevated: #ffffff; /* menus and the toolbar are white (Katerina, 27 September) */
361
+ --bg-inset: #e9e9ea; /* --color-bg-secondary: not the ground, or an inset card and a skeleton vanish on it (Katerina, 28 September) */
362
+ /* See-through, so a hover shows on a row that is itself hovered or grey (Katerina, 28 September):
363
+ 6.4% black is Linear's --color-bg-secondary (#e9e9ea) on its content panel. */
364
+ --bg-hover: rgba(0, 0, 0, 0.064);
365
+ --bg-selected: #e5e5e6; /* --color-bg-tertiary */
366
+ --bg-active: #e1e1e2; /* the selected sidebar row (lch 89.54%) */
367
+ --bg-disabled: #efeff0; /* the base, as light has it */
368
+
369
+ /* text */
370
+ --text-primary: #1b1b1b; /* --color-text-primary */
371
+ --text-secondary: #5c5c5e; /* --color-text-tertiary: labels, descriptions, icons */
372
+ --text-muted: #9c9d9f; /* --color-text-quaternary: placeholders, counts */
373
+ --text-disabled: #D4D4D8; /* light */
374
+ --text-inverse: #ffffff;
375
+ --text-interactive: #1b1b1b; /* --app-link-color: a link is the text colour */
376
+
377
+ /* borders */
378
+ /* One step darker than Linear's own faintest line (Katerina, 28 September): our cards, chips and
379
+ toasts draw their edge with subtle, and Linear's #f1f1f1 vanished on white and on the ground. */
380
+ --border-subtle: #e5e5e6; /* --color-bg-tertiary */
381
+ --border-default: #dedede; /* --color-border-secondary */
382
+ --border-strong: #d3d3d3; /* --color-border-tertiary */
383
+ --border-focus: #5e69d1; /* --focus-ring-color */
384
+
385
+ /* accent — Linear's indigo */
386
+ --accent-primary: #5e69d1; /* --focus-ring-color, the selection's colour */
387
+ --accent-hover: #6b75df; /* not named in Linear's set: the indigo beside it in the app's own values */
388
+ --accent-muted: #EDE9FE; /* light */
389
+
390
+ /* semantic — light, until Linear's are read */
391
+ --info-default: #3B82F6;
392
+ --info-muted: #DBEAFE;
393
+ --warning-default: #967000; /* Linear's darkest yellow: readable on the warning wash (Katerina, 28 September) */
394
+ --warning-muted: #FEF3C7;
395
+ --success-default: #0EA06F;
396
+ --success-muted: #D1FAE5;
397
+ --error-default: #EF4444;
398
+ --error-muted: #FEE2E2;
399
+
400
+ /* shadows — light */
401
+ --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
402
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -2px rgba(0,0,0,0.10);
403
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -4px rgba(0,0,0,0.10);
404
+ --focus-ring: 0 0 0 1px #5e69d1; /* --focus-ring-outline: 1px of the accent */
405
+ /* Transparent colours (D16): light's strengths, on Linear's indigo. */
406
+ --bg-wash: rgba(0, 0, 0, 0.04);
407
+ --accent-outline: rgba(94, 105, 209, 0.3);
408
+ --info-outline: rgba(59, 130, 246, 0.3);
409
+ --warning-outline: rgba(245, 158, 11, 0.3);
410
+ --success-outline: rgba(14, 160, 111, 0.3);
411
+ --error-outline: rgba(239, 68, 68, 0.3);
412
+ --accent-wash: rgba(94, 105, 209, 0.11);
413
+ --success-wash: rgba(14, 160, 111, 0.11);
414
+ --glow-warning: 0 0 5px rgba(245, 158, 11, 0.4);
415
+ --glow-success: 0 0 5px rgba(14, 160, 111, 0.7);
416
+ --glow-accent: 0 0 5px rgba(94, 105, 209, 0.6);
417
+ --highlight-inset: inset 0 1px 0 rgba(255, 255, 255, 0.035);
418
+ --scrim: rgba(0, 0, 0, 0.5);
419
+ --scrim-strong: rgba(0, 0, 0, 0.8);
420
+ /* Tooltips are dark on the light page (Katerina, 27 September): Linear's text colour as the ground. */
421
+ --bg-tooltip: #1b1b1b;
422
+ --text-tooltip: #ffffff;
423
+ --border-tooltip: #1b1b1b;
424
+ /* A shortcut key on the dark tooltip: a faint light key. */
425
+ --bg-tooltip-key: rgba(255, 255, 255, 0.1);
426
+ --border-tooltip-key: rgba(255, 255, 255, 0.18);
427
+ --text-tooltip-key: rgba(255, 255, 255, 0.75);
428
+ /* A field is Linear's: white, a faint line, a soft two-layer shadow (read from its
429
+ comment box, 28 September: lch(100), lch(97.3), 0 3px 6px -2px at 2% and 0 1px 1px at 4%). */
430
+ --bg-field: #ffffff;
431
+ --border-field: #dedede; /* the default line, so a field shows on a white dialog (Katerina, 28 September); Linear's own is #f7f7f7 */
432
+ --shadow-field: 0 3px 6px -2px rgba(0, 0, 0, 0.02), 0 1px 1px 0 rgba(0, 0, 0, 0.04);
289
433
  }