@estiva-app/ui 0.1.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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/Avatar.d.ts +16 -0
  4. package/dist/Avatar.d.ts.map +1 -0
  5. package/dist/Button.d.ts +28 -0
  6. package/dist/Button.d.ts.map +1 -0
  7. package/dist/Chip.d.ts +21 -0
  8. package/dist/Chip.d.ts.map +1 -0
  9. package/dist/DialogShell.d.ts +19 -0
  10. package/dist/DialogShell.d.ts.map +1 -0
  11. package/dist/Divider.d.ts +12 -0
  12. package/dist/Divider.d.ts.map +1 -0
  13. package/dist/EmptyState.d.ts +14 -0
  14. package/dist/EmptyState.d.ts.map +1 -0
  15. package/dist/Field.d.ts +13 -0
  16. package/dist/Field.d.ts.map +1 -0
  17. package/dist/IconButton.d.ts +17 -0
  18. package/dist/IconButton.d.ts.map +1 -0
  19. package/dist/Select.d.ts +30 -0
  20. package/dist/Select.d.ts.map +1 -0
  21. package/dist/Skeleton.d.ts +24 -0
  22. package/dist/Skeleton.d.ts.map +1 -0
  23. package/dist/TextInput.d.ts +11 -0
  24. package/dist/TextInput.d.ts.map +1 -0
  25. package/dist/Textarea.d.ts +8 -0
  26. package/dist/Textarea.d.ts.map +1 -0
  27. package/dist/Tooltip.d.ts +22 -0
  28. package/dist/Tooltip.d.ts.map +1 -0
  29. package/dist/cn.d.ts +10 -0
  30. package/dist/cn.d.ts.map +1 -0
  31. package/dist/index.d.ts +26 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +486 -0
  34. package/dist/index.js.map +7 -0
  35. package/package.json +74 -0
  36. package/src/Avatar.stories.tsx +55 -0
  37. package/src/Avatar.tsx +81 -0
  38. package/src/Button.stories.tsx +53 -0
  39. package/src/Button.tsx +67 -0
  40. package/src/Chip.stories.tsx +37 -0
  41. package/src/Chip.tsx +43 -0
  42. package/src/DialogShell.stories.tsx +97 -0
  43. package/src/DialogShell.tsx +65 -0
  44. package/src/Divider.stories.tsx +44 -0
  45. package/src/Divider.tsx +22 -0
  46. package/src/EmptyState.stories.tsx +18 -0
  47. package/src/EmptyState.tsx +24 -0
  48. package/src/Field.stories.tsx +22 -0
  49. package/src/Field.tsx +24 -0
  50. package/src/IconButton.stories.tsx +44 -0
  51. package/src/IconButton.tsx +60 -0
  52. package/src/Select.stories.tsx +107 -0
  53. package/src/Select.tsx +195 -0
  54. package/src/Skeleton.stories.tsx +33 -0
  55. package/src/Skeleton.tsx +38 -0
  56. package/src/TextInput.stories.tsx +18 -0
  57. package/src/TextInput.tsx +29 -0
  58. package/src/Textarea.stories.tsx +17 -0
  59. package/src/Textarea.tsx +25 -0
  60. package/src/Tooltip.stories.tsx +47 -0
  61. package/src/Tooltip.tsx +70 -0
  62. package/src/cn.ts +13 -0
  63. package/src/index.ts +25 -0
  64. package/tailwind-preset.js +162 -0
  65. package/tokens.css +225 -0
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Button } from './Button'
3
+ import { Tooltip, WithTooltip } from './Tooltip'
4
+
5
+ const meta = {
6
+ title: 'Primitives/Tooltip',
7
+ component: Tooltip,
8
+ args: { label: 'Add to starred' },
9
+ } satisfies Meta<typeof Tooltip>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** The static tooltip surface. */
15
+ export const Default: Story = {}
16
+
17
+ /** Hover the button — WithTooltip portals the tooltip above the trigger. */
18
+ export const OnHoverTop: Story = {
19
+ parameters: { controls: { disable: true } },
20
+ render: () => (
21
+ <WithTooltip label="Add to starred" placement="top">
22
+ <Button variant="outlined">Hover me</Button>
23
+ </WithTooltip>
24
+ ),
25
+ }
26
+
27
+ /** Bottom placement. */
28
+ export const OnHoverBottom: Story = {
29
+ parameters: { controls: { disable: true } },
30
+ render: () => (
31
+ <WithTooltip label="Sort by" placement="bottom">
32
+ <Button variant="outlined">Hover me</Button>
33
+ </WithTooltip>
34
+ ),
35
+ }
36
+
37
+ /** The reason a disabled control gives — wrap the control, not the form around it. */
38
+ export const OnADisabledControl: Story = {
39
+ parameters: { controls: { disable: true } },
40
+ render: () => (
41
+ <WithTooltip label="Available after sign-in">
42
+ <Button variant="primary" disabled>
43
+ Post
44
+ </Button>
45
+ </WithTooltip>
46
+ ),
47
+ }
@@ -0,0 +1,70 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * Peek's Tooltip and WithTooltip (2026-08-28), verbatim, in one file.
7
+ *
8
+ * `Tooltip` is the surface: a 30px elevated pill with a caption. `WithTooltip`
9
+ * wraps a trigger and portals the surface above or below it on hover, fixed
10
+ * to the viewport and kept 8px inside it. The wrapper is `inline-flex` and
11
+ * shrinks nothing — wrap a control, never a block (a form inside it
12
+ * collapses to its content width; Ship learnt that).
13
+ */
14
+ export interface TooltipProps {
15
+ label: string
16
+ className?: string
17
+ }
18
+
19
+ export function Tooltip({ label, className }: TooltipProps) {
20
+ return (
21
+ <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center px-2 shadow-lg', className)}>
22
+ <span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
23
+ </div>
24
+ )
25
+ }
26
+
27
+ export interface WithTooltipProps {
28
+ label: string
29
+ placement?: 'top' | 'bottom'
30
+ children: ReactNode
31
+ }
32
+
33
+ const GAP = 6
34
+ const VIEWPORT_PAD = 8
35
+
36
+ export function WithTooltip({ label, placement = 'top', children }: WithTooltipProps) {
37
+ const [show, setShow] = useState(false)
38
+ const ref = useRef<HTMLDivElement>(null)
39
+ const tooltipRef = useRef<HTMLDivElement>(null)
40
+ const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', zIndex: 9999, pointerEvents: 'none', visibility: 'hidden' })
41
+
42
+ const reposition = useCallback(() => {
43
+ const trigger = ref.current
44
+ const tip = tooltipRef.current
45
+ if (!trigger || !tip) return
46
+ const triggerRect = trigger.getBoundingClientRect()
47
+ const tipRect = tip.getBoundingClientRect()
48
+ const top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
49
+ let left = triggerRect.left + triggerRect.width / 2 - tipRect.width / 2
50
+ left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD))
51
+ setStyle({ position: 'fixed', top, left, zIndex: 9999, pointerEvents: 'none', visibility: 'visible' })
52
+ }, [placement])
53
+
54
+ useLayoutEffect(() => {
55
+ if (show) reposition()
56
+ }, [show, reposition])
57
+
58
+ return (
59
+ <div ref={ref} className="inline-flex shrink-0" onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
60
+ {children}
61
+ {show &&
62
+ createPortal(
63
+ <div ref={tooltipRef} style={style}>
64
+ <Tooltip label={label} />
65
+ </div>,
66
+ document.body,
67
+ )}
68
+ </div>
69
+ )
70
+ }
package/src/cn.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ /**
5
+ * Peek's `cn`: clsx for the conditionals, tailwind-merge for the conflicts.
6
+ *
7
+ * Remember the pitfall the README records: a custom text-size class in a
8
+ * merged list is dropped when a `text-{colour}` follows it. Inside this
9
+ * package, sizes are arbitrary values (`text-[12px]`), never the token class.
10
+ */
11
+ export function cn(...inputs: ClassValue[]) {
12
+ return twMerge(clsx(inputs))
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @estiva-app/ui — the primitives.
3
+ *
4
+ * Shipped **built**: `dist/index.js` (esbuild, one bundle, React external) plus
5
+ * `.d.ts` from `tsc`. Not source — a package of `.ts` is compiled with the
6
+ * *consumer's* tsconfig, and Ship's `noUnusedLocals` alone is enough to fail a
7
+ * build over a library it does not own. See estiva-docs ADR 0002 §4a.
8
+ *
9
+ * `src/` still ships in the tarball, so stepping into a component lands on the
10
+ * TypeScript that produced it.
11
+ */
12
+ export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
13
+ export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
14
+ export { Chip, type ChipProps, type ChipType } from './Chip'
15
+ export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
16
+ export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
17
+ export { Field, type FieldProps } from './Field'
18
+ export { Select, type SelectOption, type SelectProps } from './Select'
19
+ export { TextInput, type TextInputProps } from './TextInput'
20
+ export { Textarea, type TextareaProps } from './Textarea'
21
+ export { DialogShell, type DialogShellProps } from './DialogShell'
22
+ export { Divider, type DividerProps } from './Divider'
23
+ export { EmptyState, type EmptyStateProps } from './EmptyState'
24
+ export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
25
+ export { cn } from './cn'
@@ -0,0 +1,162 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import plugin from 'tailwindcss/plugin'
3
+
4
+ /**
5
+ * The globs a consumer must add to its own `content`, exported so nobody has to
6
+ * write a path into this package by hand.
7
+ *
8
+ * **Spread these — do not rely on the preset carrying them.** Tailwind does not
9
+ * merge `content` from a preset: measured on 2026-08-26 against Peek, with the
10
+ * preset exporting exactly these absolute paths in its own `content`, the class
11
+ * `max-h-72` — used by `Select` and by nothing in Peek — was still absent from
12
+ * the built stylesheet. Moving the identical string into Peek's own `content`
13
+ * put it there. The preset is not a place this can be fixed.
14
+ *
15
+ * Why it matters: Tailwind purges every class it cannot find, so the failure is
16
+ * a build that succeeds, tests that pass and tokens that are present, with
17
+ * components rendering at the wrong size. Nothing reports it.
18
+ *
19
+ * The paths are absolute and derived from this file's own location, because a
20
+ * `content` entry resolves against the consumer's working directory and the
21
+ * consumer has no reliable relative path to us.
22
+ *
23
+ * import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'
24
+ * export default {
25
+ * presets: [estiva],
26
+ * content: [...estivaContent, './index.html', './src/**\/*.{ts,tsx}'],
27
+ * }
28
+ */
29
+ const own = (glob) => fileURLToPath(new URL(glob, import.meta.url))
30
+ export const estivaContent = [own('./dist/*.js'), own('./src/*.{ts,tsx}')]
31
+
32
+ /**
33
+ * The Estiva Tailwind preset — the token NAMES every Estiva app shares.
34
+ *
35
+ * Peek's `tailwind.config.js` `theme.extend`, verbatim (2026-08-28), minus
36
+ * three tokens that are Peek's product and not the suite's: `bg-private`,
37
+ * `border-private` (the huddle register) and `logo` (its wordmark). Peek
38
+ * keeps those in its own `extend`, which merges over this preset.
39
+ *
40
+ * Every colour resolves a CSS variable, and the variables live in
41
+ * `tokens.css` next to this file, one block per theme. Changing a value is a
42
+ * change to a theme; changing a NAME is a change to the contract and needs
43
+ * every consumer looked at.
44
+ *
45
+ * Consume with:
46
+ *
47
+ * import estiva from '@estiva-app/ui/tailwind-preset'
48
+ * export default { presets: [estiva], content: [...your app's], theme: { extend: { ...your own } } }
49
+ *
50
+ * A consumer lists only its own files: the preset already contributes this
51
+ * package's, and Tailwind concatenates the two.
52
+ *
53
+ * `darkMode: 'class'` is here because Peek's `dark:` variants key on the
54
+ * `.dark` class, and `tokens.css` answers to both that class and
55
+ * `data-theme="dark"`. The `signal:` variant is here for the same reason:
56
+ * `signal` is a theme in `tokens.css`, and a shared component may carry
57
+ * the few Signal-only touches Peek gives it (a semibold primary, a mono
58
+ * chip label) without the consuming app knowing — an app that never
59
+ * applies `.signal` never sees them.
60
+ *
61
+ * @type {import('tailwindcss').Config}
62
+ */
63
+ export default {
64
+
65
+ darkMode: 'class',
66
+ plugins: [plugin(({ addVariant }) => addVariant('signal', '.signal &'))],
67
+ theme: {
68
+ extend: {
69
+ fontFamily: {
70
+ sans: ['Geist', 'system-ui', 'sans-serif'],
71
+ mono: ['Geist Mono', 'ui-monospace', 'monospace'],
72
+ },
73
+ fontSize: {
74
+ // Headings
75
+ h1: ['26px', { lineHeight: '115%', letterSpacing: '-0.02em', fontWeight: '600' }],
76
+ h2: ['22px', { lineHeight: '120%', letterSpacing: '-0.01em', fontWeight: '600' }],
77
+ h3: ['18px', { lineHeight: '120%', letterSpacing: '0', fontWeight: '600' }],
78
+ h4: ['16px', { lineHeight: '150%', letterSpacing: '-0.01em', fontWeight: '600' }],
79
+ h5: ['12px', { lineHeight: '100%', letterSpacing: '0', fontWeight: '500' }],
80
+ // Body
81
+ 'body-1': ['16px', { lineHeight: '150%', letterSpacing: '0', fontWeight: '400' }],
82
+ 'body-2': ['14px', { lineHeight: '140%', letterSpacing: '0', fontWeight: '400' }],
83
+ 'body-2-strong': ['14px', { lineHeight: '140%', letterSpacing: '0', fontWeight: '500' }],
84
+ caption: ['12px', { lineHeight: '120%', letterSpacing: '0', fontWeight: '400' }],
85
+ menu: ['9px', { lineHeight: '115%', letterSpacing: '0', fontWeight: '500' }],
86
+ // Component-specific. In shared components prefer explicit arbitrary
87
+ // values (`text-[12px] leading-[12px]`): tailwind-merge silently drops
88
+ // a custom text-size class that is followed by a `text-{color}` class.
89
+ 'btn-default': ['14px', { lineHeight: '14px', letterSpacing: '0', fontWeight: '500' }],
90
+ 'btn-small': ['12px', { lineHeight: '12px', letterSpacing: '0', fontWeight: '500' }],
91
+ 'input-label': ['12px', { lineHeight: '115%', letterSpacing: '0', fontWeight: '500' }],
92
+ 'input-value': ['14px', { lineHeight: '140%', letterSpacing: '0', fontWeight: '400' }],
93
+ 'input-helper': ['12px', { lineHeight: '120%', letterSpacing: '0', fontWeight: '400' }],
94
+ 'chip': ['11px', { lineHeight: '110%', letterSpacing: '0', fontWeight: '500' }],
95
+ },
96
+ borderRadius: {
97
+ none: '0px',
98
+ sm: '4px',
99
+ md: '6px',
100
+ lg: '8px',
101
+ xl: '12px',
102
+ '2xl': '16px',
103
+ '3xl': '24px',
104
+ full: '9999px',
105
+ },
106
+ colors: {
107
+ // bg
108
+ 'bg-base': 'var(--bg-base)',
109
+ 'bg-surface': 'var(--bg-surface)',
110
+ 'bg-elevated': 'var(--bg-elevated)',
111
+ 'bg-inset': 'var(--bg-inset)',
112
+ 'bg-hover': 'var(--bg-hover)',
113
+ 'bg-selected': 'var(--bg-selected)',
114
+ 'bg-active': 'var(--bg-active)',
115
+ 'bg-disabled': 'var(--bg-disabled)',
116
+ // text
117
+ 'text-primary': 'var(--text-primary)',
118
+ 'text-secondary': 'var(--text-secondary)',
119
+ 'text-muted': 'var(--text-muted)',
120
+ 'text-disabled': 'var(--text-disabled)',
121
+ 'text-inverse': 'var(--text-inverse)',
122
+ // border
123
+ 'border-subtle': 'var(--border-subtle)',
124
+ 'border-default': 'var(--border-default)',
125
+ 'border-strong': 'var(--border-strong)',
126
+ 'border-focus': 'var(--border-focus)',
127
+ // accent
128
+ 'accent-primary': 'var(--accent-primary)',
129
+ 'accent-hover': 'var(--accent-hover)',
130
+ 'accent-muted': 'var(--accent-muted)',
131
+ // semantic
132
+ 'info-default': 'var(--info-default)',
133
+ 'info-muted': 'var(--info-muted)',
134
+ 'warning-default': 'var(--warning-default)',
135
+ 'warning-muted': 'var(--warning-muted)',
136
+ 'success-default': 'var(--success-default)',
137
+ 'success-muted': 'var(--success-muted)',
138
+ 'error-default': 'var(--error-default)',
139
+ 'error-muted': 'var(--error-muted)',
140
+ },
141
+ boxShadow: {
142
+ 'sm': 'var(--shadow-sm)',
143
+ 'md': 'var(--shadow-md)',
144
+ 'lg': 'var(--shadow-lg)',
145
+ // The ring a focused control wears where a theme wants one: Signal's
146
+ // glow. Every theme defines it; the shared inputs use it under `signal:`.
147
+ 'focus-ring': 'var(--focus-ring)',
148
+ },
149
+ keyframes: {
150
+ 'skeleton-in': {
151
+ from: { opacity: '0' },
152
+ to: { opacity: '1' },
153
+ },
154
+ },
155
+ animation: {
156
+ // Delayed reveal for loading skeletons: invisible for the first 150ms
157
+ // so fast loads never flash a skeleton (Peek's rule, 2026-07-08).
158
+ 'skeleton-in': 'skeleton-in 0.2s ease-out 0.15s both',
159
+ },
160
+ },
161
+ },
162
+ }
package/tokens.css ADDED
@@ -0,0 +1,225 @@
1
+ /*
2
+ Estiva's design tokens — the VALUES behind the names in tailwind-preset.js,
3
+ one block per theme. Import once, before anything that uses them:
4
+
5
+ @import '@estiva-app/ui/tokens.css';
6
+
7
+ Select a theme with `data-theme="<name>"` on <html>. `dark` and `signal`
8
+ also answer to the `.dark` / `.signal` classes, which is how Peek selects
9
+ them (`.dark.signal` — Signal layers over dark) and what its `dark:`
10
+ variants key on. No attribute at all is `light`.
11
+
12
+ Which theme is whose: Peek renders `signal`; Ship renders `ship`; `light`
13
+ and `dark` are the bases Peek's Storybook and Estiva ID's screens use.
14
+
15
+ Every block defines every token (tokens.test.ts checks). A theme that left
16
+ one out would inherit it from `:root` and silently show light values.
17
+
18
+ Peek's Signal-only extras (highlight, washes, glows, private, logo, mono)
19
+ are product, not the contract, and stay in Peek's own `.signal` block.
20
+ */
21
+
22
+ /* ─── light ─── Peek's base theme (peek/src/index.css :root, 2026-08-28). */
23
+ :root {
24
+ /* bg */
25
+ --bg-base: #F4F4F5;
26
+ --bg-surface: #FFFFFF;
27
+ --bg-elevated: #FAFAFA;
28
+ --bg-inset: #F4F4F5;
29
+ --bg-hover: #E9E9EB;
30
+ --bg-selected: #E4E4E7;
31
+ --bg-active: #D4D4D8;
32
+ --bg-disabled: #F4F4F5;
33
+
34
+ /* text */
35
+ --text-primary: #18181B;
36
+ --text-secondary: #52525B;
37
+ --text-muted: #A1A1AA;
38
+ --text-disabled: #D4D4D8;
39
+ --text-inverse: #FAFAFA;
40
+
41
+ /* border */
42
+ --border-subtle: #F4F4F5;
43
+ --border-default: #E4E4E7;
44
+ --border-strong: #D4D4D8;
45
+ --border-focus: #8B5CF6;
46
+
47
+ /* accent */
48
+ --accent-primary: #8B5CF6;
49
+ --accent-hover: #7C3AED;
50
+ --accent-muted: #EDE9FE;
51
+
52
+ /* semantic */
53
+ --info-default: #3B82F6;
54
+ --info-muted: #DBEAFE;
55
+ --warning-default: #F59E0B;
56
+ --warning-muted: #FEF3C7;
57
+ --success-default: #0EA06F;
58
+ --success-muted: #D1FAE5;
59
+ --error-default: #EF4444;
60
+ --error-muted: #FEE2E2;
61
+
62
+ /* shadows */
63
+ --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
64
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -2px rgba(0,0,0,0.10);
65
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -4px rgba(0,0,0,0.10);
66
+ --focus-ring: 0 0 0 3px rgba(139, 92, 246, 0.16);
67
+ }
68
+
69
+ /* ─── dark ─── Peek's dark theme (peek/src/index.css .dark, 2026-08-28). */
70
+ .dark,
71
+ :root[data-theme='dark'] {
72
+ /* bg */
73
+ --bg-base: #09090B;
74
+ --bg-surface: #141416;
75
+ --bg-elevated: #1C1C1F;
76
+ --bg-inset: #27272A;
77
+ --bg-hover: #232326;
78
+ --bg-selected: #1F1F22;
79
+ --bg-active: #2D2D31;
80
+ --bg-disabled: #1C1C1F;
81
+
82
+ /* text */
83
+ --text-primary: #FAFAFA;
84
+ --text-secondary: #AEAEB6; /* nudged up from #A1A1AA (2026-07-23) — brighter bodies, still clearly below primary */
85
+ --text-muted: #71717A;
86
+ --text-disabled: #52525B;
87
+ --text-inverse: #18181B;
88
+
89
+ /* border */
90
+ --border-subtle: #1F1F22;
91
+ --border-default: #2D2D31;
92
+ --border-strong: #52525B;
93
+ --border-focus: #A78BFA;
94
+
95
+ /* accent */
96
+ --accent-primary: #A78BFA;
97
+ --accent-hover: #8B5CF6;
98
+ --accent-muted: #2E2554;
99
+
100
+ /* semantic */
101
+ --info-default: #60A5FA;
102
+ --info-muted: #172554;
103
+ --warning-default: #FBBF24;
104
+ --warning-muted: #4A3728;
105
+ --success-default: #34D399;
106
+ --success-muted: #0E392B;
107
+ --error-default: #F87171;
108
+ --error-muted: #4A2828;
109
+
110
+ /* shadows */
111
+ --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.30);
112
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.40), 0 2px 4px -2px rgba(0,0,0,0.30);
113
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.50), 0 4px 6px -4px rgba(0,0,0,0.40);
114
+ --focus-ring: 0 0 0 3px rgba(167, 139, 250, 0.16);
115
+ }
116
+
117
+
118
+ /* ─── signal ─── Peek's theme, the one it renders (peek/src/index.css .signal, 2026-08-28).
119
+ Peek applies `.dark.signal`; this block comes after dark, so it wins. Peek's
120
+ Signal-only extras (washes, glows, highlight, private, logo, mono) stay in Peek. */
121
+ .signal,
122
+ :root[data-theme='signal'] {
123
+ /* surfaces (5-step, blue-shifted graphite) */
124
+ --bg-base: #0e1014;
125
+ --bg-surface: #12151a;
126
+ --bg-elevated: #191d24;
127
+ --bg-inset: #20242d;
128
+ --bg-hover: rgba(255, 255, 255, 0.05);
129
+ --bg-selected: rgba(86, 200, 255, 0.11);
130
+ --bg-active: rgba(255, 255, 255, 0.08);
131
+ --bg-disabled: #191d24;
132
+
133
+ /* text */
134
+ --text-primary: #eef1f6;
135
+ --text-secondary: #b2b9c6; /* nudged up from #a6adbb (2026-07-23) — same cool hue, one step brighter */
136
+ --text-muted: #6b7280;
137
+ --text-disabled: #464c58;
138
+ --text-inverse: #06121a;
139
+
140
+ /* borders (alpha hairlines) */
141
+ --border-subtle: rgba(255, 255, 255, 0.065);
142
+ --border-default: rgba(255, 255, 255, 0.12);
143
+ --border-strong: rgba(255, 255, 255, 0.22);
144
+ --border-focus: #56c8ff;
145
+
146
+ /* accent: ice cyan */
147
+ --accent-primary: #56c8ff;
148
+ --accent-hover: #8ad6ff;
149
+ --accent-muted: rgba(86, 200, 255, 0.11);
150
+
151
+ /* semantic (amber is the attention color) */
152
+ --info-default: #8ad6ff;
153
+ --info-muted: rgba(86, 200, 255, 0.11);
154
+ --warning-default: #ffb020;
155
+ --warning-muted: rgba(255, 176, 32, 0.12);
156
+ --success-default: #3fde8c;
157
+ --success-muted: rgba(63, 222, 140, 0.11);
158
+ --error-default: #ff6b6b;
159
+ --error-muted: rgba(255, 107, 107, 0.12);
160
+
161
+ /* shadows: black depth + 1px white rim on floating layers */
162
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 18px -8px rgba(0, 0, 0, 0.55);
163
+ --shadow-md: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 16px rgba(0, 0, 0, 0.45), 0 22px 60px -16px rgba(0, 0, 0, 0.75);
164
+ --shadow-lg: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 12px 28px rgba(0, 0, 0, 0.5), 0 36px 90px -16px rgba(0, 0, 0, 0.8);
165
+ /* the glow a focused control wears — Peek's, verbatim */
166
+ --focus-ring: 0 0 0 3px rgba(86, 200, 255, 0.12), 0 0 28px -8px rgba(86, 200, 255, 0.4);
167
+ }
168
+
169
+ /* ─── ship ─── Ship's palette (Katerina's ruling, 2026-08-28; ship/web/src/index.css). */
170
+ :root[data-theme='ship'] {
171
+ color-scheme: dark;
172
+
173
+ /* bg */
174
+ --bg-base: #0e0f13;
175
+ --bg-surface: #16181f;
176
+ --bg-elevated: #1c1f28;
177
+ /* Lighter than the surfaces it sits on, as dark has it (its inset is the
178
+ lightest background): a neutral Chip and a search field are bg-inset. */
179
+ --bg-inset: #1f2330;
180
+ /* Translucent, so a hover reads on every surface. */
181
+ --bg-hover: rgba(255, 255, 255, 0.05);
182
+ --bg-selected: rgba(75, 88, 212, 0.16);
183
+ --bg-active: rgba(255, 255, 255, 0.08);
184
+ --bg-disabled: #16181f;
185
+
186
+ /* text */
187
+ --text-primary: #e6e8ee;
188
+ --text-secondary: #b3b9c7;
189
+ --text-muted: #8b91a1;
190
+ --text-disabled: #5b6070;
191
+ /* Light, not dark: "inverse" is the text on the accent, and Ship's accent is
192
+ dark enough that light text is the readable one (4.9:1). Katerina's
193
+ ruling for the primary button, 2026-08-27. */
194
+ --text-inverse: #e6e8ee;
195
+
196
+ /* borders (alpha hairlines, as Signal's — Katerina, 2026-08-28: Ship's
197
+ inputs should carry the same border as Signal's; an alpha line reads on
198
+ every surface, the inset field included) */
199
+ --border-subtle: rgba(255, 255, 255, 0.065);
200
+ --border-default: rgba(255, 255, 255, 0.12);
201
+ --border-strong: rgba(255, 255, 255, 0.22);
202
+ --border-focus: #4b58d4;
203
+
204
+ /* accent — blue with a touch of purple; light text on it reads at 4.9:1 */
205
+ --accent-primary: #4b58d4;
206
+ --accent-hover: #5c69dc;
207
+ --accent-muted: #1c2144;
208
+
209
+ /* semantic */
210
+ --info-default: #5b7be8;
211
+ --info-muted: #1a2342;
212
+ --warning-default: #e0b04a;
213
+ --warning-muted: #3a2f14;
214
+ --success-default: #46c08a;
215
+ --success-muted: #123326;
216
+ --error-default: #e5697a;
217
+ --error-muted: #3a1c22;
218
+
219
+ /* shadows */
220
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
221
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
222
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
223
+ /* defined, as every theme must; Ship's controls show focus with the border alone */
224
+ --focus-ring: 0 0 0 3px rgba(75, 88, 212, 0.16);
225
+ }