@estiva-app/ui 0.1.0 → 0.2.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 (157) hide show
  1. package/README.md +12 -9
  2. package/base.css +43 -0
  3. package/dist/AppShell.d.ts +39 -0
  4. package/dist/AppShell.d.ts.map +1 -0
  5. package/dist/Avatar.d.ts +7 -2
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/AvatarGroup.d.ts +23 -0
  8. package/dist/AvatarGroup.d.ts.map +1 -0
  9. package/dist/Banner.d.ts +19 -0
  10. package/dist/Banner.d.ts.map +1 -0
  11. package/dist/Breadcrumb.d.ts +28 -0
  12. package/dist/Breadcrumb.d.ts.map +1 -0
  13. package/dist/Checkbox.d.ts +18 -0
  14. package/dist/Checkbox.d.ts.map +1 -0
  15. package/dist/ChipInput.d.ts +57 -0
  16. package/dist/ChipInput.d.ts.map +1 -0
  17. package/dist/ConfirmDialog.d.ts +22 -0
  18. package/dist/ConfirmDialog.d.ts.map +1 -0
  19. package/dist/DialogShell.d.ts +9 -2
  20. package/dist/DialogShell.d.ts.map +1 -1
  21. package/dist/EditableText.d.ts +36 -0
  22. package/dist/EditableText.d.ts.map +1 -0
  23. package/dist/IdentityMenu.d.ts +58 -0
  24. package/dist/IdentityMenu.d.ts.map +1 -0
  25. package/dist/Menu.d.ts +82 -0
  26. package/dist/Menu.d.ts.map +1 -0
  27. package/dist/NavItem.d.ts +26 -0
  28. package/dist/NavItem.d.ts.map +1 -0
  29. package/dist/Person.d.ts +26 -0
  30. package/dist/Person.d.ts.map +1 -0
  31. package/dist/PersonTrigger.d.ts +23 -0
  32. package/dist/PersonTrigger.d.ts.map +1 -0
  33. package/dist/Property.d.ts +24 -0
  34. package/dist/Property.d.ts.map +1 -0
  35. package/dist/Rail.d.ts +18 -0
  36. package/dist/Rail.d.ts.map +1 -0
  37. package/dist/RailItem.d.ts +23 -0
  38. package/dist/RailItem.d.ts.map +1 -0
  39. package/dist/SearchInput.d.ts +21 -0
  40. package/dist/SearchInput.d.ts.map +1 -0
  41. package/dist/SectionHeader.d.ts +34 -0
  42. package/dist/SectionHeader.d.ts.map +1 -0
  43. package/dist/SectionLabel.d.ts +19 -0
  44. package/dist/SectionLabel.d.ts.map +1 -0
  45. package/dist/Select.d.ts +42 -5
  46. package/dist/Select.d.ts.map +1 -1
  47. package/dist/Sidebar.d.ts +18 -0
  48. package/dist/Sidebar.d.ts.map +1 -0
  49. package/dist/Tabs.d.ts +34 -0
  50. package/dist/Tabs.d.ts.map +1 -0
  51. package/dist/Toast.d.ts +48 -0
  52. package/dist/Toast.d.ts.map +1 -0
  53. package/dist/Tooltip.d.ts +3 -1
  54. package/dist/Tooltip.d.ts.map +1 -1
  55. package/dist/TopBar.d.ts +33 -0
  56. package/dist/TopBar.d.ts.map +1 -0
  57. package/dist/cn.d.ts +14 -4
  58. package/dist/cn.d.ts.map +1 -1
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1254 -106
  62. package/dist/index.js.map +4 -4
  63. package/package.json +5 -2
  64. package/src/AppShell.mdx +66 -0
  65. package/src/AppShell.stories.tsx +117 -0
  66. package/src/AppShell.tsx +77 -0
  67. package/src/Avatar.initials.test.ts +21 -0
  68. package/src/Avatar.mdx +49 -0
  69. package/src/Avatar.tsx +12 -9
  70. package/src/AvatarGroup.mdx +39 -0
  71. package/src/AvatarGroup.stories.tsx +56 -0
  72. package/src/AvatarGroup.tsx +42 -0
  73. package/src/Banner.mdx +45 -0
  74. package/src/Banner.stories.tsx +35 -0
  75. package/src/Banner.tsx +35 -0
  76. package/src/Breadcrumb.mdx +52 -0
  77. package/src/Breadcrumb.stories.tsx +42 -0
  78. package/src/Breadcrumb.tsx +106 -0
  79. package/src/Button.mdx +57 -0
  80. package/src/Checkbox.mdx +44 -0
  81. package/src/Checkbox.stories.tsx +49 -0
  82. package/src/Checkbox.tsx +46 -0
  83. package/src/Chip.mdx +43 -0
  84. package/src/ChipInput.mdx +68 -0
  85. package/src/ChipInput.stories.tsx +99 -0
  86. package/src/ChipInput.tsx +247 -0
  87. package/src/ConfirmDialog.mdx +51 -0
  88. package/src/ConfirmDialog.stories.tsx +63 -0
  89. package/src/ConfirmDialog.tsx +55 -0
  90. package/src/DialogShell.mdx +68 -0
  91. package/src/DialogShell.tsx +12 -5
  92. package/src/Divider.mdx +41 -0
  93. package/src/EditableText.mdx +59 -0
  94. package/src/EditableText.stories.tsx +63 -0
  95. package/src/EditableText.tsx +150 -0
  96. package/src/EmptyState.mdx +41 -0
  97. package/src/Field.mdx +39 -0
  98. package/src/IconButton.mdx +47 -0
  99. package/src/IdentityMenu.mdx +62 -0
  100. package/src/IdentityMenu.stories.tsx +52 -0
  101. package/src/IdentityMenu.tsx +159 -0
  102. package/src/Menu.mdx +111 -0
  103. package/src/Menu.stories.tsx +66 -0
  104. package/src/Menu.tsx +172 -0
  105. package/src/MenuItem.mdx +55 -0
  106. package/src/MenuItem.stories.tsx +98 -0
  107. package/src/NavItem.mdx +53 -0
  108. package/src/NavItem.stories.tsx +36 -0
  109. package/src/NavItem.tsx +53 -0
  110. package/src/Person.mdx +44 -0
  111. package/src/Person.stories.tsx +43 -0
  112. package/src/Person.tsx +39 -0
  113. package/src/PersonTrigger.mdx +51 -0
  114. package/src/PersonTrigger.stories.tsx +30 -0
  115. package/src/PersonTrigger.tsx +62 -0
  116. package/src/Property.mdx +51 -0
  117. package/src/Property.stories.tsx +91 -0
  118. package/src/Property.tsx +44 -0
  119. package/src/Rail.tsx +26 -0
  120. package/src/RailItem.mdx +50 -0
  121. package/src/RailItem.stories.tsx +37 -0
  122. package/src/RailItem.tsx +53 -0
  123. package/src/SearchInput.mdx +39 -0
  124. package/src/SearchInput.stories.tsx +24 -0
  125. package/src/SearchInput.tsx +45 -0
  126. package/src/SectionHeader.mdx +52 -0
  127. package/src/SectionHeader.stories.tsx +66 -0
  128. package/src/SectionHeader.tsx +85 -0
  129. package/src/SectionLabel.mdx +42 -0
  130. package/src/SectionLabel.stories.tsx +31 -0
  131. package/src/SectionLabel.tsx +28 -0
  132. package/src/Select.fit.test.ts +97 -0
  133. package/src/Select.mdx +71 -0
  134. package/src/Select.stories.tsx +95 -0
  135. package/src/Select.tsx +123 -15
  136. package/src/Sidebar.mdx +56 -0
  137. package/src/Sidebar.stories.tsx +50 -0
  138. package/src/Sidebar.tsx +32 -0
  139. package/src/Skeleton.mdx +39 -0
  140. package/src/Tabs.mdx +53 -0
  141. package/src/Tabs.stories.tsx +91 -0
  142. package/src/Tabs.tsx +73 -0
  143. package/src/TextInput.mdx +39 -0
  144. package/src/Textarea.mdx +36 -0
  145. package/src/Toast.mdx +54 -0
  146. package/src/Toast.stories.tsx +87 -0
  147. package/src/Toast.tsx +169 -0
  148. package/src/Tooltip.mdx +51 -0
  149. package/src/Tooltip.tsx +4 -2
  150. package/src/TopBar.mdx +62 -0
  151. package/src/TopBar.stories.tsx +96 -0
  152. package/src/TopBar.tsx +57 -0
  153. package/src/cn.test.ts +27 -0
  154. package/src/cn.ts +25 -5
  155. package/src/index.ts +23 -0
  156. package/tailwind-preset.js +4 -3
  157. package/tokens.css +4 -0
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { EditableText } from './EditableText'
4
+
5
+ const meta = {
6
+ title: 'Inputs/EditableText',
7
+ component: EditableText,
8
+ args: {
9
+ value: 'Team charter',
10
+ placeholder: 'Add a title',
11
+ label: 'Title',
12
+ onCommit: () => true as boolean,
13
+ className: 'text-[16px] leading-[1.4] font-medium',
14
+ },
15
+ argTypes: { onCommit: { control: false } },
16
+ decorators: [(Story) => <div className="w-80">{Story()}</div>],
17
+ } satisfies Meta<typeof EditableText>
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+
22
+ /** Click the text: it becomes a field. Enter commits, Escape cancels, blur commits. */
23
+ export const Title: Story = {
24
+ parameters: { controls: { disable: true } },
25
+ render: (args) => {
26
+ const [value, setValue] = useState(args.value)
27
+ return (
28
+ <EditableText
29
+ {...args}
30
+ value={value}
31
+ onCommit={(next) => {
32
+ setValue(next)
33
+ return true
34
+ }}
35
+ />
36
+ )
37
+ },
38
+ }
39
+
40
+ /** Shift+Enter is a new line; Enter still commits. */
41
+ export const Multiline: Story = {
42
+ args: {
43
+ value: 'A longer piece of text.\nIt keeps its lines.',
44
+ placeholder: 'Add a description',
45
+ label: 'Description',
46
+ multiline: true,
47
+ className: 'text-[14px] leading-[1.4]',
48
+ },
49
+ }
50
+
51
+ /** Empty shows the placeholder, muted — still clickable. */
52
+ export const Empty: Story = { args: { value: '' } }
53
+
54
+ /** For a reader who cannot write: the value alone, no edit affordance. */
55
+ export const ReadOnly: Story = { args: { readOnly: true } }
56
+
57
+ /**
58
+ * A commit that resolves `false` keeps the field open with the text in it —
59
+ * an edit is never silently lost. Try committing anything here.
60
+ */
61
+ export const RefusedCommit: Story = {
62
+ args: { onCommit: () => false },
63
+ }
@@ -0,0 +1,150 @@
1
+ import { useEffect, useRef, useState, type KeyboardEvent, type RefObject } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Text you click to edit — Ship's EditableText (2026-09-01), verbatim. Its
6
+ * own comment always called it a package candidate: nothing here knows what
7
+ * is being edited.
8
+ *
9
+ * Reads as text until clicked; then it is a field. Enter commits (Shift+Enter
10
+ * is a new line when multiline), Escape cancels, blur commits. A commit that
11
+ * fails keeps the field open with the text in it, so an edit is never
12
+ * silently lost — the caller has already said why. An unchanged value is not
13
+ * committed at all. Empty shows the placeholder, muted.
14
+ */
15
+ export interface EditableTextProps {
16
+ value: string
17
+ placeholder: string
18
+ /** Resolve `true` when saved; `false` (or throw) keeps the field open with the text. */
19
+ onCommit: (value: string) => Promise<boolean> | boolean
20
+ multiline?: boolean
21
+ /** Applied to both the text and the field, so they are the same size. */
22
+ className?: string
23
+ /** The accessible name of the field — "Title", "Description". */
24
+ label: string
25
+ /**
26
+ * What to *show* when not editing, when that differs from what is edited —
27
+ * a value carrying a raw reference that the caller renders as a live object
28
+ * below the text, say.
29
+ *
30
+ * It overrides the display branch only. `value` is still what the editor
31
+ * opens with and what a commit compares against, so nothing is silently lost
32
+ * — which is the failure a naive `value={stripped}` would cause.
33
+ */
34
+ display?: string
35
+ /** Show the value only — no edit affordance. For a reader who cannot write. */
36
+ readOnly?: boolean
37
+ }
38
+
39
+ export function EditableText({ value, display, placeholder, onCommit, multiline = false, className, label, readOnly = false }: EditableTextProps) {
40
+ const [editing, setEditing] = useState(false)
41
+ const [draft, setDraft] = useState(value)
42
+ const [busy, setBusy] = useState(false)
43
+ const fieldRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
44
+
45
+ useEffect(() => {
46
+ if (editing) {
47
+ fieldRef.current?.focus()
48
+ fieldRef.current?.select()
49
+ }
50
+ }, [editing])
51
+
52
+ const open = () => {
53
+ setDraft(value)
54
+ setEditing(true)
55
+ }
56
+
57
+ const cancel = () => {
58
+ setEditing(false)
59
+ setDraft(value)
60
+ }
61
+
62
+ const commit = async () => {
63
+ if (busy) return
64
+ const next = draft.trim()
65
+ if (next === value) {
66
+ setEditing(false)
67
+ return
68
+ }
69
+ setBusy(true)
70
+ try {
71
+ const ok = await onCommit(next)
72
+ if (ok) setEditing(false)
73
+ } catch {
74
+ /* the caller has shown why; keep the field open */
75
+ } finally {
76
+ setBusy(false)
77
+ }
78
+ }
79
+
80
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
81
+ if (event.key === 'Escape') {
82
+ event.preventDefault()
83
+ cancel()
84
+ } else if (event.key === 'Enter' && !(multiline && event.shiftKey)) {
85
+ event.preventDefault()
86
+ void commit()
87
+ }
88
+ }
89
+
90
+ const fieldClass = cn(
91
+ 'w-full rounded-md border border-border-strong bg-bg-inset px-2 py-1 text-text-primary outline-none',
92
+ multiline && 'resize-none',
93
+ className,
94
+ )
95
+
96
+ if (readOnly) {
97
+ return (
98
+ <div
99
+ aria-label={label}
100
+ className={cn('w-full px-2 py-1', multiline && 'whitespace-pre-wrap', (display ?? value) ? 'text-text-primary' : 'text-text-muted', className)}
101
+ >
102
+ {(display ?? value) || placeholder}
103
+ </div>
104
+ )
105
+ }
106
+
107
+ if (editing) {
108
+ return multiline ? (
109
+ <textarea
110
+ ref={fieldRef as RefObject<HTMLTextAreaElement>}
111
+ aria-label={label}
112
+ value={draft}
113
+ rows={4}
114
+ disabled={busy}
115
+ onChange={(event) => setDraft(event.target.value)}
116
+ onKeyDown={onKeyDown}
117
+ onBlur={() => void commit()}
118
+ className={fieldClass}
119
+ />
120
+ ) : (
121
+ <input
122
+ ref={fieldRef as RefObject<HTMLInputElement>}
123
+ aria-label={label}
124
+ value={draft}
125
+ disabled={busy}
126
+ onChange={(event) => setDraft(event.target.value)}
127
+ onKeyDown={onKeyDown}
128
+ onBlur={() => void commit()}
129
+ className={fieldClass}
130
+ />
131
+ )
132
+ }
133
+
134
+ return (
135
+ <button
136
+ type="button"
137
+ onClick={open}
138
+ title="Click to edit"
139
+ aria-label={`Edit ${label.toLowerCase()}`}
140
+ className={cn(
141
+ 'w-full rounded-md border border-transparent px-2 py-1 text-left transition-colors hover:border-border-default',
142
+ multiline && 'whitespace-pre-wrap',
143
+ (display ?? value) ? 'text-text-primary' : 'text-text-muted',
144
+ className,
145
+ )}
146
+ >
147
+ {(display ?? value) || placeholder}
148
+ </button>
149
+ )
150
+ }
@@ -0,0 +1,41 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as EmptyStateStories from './EmptyState.stories'
3
+
4
+ <Meta of={EmptyStateStories} />
5
+
6
+ # EmptyState
7
+
8
+ A 16px icon over a centred line of secondary text, for the place where
9
+ content will be once there is some.
10
+
11
+ <Canvas of={EmptyStateStories.Default} />
12
+
13
+ ## When
14
+
15
+ - A list, panel or view with nothing in it yet. The message says **what
16
+ would fill it** — "No comments yet. Start the thread." — not just that
17
+ it is empty.
18
+ - `icon` swaps the default speech bubble for one that matches the
19
+ subject.
20
+
21
+ ## When not
22
+
23
+ - Data is still on its way → **Skeleton**; empty and loading are
24
+ different truths.
25
+ - Something went wrong → say that, in your surface's own error treatment
26
+ — an empty state that hides a failure misleads.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { EmptyState } from '@estiva-app/ui'
32
+
33
+ <EmptyState message="No documents yet. Create the first one." />
34
+ ```
35
+
36
+ The message is the caller's — a shared component has no words of its own
37
+ for what is missing.
38
+
39
+ ## Props
40
+
41
+ <Controls of={EmptyStateStories.Default} />
package/src/Field.mdx ADDED
@@ -0,0 +1,39 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as FieldStories from './Field.stories'
3
+
4
+ <Meta of={FieldStories} />
5
+
6
+ # Field
7
+
8
+ A label over a control — the form row. The label is the `input-label`
9
+ token; `required` marks it with the error-coloured asterisk.
10
+
11
+ <Canvas of={FieldStories.Required} />
12
+
13
+ ## When
14
+
15
+ - Any labelled control in a form or dialog: a TextInput, a Textarea, a
16
+ Select, a ChipInput.
17
+
18
+ ## When not
19
+
20
+ - Displaying a label–value pair → **Property**; Field is for editing.
21
+ - It carries no helper or error text of its own — validation display is
22
+ the caller's, under the control.
23
+
24
+ ## How
25
+
26
+ ```tsx
27
+ import { Field, TextInput } from '@estiva-app/ui'
28
+
29
+ <Field label="Name" required>
30
+ <TextInput value={name} onChange={(e) => setName(e.target.value)} />
31
+ </Field>
32
+ ```
33
+
34
+ The label is visual, not wired: also give the control its accessible name
35
+ (`aria-label`, or an `id`/`htmlFor` pair of your own) so the two agree.
36
+
37
+ ## Props
38
+
39
+ <Controls of={FieldStories.Default} />
@@ -0,0 +1,47 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as IconButtonStories from './IconButton.stories'
3
+
4
+ <Meta of={IconButtonStories} />
5
+
6
+ # IconButton
7
+
8
+ A square button around a 16px icon: 4px padding, 8px radius, three
9
+ variants. For the actions a surface repeats too often to label every time.
10
+
11
+ <Canvas of={IconButtonStories.AllVariants} />
12
+
13
+ ## When
14
+
15
+ - Toolbars and row-hover actions — edit, copy, dismiss, more.
16
+ - A dialog's close.
17
+ - `tooltip` names it on hover; `primary` exists for the rare icon action
18
+ that is the point of its surface.
19
+
20
+ ## When not
21
+
22
+ - The action deserves a visible label → **Button** (words are cheaper than
23
+ a guessed icon).
24
+ - Inside a menu → **MenuItem** with `leading`.
25
+ - A strip of these is a toolbar — right; but the moment rows toggle and
26
+ persist state, see **Checkbox**.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { IconButton } from '@estiva-app/ui'
32
+ import { IconPencil } from '@tabler/icons-react'
33
+
34
+ <IconButton aria-label="Edit" tooltip="Edit" onClick={edit}>
35
+ <IconPencil size={16} stroke={1.5} />
36
+ </IconButton>
37
+ ```
38
+
39
+ - **`aria-label` is required** — nothing visible names an icon-only
40
+ control. The `tooltip` shows sighted hover users the same word; it does
41
+ not replace the label.
42
+ - The icon is 16px at stroke 1.5.
43
+ - `type` defaults to `"button"`; every native prop passes through.
44
+
45
+ ## Props
46
+
47
+ <Controls of={IconButtonStories.Muted} />
@@ -0,0 +1,62 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as IdentityMenuStories from './IdentityMenu.stories'
3
+
4
+ <Meta of={IdentityMenuStories} />
5
+
6
+ # IdentityMenu
7
+
8
+ Who you are, and where you are: the identity trigger for a top bar, and
9
+ the menu it opens — ready-made, because every app needs one and it is
10
+ built of nothing but shared parts.
11
+
12
+ <Canvas of={IdentityMenuStories.SignedIn} />
13
+
14
+ ## When
15
+
16
+ - The account control of any app. The trigger is a face and a name —
17
+ never a key — or, `compact`, the face alone at 36px.
18
+ - Anonymous is the silhouette and the word "Anonymous": the truth of the
19
+ situation rather than hex posing as a name.
20
+ - **Every section hides when the app cannot fill it** — no `relayUrl`, no
21
+ workspace section; no `onCopyKey`, no copy item; no `idBase`, no
22
+ sign-in surface. Only offer actions that can succeed. The MinimalApp
23
+ story is the floor: identity alone.
24
+
25
+ <Canvas of={IdentityMenuStories.MinimalApp} />
26
+
27
+ ## When not
28
+
29
+ - Any other menu → **Menu**.
30
+ - Showing a person who is not the viewer → **Person**.
31
+ - A trigger that opens something of your own → **PersonTrigger** plus
32
+ your menu.
33
+
34
+ ## How
35
+
36
+ ```tsx
37
+ import { IdentityMenu } from '@estiva-app/ui'
38
+
39
+ <IdentityMenu
40
+ me={{ name: me.name, picture: me.picture, email: me.email }}
41
+ signedIn={signedIn}
42
+ relayUrl={relayUrl}
43
+ idBase={ID_BASE}
44
+ onSignOut={signOut}
45
+ />
46
+ ```
47
+
48
+ - App-specific rows go through `children`, drawn as their own group
49
+ between the workspace section and the actions. Pass a function to
50
+ receive `close`, so a row can shut the menu before opening what it
51
+ opens.
52
+ - Items are text only — no icons in this menu, by ruling.
53
+ - **IdentityPanel** is exported alone for a surface that wants the panel
54
+ without the trigger; the stories render it, so the designed artifact is
55
+ what you review.
56
+ - The trigger's toggle, `aria-expanded`, and the accessible-name rules
57
+ (the row is named by the person; only the bare compact face takes an
58
+ `aria-label`) are wired inside — bring nothing.
59
+
60
+ ## Props
61
+
62
+ <Controls of={IdentityMenuStories.SignedIn} />
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IdentityPanel } from './IdentityMenu'
3
+
4
+ /**
5
+ * The menu itself, standing in flow (`static` beats the anchoring, as the
6
+ * Menu stories do it) — no trigger, no bar chrome: the trigger's shapes are
7
+ * PersonTrigger's own stories. In an app, `IdentityMenu` bundles that
8
+ * trigger with this panel.
9
+ */
10
+ const meta = {
11
+ title: 'Navigation/IdentityMenu',
12
+ component: IdentityPanel,
13
+ args: {
14
+ me: {},
15
+ signedIn: false,
16
+ relayUrl: 'http://localhost:3000',
17
+ onCopyKey: () => {},
18
+ onSignOut: () => {},
19
+ onClose: () => {},
20
+ className: 'static',
21
+ },
22
+ argTypes: { onClose: { control: false }, className: { control: false } },
23
+ } satisfies Meta<typeof IdentityPanel>
24
+
25
+ export default meta
26
+ type Story = StoryObj<typeof meta>
27
+
28
+ /** A browser-held key: silhouette, "Acting as", and the honest sentence. */
29
+ export const Anonymous: Story = {}
30
+
31
+ /** Signed in through Estiva ID, in a build that offers sign-in. */
32
+ export const SignedIn: Story = {
33
+ args: {
34
+ me: { name: 'Ana Duarte', email: 'ana@example.com' },
35
+ signedIn: true,
36
+ idBase: 'https://id.estiva.app',
37
+ },
38
+ }
39
+
40
+ /**
41
+ * An app that cannot name a relay or supply a key: those pieces simply are
42
+ * not there — only offer actions that can succeed.
43
+ */
44
+ export const MinimalApp: Story = {
45
+ args: {
46
+ me: { name: 'Ana Duarte' },
47
+ signedIn: true,
48
+ idBase: 'https://id.estiva.app',
49
+ relayUrl: undefined,
50
+ onCopyKey: undefined,
51
+ },
52
+ }
@@ -0,0 +1,159 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { Divider } from './Divider'
4
+ import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
5
+ import { Person } from './Person'
6
+ import { PersonTrigger } from './PersonTrigger'
7
+
8
+ /**
9
+ * Who you are, and where you are — the top bar's identity trigger and the
10
+ * menu it opens. Ship's IdentityMenu (2026-09-01), moved in whole: every app
11
+ * needs one, and this one is already made of nothing but shared parts.
12
+ *
13
+ * The trigger is a face and a name, never a key (Ship's ruling A4) — or, with
14
+ * `compact`, the face alone, as Peek's top bar draws it. Anonymous is a
15
+ * silhouette and the word "Anonymous": the truth of the situation rather than
16
+ * eight hex characters posing as a name.
17
+ *
18
+ * The menu keeps the two honest sentences, and the workspace line names the
19
+ * relay because on this substrate the relay *is* the workspace. Every section
20
+ * hides when the app cannot fill it: no `relayUrl`, no workspace section; no
21
+ * `onCopyKey`, no copy item — only offer actions that can succeed. The one
22
+ * place a key appears is as the *result* of "Copy public key" — this
23
+ * component never receives the key, so it cannot show one; the caller
24
+ * copies. Items are text only, no icons (her ruling).
25
+ */
26
+ export interface Identity {
27
+ /** From `kind:0`. Absent means anonymous. */
28
+ name?: string
29
+ picture?: string
30
+ /** Known to Estiva ID, never published to the relay. */
31
+ email?: string
32
+ }
33
+
34
+ export interface IdentityMenuProps {
35
+ me: Identity
36
+ /** Signed in through Estiva ID, as opposed to a browser-held key. */
37
+ signedIn: boolean
38
+ /** Names the workspace; absent hides the workspace section. */
39
+ relayUrl?: string
40
+ /** Estiva ID's base URL when this build offers sign-in; absent = anonymous-only build. */
41
+ idBase?: string
42
+ /** "Copy public key" appears only when the app can supply one. */
43
+ onCopyKey?: () => void
44
+ onSignOut?: () => void
45
+ /** Face-only trigger, 36px — no chevron, no name beside it. */
46
+ compact?: boolean
47
+ className?: string
48
+ /**
49
+ * App-specific rows, drawn as their own group between the workspace
50
+ * section and the actions. A function receives `close`, so a row can
51
+ * shut the menu before opening what it opens.
52
+ */
53
+ children?: ReactNode | ((close: () => void) => ReactNode)
54
+ }
55
+
56
+ export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' | 'className'> {
57
+ onClose: () => void
58
+ /** On the Menu surface — the stories pass `static` to stand it in flow. */
59
+ className?: string
60
+ }
61
+
62
+ /** The menu alone — what `IdentityMenu` opens. Exported so the stories show
63
+ * the designed artifact rather than a closed trigger, and for any surface
64
+ * that wants the panel without the trigger. */
65
+ export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, className, children }: IdentityPanelProps) {
66
+ const act = (action: () => void) => () => {
67
+ onClose()
68
+ action()
69
+ }
70
+ const appRows = typeof children === 'function' ? children(onClose) : children
71
+ return (
72
+ <Menu onClose={onClose} className={cn('w-72', className)}>
73
+ <MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
74
+ <MenuRow>
75
+ <Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} className="text-body-2-strong" />
76
+ </MenuRow>
77
+ <Note>
78
+ {signedIn
79
+ ? 'Your Estiva ID. The same person you are in every Estiva app.'
80
+ : 'A key held by this browser only. Nobody else knows who you are.'}
81
+ </Note>
82
+ {me.email && <Note>{me.email} · known to Estiva, never published to the relay</Note>}
83
+ </MenuSection>
84
+
85
+ {relayUrl && (
86
+ <>
87
+ <Divider className="mx-0 my-2" />
88
+ <MenuSection label="Workspace">
89
+ <MenuRow>
90
+ <span className="break-all font-mono text-caption text-text-primary">{relayUrl}</span>
91
+ </MenuRow>
92
+ <Note>Everyone on this relay shares this workspace, in every app.</Note>
93
+ </MenuSection>
94
+ </>
95
+ )}
96
+
97
+ {appRows && (
98
+ <>
99
+ <Divider className="mx-0 my-2" />
100
+ {appRows}
101
+ </>
102
+ )}
103
+
104
+ {(signedIn && idBase) || onCopyKey || (idBase && onSignOut) ? <Divider className="mx-0 my-2" /> : null}
105
+
106
+ {signedIn && idBase && (
107
+ <MenuItem
108
+ label="Edit your profile in Estiva ID"
109
+ onClick={act(() => window.open(idBase, '_blank', 'noopener,noreferrer'))}
110
+ />
111
+ )}
112
+ {onCopyKey && <MenuItem label="Copy public key" onClick={act(onCopyKey)} />}
113
+ {idBase && onSignOut && <MenuItem label="Sign out" onClick={act(onSignOut)} />}
114
+ </Menu>
115
+ )
116
+ }
117
+
118
+ export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }: IdentityMenuProps) {
119
+ const [open, setOpen] = useState(false)
120
+
121
+ return (
122
+ <div className={cn('relative', className)}>
123
+ <PersonTrigger
124
+ name={me.name}
125
+ picture={me.picture}
126
+ fallback="Anonymous"
127
+ compact={compact}
128
+ size={compact ? 36 : undefined}
129
+ open={open}
130
+ // Swallowed so the menu's outside-mousedown dismiss does not fire
131
+ // first and turn the toggle into a close-then-reopen flicker.
132
+ onMouseDown={(event) => event.stopPropagation()}
133
+ onClick={() => setOpen((value) => !value)}
134
+ // The row shape is named by its own text — the person. Only the bare
135
+ // face needs a label; naming the row would override the person's name
136
+ // as the accessible name (Ship's tests find the trigger by it).
137
+ aria-label={compact ? 'Account menu' : undefined}
138
+ />
139
+
140
+ {open && (
141
+ <IdentityPanel
142
+ me={me}
143
+ signedIn={signedIn}
144
+ relayUrl={relayUrl}
145
+ idBase={idBase}
146
+ onCopyKey={onCopyKey}
147
+ onSignOut={onSignOut}
148
+ onClose={() => setOpen(false)}
149
+ >
150
+ {children}
151
+ </IdentityPanel>
152
+ )}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ function Note({ children }: { children: ReactNode }) {
158
+ return <span className="px-2 pb-1.5 text-caption text-text-secondary">{children}</span>
159
+ }