@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/Avatar.d.ts +16 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/dist/Button.d.ts +28 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Chip.d.ts +21 -0
- package/dist/Chip.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +19 -0
- package/dist/DialogShell.d.ts.map +1 -0
- package/dist/Divider.d.ts +12 -0
- package/dist/Divider.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +14 -0
- package/dist/EmptyState.d.ts.map +1 -0
- package/dist/Field.d.ts +13 -0
- package/dist/Field.d.ts.map +1 -0
- package/dist/IconButton.d.ts +17 -0
- package/dist/IconButton.d.ts.map +1 -0
- package/dist/Select.d.ts +30 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +24 -0
- package/dist/Skeleton.d.ts.map +1 -0
- package/dist/TextInput.d.ts +11 -0
- package/dist/TextInput.d.ts.map +1 -0
- package/dist/Textarea.d.ts +8 -0
- package/dist/Textarea.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +22 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/cn.d.ts +10 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +486 -0
- package/dist/index.js.map +7 -0
- package/package.json +74 -0
- package/src/Avatar.stories.tsx +55 -0
- package/src/Avatar.tsx +81 -0
- package/src/Button.stories.tsx +53 -0
- package/src/Button.tsx +67 -0
- package/src/Chip.stories.tsx +37 -0
- package/src/Chip.tsx +43 -0
- package/src/DialogShell.stories.tsx +97 -0
- package/src/DialogShell.tsx +65 -0
- package/src/Divider.stories.tsx +44 -0
- package/src/Divider.tsx +22 -0
- package/src/EmptyState.stories.tsx +18 -0
- package/src/EmptyState.tsx +24 -0
- package/src/Field.stories.tsx +22 -0
- package/src/Field.tsx +24 -0
- package/src/IconButton.stories.tsx +44 -0
- package/src/IconButton.tsx +60 -0
- package/src/Select.stories.tsx +107 -0
- package/src/Select.tsx +195 -0
- package/src/Skeleton.stories.tsx +33 -0
- package/src/Skeleton.tsx +38 -0
- package/src/TextInput.stories.tsx +18 -0
- package/src/TextInput.tsx +29 -0
- package/src/Textarea.stories.tsx +17 -0
- package/src/Textarea.tsx +25 -0
- package/src/Tooltip.stories.tsx +47 -0
- package/src/Tooltip.tsx +70 -0
- package/src/cn.ts +13 -0
- package/src/index.ts +25 -0
- package/tailwind-preset.js +162 -0
- 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
|
+
}
|
package/src/Tooltip.tsx
ADDED
|
@@ -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
|
+
}
|