@adecore/ui 0.14.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 (318) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/AccentSwatches.d.ts +15 -0
  4. package/dist/AccentSwatches.js +21 -0
  5. package/dist/Banner.d.ts +12 -0
  6. package/dist/Banner.js +18 -0
  7. package/dist/Button.d.ts +9 -0
  8. package/dist/Button.js +28 -0
  9. package/dist/ButtonGroup.d.ts +3 -0
  10. package/dist/ButtonGroup.js +11 -0
  11. package/dist/Checkbox.d.ts +11 -0
  12. package/dist/Checkbox.js +9 -0
  13. package/dist/ChoiceCards.d.ts +23 -0
  14. package/dist/ChoiceCards.js +21 -0
  15. package/dist/CloseButton.d.ts +15 -0
  16. package/dist/CloseButton.js +11 -0
  17. package/dist/ColorSwatch.d.ts +14 -0
  18. package/dist/ColorSwatch.js +28 -0
  19. package/dist/ColumnResizeHandle.d.ts +9 -0
  20. package/dist/ColumnResizeHandle.js +12 -0
  21. package/dist/DisabledReason.d.ts +6 -0
  22. package/dist/DisabledReason.js +8 -0
  23. package/dist/DockShell.d.ts +9 -0
  24. package/dist/DockShell.js +49 -0
  25. package/dist/EmptyState.d.ts +16 -0
  26. package/dist/EmptyState.js +11 -0
  27. package/dist/ErrorBoundary.d.ts +24 -0
  28. package/dist/ErrorBoundary.js +47 -0
  29. package/dist/Field.d.ts +17 -0
  30. package/dist/Field.js +48 -0
  31. package/dist/FileIcon.d.ts +8 -0
  32. package/dist/FileIcon.js +15 -0
  33. package/dist/Icon.d.ts +9 -0
  34. package/dist/Icon.js +12 -0
  35. package/dist/IconButton.d.ts +23 -0
  36. package/dist/IconButton.js +25 -0
  37. package/dist/IconPicker.d.ts +17 -0
  38. package/dist/IconPicker.js +93 -0
  39. package/dist/Input.d.ts +14 -0
  40. package/dist/Input.js +25 -0
  41. package/dist/Kbd.d.ts +23 -0
  42. package/dist/Kbd.js +20 -0
  43. package/dist/ListRow.d.ts +10 -0
  44. package/dist/ListRow.js +20 -0
  45. package/dist/Meter.d.ts +12 -0
  46. package/dist/Meter.js +7 -0
  47. package/dist/PanelEmpty.d.ts +16 -0
  48. package/dist/PanelEmpty.js +7 -0
  49. package/dist/PanelHeader.d.ts +6 -0
  50. package/dist/PanelHeader.js +20 -0
  51. package/dist/Pill.d.ts +27 -0
  52. package/dist/Pill.js +25 -0
  53. package/dist/ProjectSwitcher.d.ts +30 -0
  54. package/dist/ProjectSwitcher.js +18 -0
  55. package/dist/PromptDialog.d.ts +40 -0
  56. package/dist/PromptDialog.js +47 -0
  57. package/dist/SectionLabel.d.ts +3 -0
  58. package/dist/SectionLabel.js +16 -0
  59. package/dist/SegmentBar.d.ts +18 -0
  60. package/dist/SegmentBar.js +44 -0
  61. package/dist/Segmented.d.ts +17 -0
  62. package/dist/Segmented.js +7 -0
  63. package/dist/Select.d.ts +27 -0
  64. package/dist/Select.js +34 -0
  65. package/dist/Separator.d.ts +7 -0
  66. package/dist/Separator.js +8 -0
  67. package/dist/ShortcutHints.d.ts +1 -0
  68. package/dist/ShortcutHints.js +94 -0
  69. package/dist/Skeleton.d.ts +6 -0
  70. package/dist/Skeleton.js +6 -0
  71. package/dist/SlidingColumn.d.ts +19 -0
  72. package/dist/SlidingColumn.js +41 -0
  73. package/dist/Spinner.d.ts +6 -0
  74. package/dist/Spinner.js +7 -0
  75. package/dist/Stepper.d.ts +14 -0
  76. package/dist/Stepper.js +18 -0
  77. package/dist/Surface.d.ts +3 -0
  78. package/dist/Surface.js +16 -0
  79. package/dist/Switch.d.ts +10 -0
  80. package/dist/Switch.js +7 -0
  81. package/dist/TextMenu.d.ts +5 -0
  82. package/dist/TextMenu.js +31 -0
  83. package/dist/Tile.d.ts +11 -0
  84. package/dist/Tile.js +10 -0
  85. package/dist/Toasts.d.ts +9 -0
  86. package/dist/Toasts.js +58 -0
  87. package/dist/Tooltip.d.ts +16 -0
  88. package/dist/Tooltip.js +21 -0
  89. package/dist/UIProvider.d.ts +9 -0
  90. package/dist/UIProvider.js +26 -0
  91. package/dist/Waveform.d.ts +16 -0
  92. package/dist/Waveform.js +123 -0
  93. package/dist/Wipe.d.ts +12 -0
  94. package/dist/Wipe.js +47 -0
  95. package/dist/ZoomControls.d.ts +30 -0
  96. package/dist/ZoomControls.js +16 -0
  97. package/dist/class-name.d.ts +2 -0
  98. package/dist/class-name.js +3 -0
  99. package/dist/clipboard.d.ts +2 -0
  100. package/dist/clipboard.js +16 -0
  101. package/dist/context-menu/index.parts.d.ts +1 -0
  102. package/dist/context-menu/index.parts.js +2 -0
  103. package/dist/dialog/index.parts.d.ts +1 -0
  104. package/dist/dialog/index.parts.js +1 -0
  105. package/dist/dialog/parts.d.ts +36 -0
  106. package/dist/dialog/parts.js +55 -0
  107. package/dist/dialog-layer.d.ts +1 -0
  108. package/dist/dialog-layer.js +27 -0
  109. package/dist/error-boundary.d.ts +4 -0
  110. package/dist/error-boundary.js +34 -0
  111. package/dist/error-message.d.ts +1 -0
  112. package/dist/error-message.js +4 -0
  113. package/dist/field-context.d.ts +7 -0
  114. package/dist/field-context.js +4 -0
  115. package/dist/file-icon.d.ts +4 -0
  116. package/dist/file-icon.js +46 -0
  117. package/dist/floating.d.ts +5 -0
  118. package/dist/floating.js +6 -0
  119. package/dist/format/datetime.d.ts +11 -0
  120. package/dist/format/datetime.js +50 -0
  121. package/dist/format/duration.d.ts +5 -0
  122. package/dist/format/duration.js +76 -0
  123. package/dist/format/index.d.ts +6 -0
  124. package/dist/format/index.js +7 -0
  125. package/dist/format/locale.d.ts +17 -0
  126. package/dist/format/locale.js +105 -0
  127. package/dist/format/number.d.ts +8 -0
  128. package/dist/format/number.js +72 -0
  129. package/dist/format/regions.d.ts +6 -0
  130. package/dist/format/regions.js +15 -0
  131. package/dist/format/time-zone.d.ts +1 -0
  132. package/dist/format/time-zone.js +2 -0
  133. package/dist/icon-button-size.d.ts +3 -0
  134. package/dist/icon-button-size.js +3 -0
  135. package/dist/icon-picker.d.ts +23 -0
  136. package/dist/icon-picker.js +80 -0
  137. package/dist/index.d.ts +74 -0
  138. package/dist/index.js +74 -0
  139. package/dist/lazy.d.ts +12 -0
  140. package/dist/lazy.js +104 -0
  141. package/dist/locales/en.json +59 -0
  142. package/dist/locales/nl.json +59 -0
  143. package/dist/locales.d.ts +4 -0
  144. package/dist/locales.js +17 -0
  145. package/dist/menu/index.parts.d.ts +1 -0
  146. package/dist/menu/index.parts.js +1 -0
  147. package/dist/menu/parts.d.ts +67 -0
  148. package/dist/menu/parts.js +113 -0
  149. package/dist/merge-refs.d.ts +2 -0
  150. package/dist/merge-refs.js +11 -0
  151. package/dist/modality.d.ts +1 -0
  152. package/dist/modality.js +29 -0
  153. package/dist/platform.d.ts +2 -0
  154. package/dist/platform.js +9 -0
  155. package/dist/popover/index.parts.d.ts +1 -0
  156. package/dist/popover/index.parts.js +1 -0
  157. package/dist/popover/parts.d.ts +26 -0
  158. package/dist/popover/parts.js +28 -0
  159. package/dist/prefetch.d.ts +21 -0
  160. package/dist/prefetch.js +99 -0
  161. package/dist/preview-card/index.parts.d.ts +1 -0
  162. package/dist/preview-card/index.parts.js +1 -0
  163. package/dist/project-switcher-row.d.ts +6 -0
  164. package/dist/project-switcher-row.js +16 -0
  165. package/dist/selection.d.ts +2 -0
  166. package/dist/selection.js +22 -0
  167. package/dist/seti-icons.d.ts +5 -0
  168. package/dist/seti-icons.js +445 -0
  169. package/dist/settings/ConfirmDialog.d.ts +9 -0
  170. package/dist/settings/ConfirmDialog.js +9 -0
  171. package/dist/settings/DetailHeader.d.ts +10 -0
  172. package/dist/settings/DetailHeader.js +6 -0
  173. package/dist/settings/MasterDetail.d.ts +24 -0
  174. package/dist/settings/MasterDetail.js +16 -0
  175. package/dist/settings/SettingsDialog.d.ts +46 -0
  176. package/dist/settings/SettingsDialog.js +96 -0
  177. package/dist/settings/SettingsRow.d.ts +22 -0
  178. package/dist/settings/SettingsRow.js +30 -0
  179. package/dist/settings/SettingsSection.d.ts +14 -0
  180. package/dist/settings/SettingsSection.js +8 -0
  181. package/dist/settings/index.d.ts +7 -0
  182. package/dist/settings/index.js +8 -0
  183. package/dist/settings/target.d.ts +7 -0
  184. package/dist/settings/target.js +3 -0
  185. package/dist/shortcut-hints.d.ts +38 -0
  186. package/dist/shortcut-hints.js +81 -0
  187. package/dist/shortcut.d.ts +32 -0
  188. package/dist/shortcut.js +137 -0
  189. package/dist/testing/dedupe.d.ts +1 -0
  190. package/dist/testing/dedupe.js +73 -0
  191. package/dist/testing/fake-source.d.ts +8 -0
  192. package/dist/testing/fake-source.js +15 -0
  193. package/dist/testing/index.d.ts +1 -0
  194. package/dist/testing/index.js +2 -0
  195. package/dist/theme.css +562 -0
  196. package/dist/toast-store.d.ts +38 -0
  197. package/dist/toast-store.js +61 -0
  198. package/dist/useAsyncAction.d.ts +8 -0
  199. package/dist/useAsyncAction.js +23 -0
  200. package/dist/useColumnResize.d.ts +14 -0
  201. package/dist/useColumnResize.js +49 -0
  202. package/dist/useContentSize.d.ts +5 -0
  203. package/dist/useContentSize.js +21 -0
  204. package/dist/useMeasuredWidth.d.ts +1 -0
  205. package/dist/useMeasuredWidth.js +6 -0
  206. package/dist/useNow.d.ts +3 -0
  207. package/dist/useNow.js +41 -0
  208. package/dist/wipe-split.d.ts +7 -0
  209. package/dist/wipe-split.js +20 -0
  210. package/dist/zoom.d.ts +3 -0
  211. package/dist/zoom.js +6 -0
  212. package/package.json +92 -0
  213. package/src/AccentSwatches.tsx +73 -0
  214. package/src/Banner.tsx +43 -0
  215. package/src/Button.tsx +55 -0
  216. package/src/ButtonGroup.tsx +14 -0
  217. package/src/Checkbox.tsx +44 -0
  218. package/src/ChoiceCards.tsx +108 -0
  219. package/src/CloseButton.tsx +21 -0
  220. package/src/ColorSwatch.tsx +41 -0
  221. package/src/ColumnResizeHandle.tsx +23 -0
  222. package/src/DisabledReason.tsx +23 -0
  223. package/src/DockShell.tsx +76 -0
  224. package/src/EmptyState.tsx +48 -0
  225. package/src/ErrorBoundary.tsx +116 -0
  226. package/src/Field.tsx +116 -0
  227. package/src/FileIcon.tsx +27 -0
  228. package/src/Icon.tsx +22 -0
  229. package/src/IconButton.tsx +84 -0
  230. package/src/IconPicker.tsx +257 -0
  231. package/src/Input.tsx +78 -0
  232. package/src/Kbd.tsx +67 -0
  233. package/src/ListRow.tsx +26 -0
  234. package/src/Meter.tsx +41 -0
  235. package/src/PanelEmpty.tsx +37 -0
  236. package/src/PanelHeader.tsx +31 -0
  237. package/src/Pill.tsx +61 -0
  238. package/src/ProjectSwitcher.tsx +127 -0
  239. package/src/PromptDialog.tsx +168 -0
  240. package/src/SectionLabel.tsx +20 -0
  241. package/src/SegmentBar.tsx +128 -0
  242. package/src/Segmented.tsx +51 -0
  243. package/src/Select.tsx +135 -0
  244. package/src/Separator.tsx +15 -0
  245. package/src/ShortcutHints.tsx +118 -0
  246. package/src/Skeleton.tsx +13 -0
  247. package/src/SlidingColumn.tsx +91 -0
  248. package/src/Spinner.tsx +32 -0
  249. package/src/Stepper.tsx +67 -0
  250. package/src/Surface.tsx +20 -0
  251. package/src/Switch.tsx +32 -0
  252. package/src/TextMenu.tsx +59 -0
  253. package/src/Tile.tsx +49 -0
  254. package/src/Toasts.tsx +151 -0
  255. package/src/Tooltip.tsx +70 -0
  256. package/src/UIProvider.tsx +41 -0
  257. package/src/Waveform.tsx +199 -0
  258. package/src/Wipe.tsx +99 -0
  259. package/src/ZoomControls.tsx +99 -0
  260. package/src/class-name.ts +8 -0
  261. package/src/clipboard.ts +17 -0
  262. package/src/context-menu/index.parts.ts +21 -0
  263. package/src/dialog/index.parts.ts +10 -0
  264. package/src/dialog/parts.tsx +112 -0
  265. package/src/dialog-layer.ts +31 -0
  266. package/src/error-boundary.ts +40 -0
  267. package/src/error-message.ts +5 -0
  268. package/src/field-context.ts +12 -0
  269. package/src/file-icon.ts +52 -0
  270. package/src/floating.ts +9 -0
  271. package/src/format/datetime.ts +63 -0
  272. package/src/format/duration.ts +83 -0
  273. package/src/format/index.ts +20 -0
  274. package/src/format/locale.ts +153 -0
  275. package/src/format/number.ts +87 -0
  276. package/src/format/regions.ts +20 -0
  277. package/src/format/time-zone.ts +2 -0
  278. package/src/icon-button-size.ts +6 -0
  279. package/src/icon-picker.ts +112 -0
  280. package/src/index.ts +103 -0
  281. package/src/lazy.tsx +124 -0
  282. package/src/locales/en.json +59 -0
  283. package/src/locales/nl.json +59 -0
  284. package/src/locales.ts +21 -0
  285. package/src/menu/index.parts.ts +20 -0
  286. package/src/menu/parts.tsx +296 -0
  287. package/src/merge-refs.ts +14 -0
  288. package/src/modality.ts +37 -0
  289. package/src/platform.ts +10 -0
  290. package/src/popover/index.parts.ts +8 -0
  291. package/src/popover/parts.tsx +119 -0
  292. package/src/prefetch.ts +113 -0
  293. package/src/preview-card/index.parts.ts +1 -0
  294. package/src/project-switcher-row.tsx +51 -0
  295. package/src/selection.ts +24 -0
  296. package/src/seti-icons.ts +451 -0
  297. package/src/settings/ConfirmDialog.tsx +30 -0
  298. package/src/settings/DetailHeader.tsx +25 -0
  299. package/src/settings/MasterDetail.tsx +67 -0
  300. package/src/settings/SettingsDialog.tsx +342 -0
  301. package/src/settings/SettingsRow.tsx +89 -0
  302. package/src/settings/SettingsSection.tsx +46 -0
  303. package/src/settings/index.ts +16 -0
  304. package/src/settings/target.ts +12 -0
  305. package/src/shortcut-hints.ts +117 -0
  306. package/src/shortcut.ts +170 -0
  307. package/src/testing/dedupe.ts +93 -0
  308. package/src/testing/fake-source.ts +21 -0
  309. package/src/testing/index.ts +3 -0
  310. package/src/theme.css +562 -0
  311. package/src/toast-store.ts +120 -0
  312. package/src/useAsyncAction.ts +32 -0
  313. package/src/useColumnResize.ts +75 -0
  314. package/src/useContentSize.ts +27 -0
  315. package/src/useMeasuredWidth.ts +7 -0
  316. package/src/useNow.ts +49 -0
  317. package/src/wipe-split.ts +24 -0
  318. package/src/zoom.ts +9 -0
package/src/theme.css ADDED
@@ -0,0 +1,562 @@
1
+ /* The tokens, type scale and shared rules of @adecore/ui. An app imports this right after Tailwind and adds its own tokens after it. */
2
+
3
+ /* dark: follows the app theme, not the OS. */
4
+ @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
5
+
6
+ /* Semantic tokens. Components only ever reference these, never a raw color. */
7
+ :root, [data-theme="light"] {
8
+ color-scheme: light;
9
+ --bg: #f4f4f5;
10
+ --surface: #ffffff;
11
+ --surface-raised: #ffffff;
12
+ --surface-sunken: #ececef;
13
+ /* A bar that is not the one in focus: between the sunken page behind it and a focused bar, so it
14
+ still reads as a bar. The toolbar of a cell in the grid that does not have the keyboard. */
15
+ --surface-idle: color-mix(in srgb, var(--surface) 50%, var(--surface-sunken));
16
+ /* The step under the pointer: one shade off the ground a row or a button sits on, and lighter
17
+ than the pressed step, so hover reads as a lift and pressed as the heavier state. A white
18
+ popup cannot go lighter, so in this theme the step is the softest gray there is. */
19
+ --surface-hover: #f3f3f5;
20
+ /* The pressed step of a toggle. It cannot be `--surface-raised`, which equals `--surface` in the
21
+ light theme, and it has to sit a shade past `--surface-hover` so pressed outranks hover. */
22
+ --surface-active: #dcdce2;
23
+ /* Borders carry an alpha rather than a gray of their own, so a line takes the color of whatever
24
+ it is drawn over instead of fighting it. A gray hairline over a tinted surface reads as a
25
+ smudge; the same line in black at a low alpha stays a line. Everything with a surface of its
26
+ own clips its background to the padding box (see `@layer base`), or the border would blend
27
+ with that surface and never with the ground behind it. */
28
+ --border: rgb(0 0 0 / 0.11);
29
+ --border-strong: rgb(0 0 0 / 0.2);
30
+ /* A hairline that stays out of the way: on a sunken field a full `--border` draws a box. */
31
+ --border-soft: rgb(0 0 0 / 0.06);
32
+ --text: #18181b;
33
+ --text-muted: #6f6f78;
34
+ --text-faint: #a1a1aa;
35
+ /* A neutral default. An app sets its own accent, in both channels, in a rule after this file. */
36
+ --accent: #155dfc;
37
+ /* The same accent as channels, for the tokens that carry it with an alpha. */
38
+ --accent-rgb: 21 93 252;
39
+ --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--surface));
40
+ --accent-text: #ffffff;
41
+ --selection: rgb(var(--accent-rgb) / 0.55);
42
+ --float-shadow:
43
+ 0 1px 2px -1px rgb(0 0 0 / 0.08),
44
+ 0 3px 6px -2px rgb(0 0 0 / 0.07),
45
+ 0 8px 16px -8px rgb(0 0 0 / 0.1),
46
+ 0 16px 28px -14px rgb(0 0 0 / 0.1);
47
+ /* The small lift of a thumb or a picked segment off the track it sits in: close to the surface
48
+ rather than a haze around it. Not named `--shadow-*`, since the theme maps that name onto this. */
49
+ --raised-shadow:
50
+ 0 1px 2px rgb(0 0 0 / 0.05),
51
+ 0 2px 4px -1px rgb(0 0 0 / 0.05),
52
+ 0 6px 12px -6px rgb(0 0 0 / 0.08);
53
+ --status-running: #2563eb;
54
+ --status-needs-you: #d97706;
55
+ --status-idle: #16a34a;
56
+ --status-error: #dc2626;
57
+ --positive: #15803d;
58
+ --positive-text: #ffffff;
59
+ /* A control drawn over a picture rather than over the interface, the handle of a wipe, so it
60
+ stays the same in both themes. */
61
+ --media-handle: #ffffff;
62
+ --media-handle-text: #18181b;
63
+ --media-handle-edge: 0 0 0 1px rgb(0 0 0 / 0.3);
64
+ --media-handle-shadow: 0 2px 12px rgb(0 0 0 / 0.35);
65
+ /* The file type palette. A file icon is the one thing in the interface that is not tinted by the
66
+ theme: TypeScript is blue and Vue is green wherever the file shows up, which is what makes a
67
+ row readable before the name is. The sprite of the file icons reads these, inside a tree's
68
+ shadow root as well as beside it, so both stay the same color. */
69
+ --file-icon-gray: #84848a;
70
+ --file-icon-red: #d52c36;
71
+ --file-icon-vermilion: #ff8c5b;
72
+ --file-icon-orange: #d47628;
73
+ --file-icon-yellow: #d5a910;
74
+ --file-icon-green: #199f43;
75
+ --file-icon-teal: #17a5af;
76
+ --file-icon-cyan: #1ca1c7;
77
+ --file-icon-blue: #1a85d4;
78
+ --file-icon-indigo: #693acf;
79
+ --file-icon-purple: #a631be;
80
+ --file-icon-pink: #d32a61;
81
+ --file-icon-mauve: #594c5b;
82
+ }
83
+
84
+ /* The theme attribute sits on `<html>`, the element the light rule above lands on too, so a token
85
+ derived there with `var()` resolves against these values, and a value that does not change
86
+ between themes is not repeated here. */
87
+ [data-theme="dark"] {
88
+ color-scheme: dark;
89
+ --bg: #0d0d10;
90
+ --surface: #131316;
91
+ --surface-raised: #18181c;
92
+ --surface-sunken: #08080a;
93
+ --surface-hover: #202024;
94
+ --surface-active: #28282e;
95
+ /* White at a low alpha, which is what gives a dark surface its lifted edge. */
96
+ --border: rgb(255 255 255 / 0.07);
97
+ --border-strong: rgb(255 255 255 / 0.13);
98
+ --border-soft: rgb(255 255 255 / 0.03);
99
+ --text: #ececf1;
100
+ --text-muted: #9a9aa6;
101
+ --text-faint: #5f5f6b;
102
+ --selection: rgb(var(--accent-rgb) / 0.6);
103
+ --float-shadow:
104
+ 0 1px 2px -1px rgb(0 0 0 / 0.3),
105
+ 0 2px 4px rgb(0 0 0 / 0.24),
106
+ 0 4px 8px rgb(0 0 0 / 0.18),
107
+ 0 6px 12px rgb(0 0 0 / 0.1);
108
+ /* Tighter than in the light theme: the compositor rounds the faint tail of a shadow on a layer
109
+ of its own a gray level darker, so a wide blur ends in a flat ring that reads as a band. */
110
+ --raised-shadow:
111
+ 0 1px 2px rgb(0 0 0 / 0.3),
112
+ 0 2px 4px rgb(0 0 0 / 0.2),
113
+ 0 4px 8px rgb(0 0 0 / 0.12);
114
+ --status-running: #60a5fa;
115
+ --status-needs-you: #fbbf24;
116
+ --status-idle: #4ade80;
117
+ --status-error: #ef4444;
118
+ --positive: #4ade80;
119
+ /* Dark on light green: the dark theme's positive is a foreground color, so text sits on it the
120
+ other way round from the light theme, where the same token is a fill deep enough for white. */
121
+ --positive-text: #052e16;
122
+ --file-icon-gray: #adadb1;
123
+ --file-icon-red: #ff6762;
124
+ --file-icon-vermilion: #d5512f;
125
+ --file-icon-orange: #ffa359;
126
+ --file-icon-yellow: #ffd452;
127
+ --file-icon-green: #5ecc71;
128
+ --file-icon-teal: #64d1db;
129
+ --file-icon-cyan: #68cdf2;
130
+ --file-icon-blue: #69b1ff;
131
+ --file-icon-indigo: #9d6afb;
132
+ --file-icon-purple: #d568ea;
133
+ --file-icon-pink: #ff678d;
134
+ --file-icon-mauve: #79697b;
135
+ }
136
+
137
+ /* Who floats over whom. Whole numbers in one place, so a new popup cannot guess. */
138
+ :root {
139
+ --z-dialog-backdrop: 80;
140
+ --z-dialog: 90;
141
+ --z-popup: 100;
142
+ --z-tooltip: 110;
143
+ --z-shortcut-hints: 120;
144
+ }
145
+
146
+ /* One type scale for the whole interface, in rem against the font size the app sets on `<html>`
147
+ (15px reads well in a desktop app), so text and the rem-based layout scale together when it moves.
148
+ Code keeps its own absolute size, next to a terminal's, so it never scales twice.
149
+ `xs` is the size most of the interface is written in, so it is where reading has to work: the
150
+ sizes above it are a step apart rather than a leap.
151
+ Every rem is rounded to a whole pixel, since no interface size in the range lands a quarter rem
152
+ on one. The spacing unit is rounded rather than each multiple, so `p-2` is twice a whole pixel. */
153
+ @theme {
154
+ /* No default palette: a component reaches only the semantic colors below. */
155
+ --color-*: initial;
156
+ --spacing: round(0.25rem, 1px);
157
+ --text-xs: round(0.875rem, 1px);
158
+ --text-xs--line-height: round(1.25rem, 1px);
159
+ --text-sm: round(0.9375rem, 1px);
160
+ --text-sm--line-height: round(1.375rem, 1px);
161
+ --text-base: round(1rem, 1px);
162
+ --text-base--line-height: round(1.5rem, 1px);
163
+ --text-lg: round(1.125rem, 1px);
164
+ --text-lg--line-height: round(1.5rem, 1px);
165
+ --text-4xl: round(2.25rem, 1px);
166
+ --text-4xl--line-height: round(2.5rem, 1px);
167
+ --text-code: 13px;
168
+ --text-code--line-height: 20px;
169
+ --radius-xs: round(0.125rem, 1px);
170
+ --radius-sm: round(0.25rem, 1px);
171
+ --radius-md: round(0.375rem, 1px);
172
+ --radius-lg: round(0.5rem, 1px);
173
+ --radius-xl: round(0.75rem, 1px);
174
+ --radius-2xl: round(1rem, 1px);
175
+ --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
176
+ /* Not inline: an app that lets a person pick a monospace font overrides this variable on
177
+ `<html>`, and `font-mono` has to follow it. */
178
+ --font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
179
+ }
180
+
181
+ /* A line height relative to the font would put a line between two pixels. Inline, so the `em`
182
+ resolves against the element that carries the utility. */
183
+ @theme inline {
184
+ --leading-tight: round(1.25em, 1px);
185
+ --leading-snug: round(1.375em, 1px);
186
+ --leading-normal: round(1.5em, 1px);
187
+ }
188
+
189
+ @theme inline {
190
+ --color-bg: var(--bg);
191
+ --color-surface: var(--surface);
192
+ --color-surface-raised: var(--surface-raised);
193
+ --color-surface-sunken: var(--surface-sunken);
194
+ --color-surface-idle: var(--surface-idle);
195
+ --color-surface-hover: var(--surface-hover);
196
+ --color-surface-active: var(--surface-active);
197
+ --color-border: var(--border);
198
+ --color-border-strong: var(--border-strong);
199
+ --color-border-soft: var(--border-soft);
200
+ --color-text: var(--text);
201
+ --color-text-muted: var(--text-muted);
202
+ --color-text-faint: var(--text-faint);
203
+ --color-accent: var(--accent);
204
+ --color-accent-soft: var(--accent-soft);
205
+ --color-accent-text: var(--accent-text);
206
+ --color-status-running: var(--status-running);
207
+ --color-status-needs-you: var(--status-needs-you);
208
+ --color-status-idle: var(--status-idle);
209
+ --color-status-error: var(--status-error);
210
+ --color-positive: var(--positive);
211
+ --color-positive-text: var(--positive-text);
212
+ --color-media-handle: var(--media-handle);
213
+ --color-media-handle-text: var(--media-handle-text);
214
+ --shadow-float: var(--float-shadow);
215
+ --shadow-raised: var(--raised-shadow);
216
+ --shadow-media-handle-edge: var(--media-handle-edge);
217
+ --shadow-media-handle: var(--media-handle-shadow);
218
+ }
219
+
220
+ @layer base {
221
+ /*
222
+ * A border with an alpha has to mix with what is behind the element, not with the element's own
223
+ * background: by default a background runs on under the border and a node's white surface would
224
+ * wash its line out over the canvas. Utilities sit in a later layer, so `bg-clip-text` still wins.
225
+ */
226
+ *, ::before, ::after {
227
+ background-clip: padding-box;
228
+ }
229
+ body {
230
+ font-family: var(--font-sans);
231
+ font-size: var(--text-sm);
232
+ color: var(--text);
233
+ background: var(--bg);
234
+ -webkit-font-smoothing: antialiased;
235
+ -moz-osx-font-smoothing: grayscale;
236
+ user-select: none;
237
+ /* The longhand, because the `text-wrap` shorthand also resets the wrap mode and would undo `whitespace-nowrap`. */
238
+ text-wrap-style: pretty;
239
+ }
240
+ ::selection {
241
+ background: var(--selection);
242
+ color: var(--accent-text);
243
+ }
244
+ }
245
+
246
+ /* The rules a utility cannot write: Base UI state selectors shared by many items, pseudo-elements,
247
+ keyframes. Inside Tailwind's `components` layer, so a utility on the same element still wins. */
248
+ @layer components {
249
+ /* Every icon button in the app, with the pressed state a `data-` or `aria-` attribute puts on it. */
250
+ .icon-btn {
251
+ display: inline-flex;
252
+ align-items: center;
253
+ justify-content: center;
254
+ width: 32px;
255
+ height: 32px;
256
+ /* An icon button is a square in every row it stands in: a bar that runs out of room truncates
257
+ its text or folds its actions away, it never squeezes a button into a sliver. */
258
+ flex-shrink: 0;
259
+ border-radius: 8px;
260
+ color: var(--text-muted);
261
+ /* Only the press animates. A fade on hover lags behind the pointer, which reads as the
262
+ button answering late. */
263
+ transition: scale 120ms ease;
264
+ }
265
+ /* The size decides the radius, and the icon inside: 16 in the default of 32, 14 in 28, 12 below. */
266
+ .icon-btn-sm { width: 28px; height: 28px; border-radius: 6px; }
267
+ .icon-btn-xs { width: 24px; height: 24px; border-radius: 6px; }
268
+ .icon-btn-2xs { width: 20px; height: 20px; border-radius: 4px; }
269
+ .icon-btn:hover { background: var(--surface-hover); color: var(--text); }
270
+ .icon-btn:active { scale: 0.96; }
271
+ /* Pressed, not important: a toggle that is on reads as a filled gray key. The accent stays for
272
+ the focus ring, the primary button, canvas selection and agent status. */
273
+ .icon-btn[data-active="true"], .icon-btn[aria-pressed="true"] { background: var(--surface-active); color: var(--text); }
274
+ /* A button with nothing to do says so and stays quiet under the pointer. `aria-disabled` is the
275
+ one that keeps its focus and its tooltip. */
276
+ .icon-btn:disabled, .icon-btn[aria-disabled="true"] { color: var(--text-faint); cursor: default; }
277
+ .icon-btn:disabled:hover, .icon-btn[aria-disabled="true"]:hover { background: transparent; color: var(--text-faint); }
278
+ .icon-btn:disabled:active, .icon-btn[aria-disabled="true"]:active { scale: 1; }
279
+ /* Every text input in the app: one border and one focus ring, at the one height or the compact
280
+ one, so a field in a dialog, in settings and next to a chat answer cannot drift apart. */
281
+ .field {
282
+ height: 32px;
283
+ width: 100%;
284
+ padding: 0 10px;
285
+ border-radius: 8px;
286
+ border: 1px solid var(--border);
287
+ background: var(--surface);
288
+ color: var(--text);
289
+ font-size: var(--text-sm);
290
+ outline: none;
291
+ }
292
+ .field::placeholder { color: var(--text-faint); }
293
+ .field:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
294
+ /* A field with an icon is a box around a bare input: the box wears the outline the input would. */
295
+ .field:has(> input:focus-visible) { outline: 2px solid var(--accent); outline-offset: -1px; }
296
+ /* A value shown in a field but not typed into, such as a chosen folder beside the button that picks it. */
297
+ input.field[readonly], .field > input[readonly] { color: var(--text-muted); text-overflow: ellipsis; }
298
+ /* At the height of a small button: a row of tools, and a name typed in place of the label it renames. */
299
+ .field-sm {
300
+ height: 28px;
301
+ padding: 0 8px;
302
+ border-radius: 6px;
303
+ font-size: var(--text-xs);
304
+ }
305
+ /* Keyboard focus is visible on every control; pointer focus stays quiet. `.focus-ring` is the
306
+ same outline for something that takes focus without being a button, such as a row in a list. */
307
+ .icon-btn:focus-visible, .menu-item:focus-visible, button:focus-visible, .focus-ring:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
308
+ .menu-item:focus-visible { outline-offset: -2px; }
309
+ /* A card that holds a field, the composer's or a prompt's, wears the field's outline on its own edge. */
310
+ .focus-ring-within:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -1px; }
311
+ /* Base UI moves DOM focus to the item the pointer hovers, and a browser keeps `:focus-visible`
312
+ on for scripted focus as long as its own keyboard flag is up, so after any keystroke the ring
313
+ would follow the mouse through every menu. The last real input event decides instead
314
+ (`startInputModality`); hover keeps the background it already has. The role reaches an item
315
+ that is no row, such as an icon button or a swatch inside a menu. */
316
+ :root[data-modality='pointer'] :is(.menu-item, [role^='menuitem']):focus-visible { outline: none; }
317
+ /* Base UI's open and close transitions are `data-` states on the popup itself, and every menu,
318
+ picker, dialog and tooltip in the app shares them, along with the surface they float on. */
319
+ .menu-popup, .picker-popup, .dialog-popup, .tooltip-popup {
320
+ background: var(--surface-raised);
321
+ border: 1px solid var(--border);
322
+ box-shadow: var(--float-shadow);
323
+ transition: opacity 120ms ease, scale 120ms ease;
324
+ }
325
+ .menu-popup {
326
+ min-width: 200px;
327
+ padding: 4px;
328
+ border-radius: 10px;
329
+ outline: none;
330
+ /* The positioner says how much room is left before the viewport edge; a long menu scrolls
331
+ inside that, with some air so it never sits against the window's edge. */
332
+ max-height: calc(var(--available-height) - 16px);
333
+ overflow-y: auto;
334
+ overscroll-behavior: contain;
335
+ transform-origin: var(--transform-origin);
336
+ }
337
+ .menu-popup[data-starting-style], .menu-popup[data-ending-style] { opacity: 0; scale: 0.96; }
338
+ /* A menu row, with the states Base UI puts on it while it is highlighted, disabled or the
339
+ trigger of a submenu that is open. */
340
+ .menu-item {
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 10px;
344
+ padding: 6px 10px;
345
+ border-radius: 6px;
346
+ font-size: var(--text-sm);
347
+ /* The type scale, not the 1.5 the reset inherits, so a row is a whole number of pixels tall
348
+ and a glyph next to a two-line label lands on a whole pixel too. */
349
+ line-height: var(--text-sm--line-height);
350
+ color: var(--text);
351
+ cursor: default;
352
+ outline: none;
353
+ }
354
+ /* Base UI highlights a row for the pointer and for the arrow keys alike. The keyboard row is a
355
+ mark that has to be findable, the pointer's is the lift under the cursor. */
356
+ .menu-item[data-highlighted] { background: var(--surface-active); }
357
+ :root[data-modality='pointer'] .menu-item[data-highlighted] { background: var(--surface-hover); }
358
+ .menu-item[data-disabled] { opacity: .5; }
359
+ /* Two lines against each other divide nothing, and so does a line at the top or the bottom of a
360
+ menu. A menu is composed out of rows that each decide whether they are there, so the last word
361
+ on this is here rather than in every list that draws one. */
362
+ .menu-popup > [role='separator'] + [role='separator'],
363
+ .menu-popup > [role='separator']:first-child,
364
+ .menu-popup > [role='separator']:last-child { display: none; }
365
+ /* A list the arrow keys walk that is not a Base UI menu: the palette, the model picker, the
366
+ composer's own menus. Its cursor carries the same two marks a menu row does. */
367
+ .cursor-row[data-active='true'] { background: var(--surface-active); color: var(--text); }
368
+ :root[data-modality='pointer'] .cursor-row[data-active='true'] { background: var(--surface-hover); }
369
+ .menu-item[data-popup-open] { background: var(--surface-active); }
370
+ .project-switcher-trigger {
371
+ display: flex;
372
+ height: 32px;
373
+ min-width: 0;
374
+ align-items: center;
375
+ gap: 8px;
376
+ border-radius: 6px;
377
+ padding: 0 8px;
378
+ text-align: left;
379
+ }
380
+ .project-switcher-trigger:hover { background: var(--surface-hover); }
381
+ .project-switcher-trigger[data-popup-open] { background: var(--surface-active); }
382
+ .project-switcher-trigger:disabled { opacity: .5; pointer-events: none; }
383
+ /* A row with buttons at its end: the items touch, 1px apart, so only the outer corners stay round,
384
+ and the whole row lights up under the pointer while the item beneath it lights up more. */
385
+ .menu-row { display: flex; min-width: 0; align-items: stretch; gap: 1px; }
386
+ .menu-row > .menu-item:first-child { flex: 1 1 0%; min-width: 0; }
387
+ /* By the items around it, not by :last-child: an open submenu puts its focus guards in the row. */
388
+ .menu-row > .menu-item:has(~ .menu-item) { border-top-right-radius: 0; border-bottom-right-radius: 0; }
389
+ .menu-row > .menu-item ~ .menu-item { border-top-left-radius: 0; border-bottom-left-radius: 0; }
390
+ .menu-row-action {
391
+ flex-shrink: 0;
392
+ width: calc(var(--text-sm--line-height) + 12px);
393
+ padding: 0;
394
+ justify-content: center;
395
+ gap: 0;
396
+ }
397
+ :root[data-modality='pointer'] .menu-row:hover > .menu-item {
398
+ background: color-mix(in srgb, var(--surface-hover) 55%, transparent);
399
+ }
400
+ :root[data-modality='pointer'] .menu-row > .menu-item[data-highlighted] {
401
+ background: var(--surface-hover);
402
+ }
403
+ .menu-row:has(.menu-row-action[data-popup-open]) > .menu-item {
404
+ background: var(--surface-hover);
405
+ }
406
+ /* A shortcut in a row: pushed to the right, in the interface font, wherever a row carries one. */
407
+ .menu-item kbd { margin-left: auto; font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-faint); }
408
+ /* A menu with a search field on top: the field sits flush against the edge, so the padding is the list's. */
409
+ .picker-popup {
410
+ width: 320px;
411
+ border-radius: 10px;
412
+ outline: none;
413
+ overflow: hidden;
414
+ transform-origin: var(--transform-origin);
415
+ }
416
+ .picker-popup[data-starting-style], .picker-popup[data-ending-style] { opacity: 0; scale: 0.96; }
417
+ /* One stack for everything that floats: a dialog, then the menus and selects a dialog can host,
418
+ then the tooltips over those. A popup under its own dialog is the bug this ordering prevents;
419
+ a component reaches the middle step with `z-(--z-popup)`. */
420
+ .popup-positioner { z-index: var(--z-popup); }
421
+ /* A press on a window's drag region goes to the system and never reaches the page, so a popup
422
+ that closes on a press outside it would stay open over a custom title bar. While one is open
423
+ nothing drags, the way a native title bar takes that first press to close a menu. */
424
+ :root:has(.popup-positioner[data-open]) * { -webkit-app-region: no-drag; }
425
+ .dialog-backdrop { position: fixed; inset: 0; z-index: var(--z-dialog-backdrop); background: color-mix(in srgb, var(--bg) 55%, transparent); }
426
+ .dialog-backdrop[data-starting-style], .dialog-backdrop[data-ending-style] { opacity: 0; }
427
+ .dialog-popup {
428
+ position: fixed;
429
+ left: 50%;
430
+ top: 50%;
431
+ z-index: var(--z-dialog);
432
+ /* The individual `translate` property, not `transform`, because the spec applies translate
433
+ before scale: a `-50%` inside `transform` would be scaled along with the popup and leave
434
+ it drifting sideways while it grows. */
435
+ translate: -50% -50%;
436
+ max-width: calc(100vw - 32px);
437
+ max-height: calc(100dvh - 32px);
438
+ border-radius: 14px;
439
+ outline: none;
440
+ overflow: hidden;
441
+ }
442
+ .dialog-popup[data-starting-style], .dialog-popup[data-ending-style] { opacity: 0; scale: 0.97; }
443
+ /* A dialog opened from inside another dialog. Base UI leaves the nested backdrop out because the
444
+ one of the dialog underneath is still on screen, but that one lies under its own popup, so the
445
+ dialog you opened this from keeps taking clicks. `forceRender` on the backdrop plus these two
446
+ steps put the dim over it; both stay under `--z-popup`, so a menu in the nested dialog wins. */
447
+ .dialog-backdrop-nested { z-index: calc(var(--z-dialog) + 1); }
448
+ .dialog-popup-nested { z-index: calc(var(--z-dialog) + 2); }
449
+ /* A lightbox is the picture and nothing else, so the popup takes the size the picture settles at
450
+ instead of filling the window. The margin keeps the window's own title bar, and the traffic
451
+ lights on it, in reach above a picture that is as large as it is allowed to get. */
452
+ .lightbox-frame {
453
+ --lightbox-margin: 64px;
454
+ --lightbox-header: 45px;
455
+ }
456
+ .lightbox-frame img {
457
+ max-width: calc(100vw - 2 * var(--lightbox-margin));
458
+ max-height: calc(100dvh - 2 * var(--lightbox-margin) - var(--lightbox-header));
459
+ }
460
+ /* A picture holds the eye better against a heavier dim than a dialog of controls needs. */
461
+ .lightbox-backdrop { background: color-mix(in srgb, var(--bg) 80%, transparent); }
462
+ /* Tooltips. The positioner animates its position, so a tooltip slides to the next button
463
+ instead of blinking; the popup fades and scales from its anchor side. Both drop the motion
464
+ while Base UI says the tooltip is instant. */
465
+ /* As wide as its label up to the viewport's max width, so a short label near the edge of the
466
+ window does not wrap to the room left of it before the positioner moves it back in. */
467
+ .tooltip-positioner {
468
+ width: max-content;
469
+ z-index: var(--z-tooltip);
470
+ pointer-events: none;
471
+ transition: top 150ms ease, left 150ms ease, right 150ms ease, bottom 150ms ease;
472
+ }
473
+ .tooltip-positioner[data-instant] { transition: none; }
474
+ .tooltip-popup {
475
+ display: flex;
476
+ border-radius: 7px;
477
+ color: var(--text);
478
+ font-size: var(--text-xs);
479
+ line-height: var(--text-xs--line-height);
480
+ transform-origin: var(--transform-origin);
481
+ }
482
+ .tooltip-popup[data-starting-style], .tooltip-popup[data-ending-style] { opacity: 0; scale: 0.98; }
483
+ .tooltip-popup[data-instant] { transition: none; }
484
+
485
+ /* A file icon outside the tree. Its glyph takes the hue the sprite gives the file type, and a
486
+ type without one keeps the muted text it would have in the tree. */
487
+ .file-icon { flex-shrink: 0; vertical-align: middle; fill: currentColor; color: var(--text-muted); }
488
+
489
+ /* A strip that scrolls sideways fades out at the edge that still hides something, and only there,
490
+ so a strip that fits keeps its first and last item sharp. The edges are set by whoever scrolls. */
491
+ .scroll-fade-x {
492
+ --fade-start: 0px;
493
+ --fade-end: 0px;
494
+ mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent);
495
+ }
496
+ .scroll-fade-x[data-fade-start] { --fade-start: 24px; }
497
+ .scroll-fade-x[data-fade-end] { --fade-end: 24px; }
498
+ /* The same for a column under a fixed header: its top fades once something has scrolled under it. */
499
+ .scroll-fade-top {
500
+ --fade-start: 0px;
501
+ mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-start));
502
+ }
503
+ .scroll-fade-top[data-fade-start] { --fade-start: 24px; }
504
+
505
+ /* A toast's countdown ring; `TimerRing` sets duration and delay from the toast's own timer. */
506
+ .toast-timer-arc { animation: toast-timer linear forwards; }
507
+ @keyframes toast-timer {
508
+ from { stroke-dashoffset: 0; }
509
+ to { stroke-dashoffset: 1; }
510
+ }
511
+ @media (prefers-reduced-motion: reduce) {
512
+ .toast-timer-arc { animation: none; }
513
+ }
514
+
515
+ /* A `Spinner`: Leap from loading-dev (https://github.com/jakubkrehel/loading), MIT, Copyright (c) 2026 Jakub Krehel.
516
+ Each dot turns half a circle over the other two, then slides back one gap at a time; the three
517
+ are a third of a turn apart, so at every moment one dot is in the air. */
518
+ .spinner {
519
+ --spinner-duration: 1800ms;
520
+ --spinner-dot: round(calc(var(--spinner-size) * 0.22), 1px);
521
+ --spinner-gap: round(down, calc((var(--spinner-size) - var(--spinner-dot)) / 2), 1px);
522
+ position: relative;
523
+ display: inline-block;
524
+ flex-shrink: 0;
525
+ width: var(--spinner-size);
526
+ height: var(--spinner-size);
527
+ vertical-align: middle;
528
+ }
529
+ .spinner-hop {
530
+ position: absolute;
531
+ top: round(calc((var(--spinner-size) - var(--spinner-dot)) / 2), 1px);
532
+ left: calc(var(--spinner-size) - var(--spinner-dot) - var(--spinner-gap) * 2);
533
+ width: calc(var(--spinner-gap) * 2 + var(--spinner-dot));
534
+ height: var(--spinner-dot);
535
+ animation: spinner-hop var(--spinner-duration) ease-in-out infinite;
536
+ animation-delay: calc(var(--spinner-duration) * (var(--spinner-step) - 3) / 3);
537
+ }
538
+ .spinner-dot {
539
+ position: absolute;
540
+ inset: 0 auto 0 0;
541
+ width: var(--spinner-dot);
542
+ border-radius: 50%;
543
+ background: currentColor;
544
+ }
545
+ @keyframes spinner-hop {
546
+ 0% { transform: translateX(0) rotate(0); }
547
+ 33.33% { transform: translateX(0) rotate(180deg); }
548
+ 66.66% { transform: translateX(calc(var(--spinner-gap) * -1)) rotate(180deg); }
549
+ 100% { transform: translateX(calc(var(--spinner-gap) * -2)) rotate(180deg); }
550
+ }
551
+ @media (prefers-reduced-motion: reduce) {
552
+ .spinner-hop {
553
+ transform: translateX(calc(var(--spinner-step) * var(--spinner-gap)));
554
+ animation: none;
555
+ }
556
+ }
557
+
558
+ /* A `SlidingColumn` opening and closing. A drag or a width restored on load lands at once. */
559
+ .sliding-column { transition: width 200ms cubic-bezier(0, 0, 0.2, 1); }
560
+ .sliding-column[data-resizing],
561
+ .sliding-column[data-instant] { transition: none; }
562
+ }
@@ -0,0 +1,120 @@
1
+ import { create, type StoreApi, type UseBoundStore } from 'zustand';
2
+ import type { Shortcut } from './shortcut.ts';
3
+
4
+ // How long a toast that went well stays up; a failure waits for the person instead.
5
+ export const SUCCESS_MS = 4000;
6
+
7
+ // How long a deletion can still be taken back.
8
+ export const UNDO_MS = 8000;
9
+
10
+ /* `deleted` says something went that can still come back, which is what its action is for. */
11
+ export type ToastKind = 'progress' | 'success' | 'error' | 'deleted';
12
+
13
+ export interface ToastAction {
14
+ label: string;
15
+ run(): void;
16
+ /* The key that does the same, printed beside the label. The key itself is bound elsewhere. */
17
+ shortcut?: Shortcut;
18
+ }
19
+
20
+ /* When a toast that goes by itself started counting and when it goes, as epoch milliseconds. */
21
+ export interface ToastDeadline {
22
+ start: number;
23
+ end: number;
24
+ }
25
+
26
+ /* How far into its lifetime a toast is at `now`, held inside that lifetime. */
27
+ export const elapsedOf = (deadline: ToastDeadline, now: number): number => Math.min(Math.max(now - deadline.start, 0), deadline.end - deadline.start);
28
+
29
+ /* An app that carries more on a toast extends this and hands its own type to `createToastStore`. */
30
+ export interface Toast {
31
+ id: string;
32
+ title: string;
33
+ /* The line under the title, often the one that says why something stopped. */
34
+ description?: string;
35
+ kind: ToastKind;
36
+ action?: ToastAction;
37
+ /* More than one thing to do about it, such as looking at what went wrong or trying again; drawn after `action`, in order. */
38
+ actions?: ToastAction[];
39
+ /* A success that waits to be dismissed, for news that lands while nobody is looking yet. */
40
+ persist?: boolean;
41
+ /* Runs once the toast is gone, whether it ran out or was dismissed. */
42
+ onClose?: () => void;
43
+ /* Set by the store from the timer that takes the toast away, so what counts down on screen is that timer. */
44
+ deadline?: ToastDeadline;
45
+ }
46
+
47
+ export type ToastInput<T extends Toast = Toast> = Omit<T, 'id' | 'deadline'> & { id?: string };
48
+
49
+ export type ToastPatch<T extends Toast = Toast> = Partial<Omit<T, 'id' | 'deadline'>>;
50
+
51
+ export interface ToastStore<T extends Toast = Toast> {
52
+ toasts: T[];
53
+ /* Puts one up, or moves the one with this id to what it says now; answers its id. */
54
+ show(toast: ToastInput<T>): string;
55
+ update(id: string, patch: ToastPatch<T>): void;
56
+ dismiss(id: string): void;
57
+ }
58
+
59
+ export type ToastStoreHook<T extends Toast = Toast> = UseBoundStore<StoreApi<ToastStore<T>>>;
60
+
61
+ /*
62
+ * The one place an app says how something went that no surface on screen shows. A long action keeps
63
+ * one id from progress to its outcome, so a person never watches two cards for one thing.
64
+ */
65
+ export const createToastStore = <T extends Toast = Toast>(): ToastStoreHook<T> => {
66
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
67
+ let counter = 0;
68
+
69
+ return create<ToastStore<T>>((set, get) => {
70
+ const stop = (id: string): void => {
71
+ clearTimeout(timers.get(id));
72
+ timers.delete(id);
73
+ };
74
+
75
+ /* A toast that went well takes itself away, and so does the offer to undo; a failure and a running action stay. */
76
+ const schedule = (id: string, kind: ToastKind, persist: boolean): ToastDeadline | undefined => {
77
+ stop(id);
78
+ const lifetime = kind === 'deleted' ? UNDO_MS : kind === 'success' && !persist ? SUCCESS_MS : null;
79
+ if (lifetime === null) {
80
+ return undefined;
81
+ }
82
+ timers.set(
83
+ id,
84
+ setTimeout(() => {
85
+ timers.delete(id);
86
+ get().dismiss(id);
87
+ }, lifetime)
88
+ );
89
+ const start = Date.now();
90
+ return { start, end: start + lifetime };
91
+ };
92
+
93
+ return {
94
+ toasts: [],
95
+ show(toast) {
96
+ counter += 1;
97
+ const id = toast.id ?? `toast-${counter}`;
98
+ const next = { ...toast, id, deadline: schedule(id, toast.kind, toast.persist === true) } as T;
99
+ const toasts = get().toasts;
100
+ set({ toasts: toasts.some((entry) => entry.id === id) ? toasts.map((entry) => (entry.id === id ? next : entry)) : [...toasts, next] });
101
+ return id;
102
+ },
103
+ update(id, patch) {
104
+ const current = get().toasts.find((entry) => entry.id === id);
105
+ if (current === undefined) {
106
+ return;
107
+ }
108
+ const merged: T = { ...current, ...patch };
109
+ const next: T = { ...merged, deadline: schedule(id, merged.kind, merged.persist === true) };
110
+ set({ toasts: get().toasts.map((entry) => (entry.id === id ? next : entry)) });
111
+ },
112
+ dismiss(id) {
113
+ stop(id);
114
+ const gone = get().toasts.find((entry) => entry.id === id);
115
+ set({ toasts: get().toasts.filter((entry) => entry.id !== id) });
116
+ gone?.onClose?.();
117
+ }
118
+ };
119
+ });
120
+ };