@adea-ai/ui 0.104.3 → 0.106.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 (191) hide show
  1. package/README.md +44 -8
  2. package/dist/components/composites/action-button/action-button.d.ts.map +1 -1
  3. package/dist/components/composites/action-button/action-button.js +6 -8
  4. package/dist/components/composites/action-button/action-button.js.map +1 -1
  5. package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts +16 -0
  6. package/dist/components/composites/appearance-editor/accent-swatch-groups.d.ts.map +1 -0
  7. package/dist/components/composites/appearance-editor/accent-swatch-groups.js +151 -0
  8. package/dist/components/composites/appearance-editor/accent-swatch-groups.js.map +1 -0
  9. package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
  10. package/dist/components/composites/appearance-editor/appearance-choices.js +24 -84
  11. package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
  12. package/dist/components/composites/appearance-editor/appearance-editor.d.ts +2 -2
  13. package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
  14. package/dist/components/composites/appearance-editor/appearance-editor.js +25 -7
  15. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  16. package/dist/components/composites/appearance-editor/appearance-types.d.ts +9 -0
  17. package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
  18. package/dist/components/composites/appearance-editor/font-settings-group.d.ts +10 -0
  19. package/dist/components/composites/appearance-editor/font-settings-group.d.ts.map +1 -0
  20. package/dist/components/composites/appearance-editor/font-settings-group.js +310 -0
  21. package/dist/components/composites/appearance-editor/font-settings-group.js.map +1 -0
  22. package/dist/components/composites/appearance-editor/index.d.ts +2 -0
  23. package/dist/components/composites/appearance-editor/index.d.ts.map +1 -1
  24. package/dist/components/composites/appearance-editor/index.js +3 -1
  25. package/dist/components/composites/update-dialog/update-dialog.js +2 -2
  26. package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
  27. package/dist/components/conversation/message-row.d.ts.map +1 -1
  28. package/dist/components/conversation/message-row.js +3 -3
  29. package/dist/components/conversation/message-row.js.map +1 -1
  30. package/dist/components/conversation/paste-token-editor.js +1 -1
  31. package/dist/components/conversation/paste-token-editor.js.map +1 -1
  32. package/dist/components/layout/side-rail/side-rail.js +2 -2
  33. package/dist/components/layout/side-rail/side-rail.js.map +1 -1
  34. package/dist/components/layout/top-bar/top-bar.js +1 -1
  35. package/dist/components/layout/top-bar/top-bar.js.map +1 -1
  36. package/dist/components/theme/appearance-panel.d.ts +1 -1
  37. package/dist/components/theme/appearance-panel.d.ts.map +1 -1
  38. package/dist/components/theme/appearance-panel.js +45 -105
  39. package/dist/components/theme/appearance-panel.js.map +1 -1
  40. package/dist/components/theme/theme-provider.d.ts +5 -2
  41. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  42. package/dist/components/theme/theme-provider.js +38 -19
  43. package/dist/components/theme/theme-provider.js.map +1 -1
  44. package/dist/components/ui/code-block/code-block.d.ts +3 -3
  45. package/dist/components/ui/code-block/code-block.js +6 -6
  46. package/dist/components/ui/code-block/code-block.js.map +1 -1
  47. package/dist/components/ui/command/command.js +1 -1
  48. package/dist/components/ui/command/command.js.map +1 -1
  49. package/dist/components/ui/context-menu/context-menu.js +1 -1
  50. package/dist/components/ui/context-menu/context-menu.js.map +1 -1
  51. package/dist/components/ui/diff-block/diff-block.js +5 -5
  52. package/dist/components/ui/diff-block/diff-block.js.map +1 -1
  53. package/dist/components/ui/dropdown-menu/dropdown-menu.d.ts.map +1 -1
  54. package/dist/components/ui/dropdown-menu/dropdown-menu.js +101 -8
  55. package/dist/components/ui/dropdown-menu/dropdown-menu.js.map +1 -1
  56. package/dist/components/ui/kbd/index.d.ts +1 -1
  57. package/dist/components/ui/kbd/index.d.ts.map +1 -1
  58. package/dist/components/ui/kbd/index.js +2 -2
  59. package/dist/components/ui/kbd/kbd.d.ts +40 -2
  60. package/dist/components/ui/kbd/kbd.d.ts.map +1 -1
  61. package/dist/components/ui/kbd/kbd.js +74 -7
  62. package/dist/components/ui/kbd/kbd.js.map +1 -1
  63. package/dist/components/ui/menubar/menubar.js +1 -1
  64. package/dist/components/ui/menubar/menubar.js.map +1 -1
  65. package/dist/components/ui/typography/typography.d.ts +3 -5
  66. package/dist/components/ui/typography/typography.d.ts.map +1 -1
  67. package/dist/components/ui/typography/typography.js +6 -8
  68. package/dist/components/ui/typography/typography.js.map +1 -1
  69. package/dist/index.js +7 -4
  70. package/dist/lib/appearance-font-settings.d.ts +53 -0
  71. package/dist/lib/appearance-font-settings.d.ts.map +1 -0
  72. package/dist/lib/appearance-font-settings.js +160 -0
  73. package/dist/lib/appearance-font-settings.js.map +1 -0
  74. package/dist/lib/font-catalog.d.ts +72 -0
  75. package/dist/lib/font-catalog.d.ts.map +1 -0
  76. package/dist/lib/font-catalog.js +71 -0
  77. package/dist/lib/font-catalog.js.map +1 -0
  78. package/dist/lib/safe-script.d.ts +3 -0
  79. package/dist/lib/safe-script.d.ts.map +1 -0
  80. package/dist/lib/safe-script.js +9 -0
  81. package/dist/lib/safe-script.js.map +1 -0
  82. package/dist/lib/tokens.d.ts +2 -19
  83. package/dist/lib/tokens.d.ts.map +1 -1
  84. package/dist/lib/tokens.js +63 -39
  85. package/dist/lib/tokens.js.map +1 -1
  86. package/dist/r/about-dialog.json +1 -1
  87. package/dist/r/accordion.json +1 -1
  88. package/dist/r/account-menu.json +1 -1
  89. package/dist/r/alert.json +1 -1
  90. package/dist/r/appearance-editor.json +13 -2
  91. package/dist/r/breadcrumb.json +1 -1
  92. package/dist/r/calendar.json +1 -1
  93. package/dist/r/carousel.json +1 -1
  94. package/dist/r/catalog-browser.json +1 -1
  95. package/dist/r/checkbox.json +1 -1
  96. package/dist/r/code-block.json +1 -1
  97. package/dist/r/combobox.json +1 -1
  98. package/dist/r/command.json +1 -1
  99. package/dist/r/context-menu.json +1 -1
  100. package/dist/r/conversation.json +1 -1
  101. package/dist/r/detail-panel.json +1 -1
  102. package/dist/r/dialog.json +1 -1
  103. package/dist/r/dropdown-menu.json +1 -1
  104. package/dist/r/empty.json +1 -1
  105. package/dist/r/floating-preview.json +1 -1
  106. package/dist/r/help-center.json +1 -1
  107. package/dist/r/lib.json +16 -1
  108. package/dist/r/menubar.json +1 -1
  109. package/dist/r/native-select.json +1 -1
  110. package/dist/r/navigation-menu.json +1 -1
  111. package/dist/r/pagination.json +1 -1
  112. package/dist/r/radio-group.json +1 -1
  113. package/dist/r/registry.json +75 -42
  114. package/dist/r/resizable.json +1 -1
  115. package/dist/r/scene-controls.json +1 -1
  116. package/dist/r/select.json +1 -1
  117. package/dist/r/sheet.json +1 -1
  118. package/dist/r/sidebar-nav.json +1 -1
  119. package/dist/r/spinner.json +1 -1
  120. package/dist/r/split-layout.json +1 -1
  121. package/dist/r/src/components/composites/action-button/action-button.tsx +2 -1
  122. package/dist/r/src/components/composites/appearance-editor/accent-swatch-groups.tsx +147 -0
  123. package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +17 -74
  124. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +19 -6
  125. package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +9 -0
  126. package/dist/r/src/components/composites/appearance-editor/font-settings-group.tsx +350 -0
  127. package/dist/r/src/components/composites/appearance-editor/index.ts +19 -0
  128. package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +2 -2
  129. package/dist/r/src/components/conversation/message-row.tsx +7 -3
  130. package/dist/r/src/components/conversation/paste-token-editor.tsx +1 -1
  131. package/dist/r/src/components/layout/side-rail/side-rail.tsx +3 -3
  132. package/dist/r/src/components/layout/top-bar/top-bar.tsx +1 -1
  133. package/dist/r/src/components/theme/appearance-panel.tsx +34 -83
  134. package/dist/r/src/components/theme/theme-provider.tsx +50 -20
  135. package/dist/r/src/components/ui/code-block/code-block.tsx +6 -6
  136. package/dist/r/src/components/ui/command/command.tsx +1 -1
  137. package/dist/r/src/components/ui/context-menu/context-menu.tsx +1 -1
  138. package/dist/r/src/components/ui/diff-block/diff-block.tsx +7 -7
  139. package/dist/r/src/components/ui/dropdown-menu/dropdown-menu.tsx +145 -7
  140. package/dist/r/src/components/ui/kbd/index.ts +10 -1
  141. package/dist/r/src/components/ui/kbd/kbd.tsx +77 -14
  142. package/dist/r/src/components/ui/menubar/menubar.tsx +1 -1
  143. package/dist/r/src/components/ui/typography/typography.tsx +6 -8
  144. package/dist/r/src/lib/appearance-font-settings.ts +233 -0
  145. package/dist/r/src/lib/font-catalog.ts +80 -0
  146. package/dist/r/src/lib/safe-script.ts +7 -0
  147. package/dist/r/src/lib/tokens.ts +64 -59
  148. package/dist/r/src/styles/appearance-font-settings.css +110 -0
  149. package/dist/r/src/styles/fonts.css +4 -5
  150. package/dist/r/src/styles/globals.css +4 -1
  151. package/dist/r/src/styles/theme.css +14 -0
  152. package/dist/r/stat.json +1 -1
  153. package/dist/r/theme.json +5 -0
  154. package/dist/r/theming.json +4 -2
  155. package/dist/r/toast.json +1 -1
  156. package/dist/r/top-bar.json +1 -1
  157. package/dist/r/tree.json +1 -1
  158. package/dist/r/update-dialog.json +1 -1
  159. package/package.json +7 -6
  160. package/registry.json +75 -42
  161. package/src/components/composites/action-button/action-button.tsx +2 -1
  162. package/src/components/composites/appearance-editor/accent-swatch-groups.tsx +147 -0
  163. package/src/components/composites/appearance-editor/appearance-choices.tsx +17 -74
  164. package/src/components/composites/appearance-editor/appearance-editor.tsx +19 -6
  165. package/src/components/composites/appearance-editor/appearance-types.ts +9 -0
  166. package/src/components/composites/appearance-editor/font-settings-group.tsx +350 -0
  167. package/src/components/composites/appearance-editor/index.ts +19 -0
  168. package/src/components/composites/update-dialog/update-dialog.tsx +2 -2
  169. package/src/components/conversation/message-row.tsx +7 -3
  170. package/src/components/conversation/paste-token-editor.tsx +1 -1
  171. package/src/components/layout/side-rail/side-rail.tsx +3 -3
  172. package/src/components/layout/top-bar/top-bar.tsx +1 -1
  173. package/src/components/theme/appearance-panel.tsx +34 -83
  174. package/src/components/theme/theme-provider.tsx +50 -20
  175. package/src/components/ui/code-block/code-block.tsx +6 -6
  176. package/src/components/ui/command/command.tsx +1 -1
  177. package/src/components/ui/context-menu/context-menu.tsx +1 -1
  178. package/src/components/ui/diff-block/diff-block.tsx +7 -7
  179. package/src/components/ui/dropdown-menu/dropdown-menu.tsx +145 -7
  180. package/src/components/ui/kbd/index.ts +10 -1
  181. package/src/components/ui/kbd/kbd.tsx +77 -14
  182. package/src/components/ui/menubar/menubar.tsx +1 -1
  183. package/src/components/ui/typography/typography.tsx +6 -8
  184. package/src/lib/appearance-font-settings.ts +233 -0
  185. package/src/lib/font-catalog.ts +80 -0
  186. package/src/lib/safe-script.ts +7 -0
  187. package/src/lib/tokens.ts +64 -59
  188. package/src/styles/appearance-font-settings.css +110 -0
  189. package/src/styles/fonts.css +4 -5
  190. package/src/styles/globals.css +4 -1
  191. package/src/styles/theme.css +14 -0
@@ -301,7 +301,7 @@ export function SideRailItem<T extends ValidComponent = 'a'>(props: SideRailItem
301
301
  <Show when={local.shortcut && local.trailing === undefined}>
302
302
  <span
303
303
  aria-hidden="true"
304
- class="text-sidebar-muted-foreground shrink-0 font-mono text-2xs leading-none opacity-0 transition-opacity group-hover/rail-item:opacity-100 group-focus-visible/rail-item:opacity-100 group-data-[collapsed=true]/rail:hidden"
304
+ class="text-sidebar-muted-foreground shrink-0 font-code text-code leading-none opacity-0 transition-opacity group-hover/rail-item:opacity-100 group-focus-visible/rail-item:opacity-100 group-data-[collapsed=true]/rail:hidden"
305
305
  >
306
306
  {local.shortcut}
307
307
  </span>
@@ -337,7 +337,7 @@ export function SideRailItem<T extends ValidComponent = 'a'>(props: SideRailItem
337
337
  </span>
338
338
  {local.label}
339
339
  <Show when={local.shortcut}>
340
- <span class="text-muted-foreground shrink-0 font-mono text-2xs leading-none">
340
+ <span class="text-muted-foreground shrink-0 font-code text-code leading-none">
341
341
  {local.shortcut}
342
342
  </span>
343
343
  </Show>
@@ -399,7 +399,7 @@ export function SideRailButton(
399
399
  <Show when={local.shortcut}>
400
400
  <span
401
401
  aria-hidden="true"
402
- class="text-sidebar-muted-foreground shrink-0 font-mono text-2xs leading-none opacity-0 transition-opacity group-hover/rail-item:opacity-100 group-focus-visible/rail-item:opacity-100 group-data-[collapsed=true]/rail:hidden"
402
+ class="text-sidebar-muted-foreground shrink-0 font-code text-code leading-none opacity-0 transition-opacity group-hover/rail-item:opacity-100 group-focus-visible/rail-item:opacity-100 group-data-[collapsed=true]/rail:hidden"
403
403
  >
404
404
  {local.shortcut}
405
405
  </span>
@@ -166,7 +166,7 @@ export function TopBarSearch(props: TopBarSearchProps) {
166
166
  <Show when={local.shortcut}>
167
167
  <kbd
168
168
  aria-hidden="true"
169
- class="bg-muted ms-auto hidden h-5 shrink-0 items-center rounded-sm border border-border px-1.5 font-mono text-2xs sm:flex"
169
+ class="bg-muted ms-auto hidden h-5 shrink-0 items-center rounded-sm border border-border px-1.5 font-code text-code sm:flex"
170
170
  >
171
171
  {local.shortcut}
172
172
  </kbd>
@@ -1,9 +1,12 @@
1
1
  import type { ComponentProps } from 'solid-js'
2
- import { For, Show, splitProps } from 'solid-js'
3
- import { accentPresets, fontOptions } from '../../lib/tokens'
2
+ import { Show, splitProps } from 'solid-js'
3
+ import { RadioGroup as Radio } from '@kobalte/core/radio-group'
4
+ import { accentPresets } from '../../lib/tokens'
4
5
  import { themesForAppearance } from '../../lib/themes'
6
+ import { DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS } from '../../lib/appearance-font-settings'
5
7
  import { cn } from '../../lib/utils'
6
- import { Button } from '../ui/button/button'
8
+ import { AccentSwatchGroups } from '../composites/appearance-editor/accent-swatch-groups'
9
+ import { AppearanceFontSettingsGroup } from '../composites/appearance-editor/font-settings-group'
7
10
  import { RadioGroup, RadioGroupItem } from '../ui/radio-group/radio-group'
8
11
  import { ThemePicker } from './theme-picker'
9
12
  import { useTheme } from './theme-provider'
@@ -26,7 +29,7 @@ import { Heading } from '../ui/typography'
26
29
  * setting that keeps changing.
27
30
  * 3. **Accent** — adea's presets, then the accents the selected theme pair
28
31
  * carries itself, layered on whichever theme is active.
29
- * 4. **Font** — the interface face.
32
+ * 4. **Fonts** — separate families and sizes for interface, content and code.
30
33
  *
31
34
  * The panel reads and writes the provider's preference and keeps no state of its
32
35
  * own, so it cannot disagree with what is applied.
@@ -39,6 +42,11 @@ const DARK_THEMES = themesForAppearance('dark')
39
42
  export function AppearancePanel(props: AppearancePanelProps) {
40
43
  const [local, rest] = splitProps(props, ['class'])
41
44
  const { selection, setSelection, resolvedAppearance, themeAccents } = useTheme()
45
+ const selectedAccent = () =>
46
+ accentPresets.some((option) => option.id === selection().accent) ||
47
+ themeAccents().some((option) => option.id === selection().accent)
48
+ ? selection().accent
49
+ : 'theme'
42
50
 
43
51
  return (
44
52
  <div data-slot="appearance-panel" class={cn('flex flex-col gap-8', local.class)} {...rest}>
@@ -101,99 +109,42 @@ export function AppearancePanel(props: AppearancePanelProps) {
101
109
  unreadable.
102
110
  </p>
103
111
  </header>
104
- <div role="radiogroup" aria-label="Accent" class="flex flex-wrap gap-2">
105
- <For each={accentPresets}>
106
- {(preset) => (
107
- <AccentOption
108
- id={preset.id}
109
- label={preset.label}
110
- checked={selection().accent === preset.id}
111
- swatch={
112
- preset.id === 'theme'
113
- ? 'var(--primary)'
114
- : `var(--accent-${preset.id}, ${preset.dark})`
115
- }
116
- onSelect={() => setSelection({ accent: preset.id })}
117
- />
118
- )}
119
- </For>
120
- {/* The theme's own accents follow the presets. Their values are the
121
- theme pair's, so the swatch shows the side that is active now. */}
122
- <For each={themeAccents()}>
123
- {(accent) => (
124
- <AccentOption
125
- id={accent.id}
126
- label={`Theme ${accent.label.toLowerCase()}`}
127
- checked={selection().accent === accent.id}
128
- swatch={resolvedAppearance() === 'dark' ? accent.dark : accent.light}
129
- onSelect={() => setSelection({ accent: accent.id })}
130
- />
131
- )}
132
- </For>
133
- </div>
112
+ <Radio
113
+ value={selectedAccent()}
114
+ onChange={(accent) => setSelection({ accent })}
115
+ orientation="horizontal"
116
+ aria-label="Accent"
117
+ class="flex flex-col items-start gap-3"
118
+ >
119
+ <AccentSwatchGroups
120
+ accentOptions={accentPresets}
121
+ themeAccentOptions={themeAccents()}
122
+ resolvedAppearance={resolvedAppearance()}
123
+ themeDefaultColor="var(--primary)"
124
+ />
125
+ </Radio>
134
126
  </section>
135
127
 
136
- {/* --- 4. Font -------------------------------------------------------- */}
128
+ {/* --- 4. Fonts ------------------------------------------------------- */}
137
129
  <section class="flex flex-col gap-3">
138
130
  <header class="flex flex-col gap-0.5">
139
131
  <Heading as="h2" size="card">
140
- Typeface
132
+ Fonts
141
133
  </Heading>
142
134
  <p class="max-w-prose text-sm text-muted-foreground">
143
- The interface face. Every option is self-hosted, so nothing is fetched at runtime and
144
- the choice survives going offline.
135
+ Choose separate families and sizes for the interface, reading, and code. Font families
136
+ come from the shared catalogue and are self-hosted.
145
137
  </p>
146
138
  </header>
147
- <RadioGroup
148
- value={selection().font}
149
- onChange={(value) => setSelection({ font: value as string })}
150
- aria-label="Typeface"
151
- >
152
- <For each={fontOptions}>
153
- {(option) => (
154
- <RadioGroupItem
155
- value={option.id}
156
- label={option.label}
157
- description={option.description}
158
- />
159
- )}
160
- </For>
161
- </RadioGroup>
139
+ <AppearanceFontSettingsGroup
140
+ settings={selection().fonts ?? DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS}
141
+ onChange={(fonts) => setSelection({ fonts })}
142
+ />
162
143
  </section>
163
144
  </div>
164
145
  )
165
146
  }
166
147
 
167
- function AccentOption(props: {
168
- id: string
169
- label: string
170
- checked: boolean
171
- swatch: string
172
- onSelect: () => void
173
- }) {
174
- return (
175
- <Button
176
- as="button"
177
- type="button"
178
- variant="outline"
179
- size="sm"
180
- role="radio"
181
- data-accent-option={props.id}
182
- aria-checked={props.checked}
183
- aria-label={props.label}
184
- class={cn({ 'border-ring ring-3 ring-primary-subtle': props.checked })}
185
- onClick={() => props.onSelect()}
186
- >
187
- <span
188
- aria-hidden="true"
189
- class="size-3 rounded-full border border-border"
190
- style={{ 'background-color': props.swatch }}
191
- />
192
- {props.label}
193
- </Button>
194
- )
195
- }
196
-
197
148
  function ThemeSection(props: {
198
149
  title: string
199
150
  description: string
@@ -8,7 +8,15 @@ import {
8
8
  splitProps,
9
9
  useContext,
10
10
  } from 'solid-js'
11
- import { accentPresets, fontOptions } from '../../lib/tokens'
11
+ import { accentPresets } from '../../lib/tokens'
12
+ import { safeScriptStringLiteral } from '../../lib/safe-script'
13
+ import {
14
+ applyAppearanceFontSettings,
15
+ DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
16
+ fontSettingsBootstrapScript,
17
+ normalizeAppearanceEditorFontSettings,
18
+ type AppearanceEditorFontSettings,
19
+ } from '../../lib/appearance-font-settings'
12
20
  import type { AccentPreset } from '@adea-ai/themes'
13
21
  import {
14
22
  accentVariables,
@@ -39,7 +47,7 @@ import {
39
47
  * - **an accent** — adea's six presets, or one of the accents the selected theme
40
48
  * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
41
49
  * top of whichever theme is active.
42
- * - **a font** — the interface face.
50
+ * - **fonts** — separate family and size choices for UI, content and code.
43
51
  *
44
52
  * Four axes rather than one list of presets, because they compose: a user who wants
45
53
  * Nord with a violet accent and the system font should not need someone to author
@@ -67,8 +75,10 @@ export type ThemeSelection = {
67
75
  * not offer falls back to the variant's own primary.
68
76
  */
69
77
  accent: string
70
- /** A `fontOptions` id, or `space-grotesk` for the default. */
78
+ /** UI-family alias for consumers of the original single-font preference. */
71
79
  font: string
80
+ /** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
81
+ fonts?: AppearanceEditorFontSettings
72
82
  /**
73
83
  * `comfortable` is the system's default geometry; `compact` moves the control
74
84
  * ladder down one rung.
@@ -84,7 +94,8 @@ export const defaultThemeSelection: ThemeSelection = {
84
94
  lightThemeId: defaultLightThemeId,
85
95
  darkThemeId: defaultDarkThemeId,
86
96
  accent: 'theme',
87
- font: 'space-grotesk',
97
+ font: 'system',
98
+ fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
88
99
  density: 'comfortable',
89
100
  }
90
101
 
@@ -153,6 +164,7 @@ function readSelection(storageKey: string): ThemeSelection {
153
164
  const raw = preferenceStore()?.getItem(storageKey)
154
165
  if (!raw) return defaultThemeSelection
155
166
  const parsed = JSON.parse(raw) as Partial<ThemeSelection>
167
+ const fonts = normalizeAppearanceEditorFontSettings(parsed.fonts, parsed.font).settings
156
168
  return {
157
169
  appearance:
158
170
  parsed.appearance === 'light' ||
@@ -179,9 +191,8 @@ function readSelection(storageKey: string): ThemeSelection {
179
191
  isThemeAccentId(parsed.accent))
180
192
  ? parsed.accent
181
193
  : defaultThemeSelection.accent,
182
- font: fontOptions.some((option) => option.id === parsed.font)
183
- ? (parsed.font as string)
184
- : defaultThemeSelection.font,
194
+ font: fonts.ui.family,
195
+ fonts,
185
196
  // Every axis has to be read back here, or it is lost twice: the effect below
186
197
  // removes the `data-density` that `themeScript` set for the first paint, and
187
198
  // the next `setSelection` rewrites the store without it.
@@ -192,6 +203,26 @@ function readSelection(storageKey: string): ThemeSelection {
192
203
  }
193
204
  }
194
205
 
206
+ /** Keep the deprecated single-font view synchronized with the UI axis. */
207
+ function mergeThemeSelection(base: ThemeSelection, patch: Partial<ThemeSelection>): ThemeSelection {
208
+ const merged = { ...base, ...patch }
209
+ let fonts: AppearanceEditorFontSettings
210
+
211
+ if (patch.fonts !== undefined) {
212
+ fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
213
+ } else if (patch.font !== undefined) {
214
+ const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
215
+ fonts = normalizeAppearanceEditorFontSettings({
216
+ ...currentFonts,
217
+ ui: { ...currentFonts.ui, family: patch.font },
218
+ }).settings
219
+ } else {
220
+ fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
221
+ }
222
+
223
+ return { ...merged, fonts, font: fonts.ui.family }
224
+ }
225
+
195
226
  export type ThemeProviderProps = {
196
227
  children?: JSX.Element
197
228
  /** Where the preference is persisted. Distinct per application. */
@@ -217,10 +248,9 @@ export function ThemeProvider(props: ThemeProviderProps) {
217
248
  const [local] = splitProps(props, ['children', 'storageKey', 'initial', 'selection'])
218
249
  const storageKey = () => local.storageKey ?? 'adea-appearance'
219
250
 
220
- const [selection, setSelectionState] = createSignal<ThemeSelection>({
221
- ...readSelection(storageKey()),
222
- ...local.initial,
223
- })
251
+ const [selection, setSelectionState] = createSignal<ThemeSelection>(
252
+ mergeThemeSelection(readSelection(storageKey()), local.initial ?? {})
253
+ )
224
254
 
225
255
  // The controlled half of the contract. A patch from the owner replaces the
226
256
  // matching axes; the stored preference is untouched, since the owner is the one
@@ -228,7 +258,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
228
258
  // value — is what keeps this subscribed for the provider's lifetime.
229
259
  createEffect(() => {
230
260
  const incoming = local.selection
231
- if (incoming) setSelectionState((current) => ({ ...current, ...incoming }))
261
+ if (incoming) setSelectionState((current) => mergeThemeSelection(current, incoming))
232
262
  })
233
263
 
234
264
  const [systemAppearance, setSystemAppearance] = createSignal<ThemeAppearance>('light')
@@ -284,8 +314,10 @@ export function ThemeProvider(props: ThemeProviderProps) {
284
314
  if (!accent) root.removeAttribute('data-accent')
285
315
  else root.dataset['accent'] = selection().accent
286
316
 
287
- if (selection().font === 'space-grotesk') root.removeAttribute('data-font')
288
- else root.dataset['font'] = selection().font
317
+ applyAppearanceFontSettings(
318
+ root,
319
+ normalizeAppearanceEditorFontSettings(selection().fonts, selection().font).settings
320
+ )
289
321
 
290
322
  // Density is a pure token shift, so unlike the colour axes there is nothing to
291
323
  // compute: the attribute selects a block in `theme.css`. It is applied here for
@@ -306,7 +338,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
306
338
  themes: builtinThemes,
307
339
  themeAccents,
308
340
  setSelection(patch) {
309
- const next = { ...selection(), ...patch }
341
+ const next = mergeThemeSelection(selection(), patch)
310
342
  setSelectionState(next)
311
343
  try {
312
344
  preferenceStore()?.setItem(storageKey(), JSON.stringify(next))
@@ -339,10 +371,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
339
371
  * was found; `tests/theme-script.test.ts` pins it so it cannot come back.
340
372
  */
341
373
  export function inlineScriptLiteral(value: string): string {
342
- return JSON.stringify(value).replace(
343
- /[<>\u2028\u2029]/g,
344
- (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
345
- )
374
+ return safeScriptStringLiteral(value)
346
375
  }
347
376
 
348
377
  /**
@@ -365,5 +394,6 @@ export function inlineScriptLiteral(value: string): string {
365
394
  * documented there.
366
395
  */
367
396
  export function themeScript(storageKey = 'adea-appearance'): string {
368
- return `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.font&&p.font!=='space-grotesk')r.dataset.font=p.font;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`
397
+ const script = `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`
398
+ return `${script}${fontSettingsBootstrapScript(storageKey)}`
369
399
  }
@@ -156,7 +156,7 @@ export function CodeBlock(props: CodeBlockProps) {
156
156
  }}
157
157
  >
158
158
  <div class="flex items-center justify-between gap-2 px-3 py-1">
159
- <span class="truncate font-mono text-xs text-muted-foreground">{label()}</span>
159
+ <span class="truncate font-code text-code text-muted-foreground">{label()}</span>
160
160
  <Actions />
161
161
  </div>
162
162
  <div
@@ -170,7 +170,7 @@ export function CodeBlock(props: CodeBlockProps) {
170
170
  when={local.highlight}
171
171
  fallback={
172
172
  <pre class="m-0 px-3 py-2" style={{ 'line-height': local.lineHeight ?? '1.5' }}>
173
- <code class="font-mono text-xs">
173
+ <code class="font-code text-code">
174
174
  <Show when={local.showLineNumbers} fallback={local.code.replace(/\n$/, '')}>
175
175
  <For each={lines()}>
176
176
  {(line, index) => (
@@ -217,9 +217,9 @@ export function CodeBlock(props: CodeBlockProps) {
217
217
  * InlineCode.
218
218
  *
219
219
  * The `code` inside a sentence. It is a component rather than a class because it
220
- * has to hold three properties at once: it must not break the line's rhythm
221
- * (`font-size: 0.9em`, so it tracks the surrounding text), it must wrap rather
222
- * than overflow a narrow column (`overflow-wrap: anywhere` — an identifier with
220
+ * has to hold three properties at once: it uses the shared code family and size
221
+ * preference, and it wraps rather than overflows a narrow column
222
+ * (`overflow-wrap: anywhere` — an identifier with
223
223
  * no spaces is one long token), and it must be legible against whatever surface
224
224
  * it lands on, which is why the fill is a token and not a fixed grey.
225
225
  */
@@ -229,7 +229,7 @@ export function InlineCode(props: ComponentProps<'code'>) {
229
229
  return (
230
230
  <code
231
231
  class={cn(
232
- 'rounded-sm bg-muted px-[0.4em] py-[0.15em] font-mono text-[0.9em] text-foreground [overflow-wrap:anywhere]',
232
+ 'rounded-sm bg-muted px-[0.4em] py-[0.15em] font-code text-code text-foreground [overflow-wrap:anywhere]',
233
233
  local.class
234
234
  )}
235
235
  {...rest}
@@ -274,7 +274,7 @@ export function CommandShortcut(props: {
274
274
  return (
275
275
  <span
276
276
  class={cn(
277
- 'text-muted-foreground ms-auto font-mono text-2xs tracking-widest group-data-[selected=true]/command-item:text-foreground',
277
+ 'text-muted-foreground ms-auto font-code text-code tracking-widest group-data-[selected=true]/command-item:text-foreground',
278
278
  props.class
279
279
  )}
280
280
  >
@@ -90,7 +90,7 @@ export function ContextMenuItem(
90
90
  {local.shortcut ? (
91
91
  <span
92
92
  aria-hidden="true"
93
- class="text-muted-foreground ms-auto font-mono text-2xs tracking-widest"
93
+ class="text-muted-foreground ms-auto font-code text-code tracking-widest"
94
94
  >
95
95
  {local.shortcut}
96
96
  </span>
@@ -152,7 +152,7 @@ export function diffTargetPath(patch: string): string | null {
152
152
  return null
153
153
  }
154
154
 
155
- export const diffLineVariants = cva('flex items-baseline font-mono text-xs', {
155
+ export const diffLineVariants = cva('flex items-baseline font-code text-code', {
156
156
  variants: {
157
157
  kind: {
158
158
  add: 'bg-success-subtle text-foreground',
@@ -201,10 +201,10 @@ export function DiffBlock(props: DiffBlockProps) {
201
201
  >
202
202
  <div class="flex items-center justify-between gap-2 border-b border-border px-3 py-1">
203
203
  <span class="flex min-w-0 items-baseline gap-2">
204
- <span class="truncate font-mono text-xs text-foreground">{name()}</span>
204
+ <span class="truncate font-code text-code text-foreground">{name()}</span>
205
205
  {/* The counts are text, so the summary survives greyscale and a reader
206
206
  who cannot separate the two line tones still gets the shape of it. */}
207
- <span class="shrink-0 font-mono text-2xs text-muted-foreground">
207
+ <span class="shrink-0 font-code text-code text-muted-foreground">
208
208
  <span class="text-foreground">+{stats().added}</span>{' '}
209
209
  <span class="text-foreground">−{stats().removed}</span>
210
210
  </span>
@@ -223,13 +223,13 @@ export function DiffBlock(props: DiffBlockProps) {
223
223
  <div class={diffLineVariants({ kind: line.kind })}>
224
224
  <Show when={numbered(line, local.showLineNumbers)}>
225
225
  <span
226
- class="w-10 shrink-0 pr-2 text-right text-2xs text-muted-foreground tabular-nums select-none"
226
+ class="w-10 shrink-0 pr-2 text-right text-code text-muted-foreground tabular-nums select-none"
227
227
  aria-hidden="true"
228
228
  >
229
229
  {line.oldLine ?? ''}
230
230
  </span>
231
231
  <span
232
- class="w-10 shrink-0 pr-2 text-right text-2xs text-muted-foreground tabular-nums select-none"
232
+ class="w-10 shrink-0 pr-2 text-right text-code text-muted-foreground tabular-nums select-none"
233
233
  aria-hidden="true"
234
234
  >
235
235
  {line.newLine ?? ''}
@@ -271,13 +271,13 @@ export function DiffSummary(props: { patch: string; class?: string; onSelect?: (
271
271
  <button
272
272
  type="button"
273
273
  class={cn(
274
- 'flex w-full items-baseline justify-between gap-3 rounded-md px-2 py-1 text-left font-mono text-xs hover:bg-muted',
274
+ 'flex w-full items-baseline justify-between gap-3 rounded-md px-2 py-1 text-left font-code text-code hover:bg-muted',
275
275
  props.class
276
276
  )}
277
277
  onClick={() => props.onSelect?.()}
278
278
  >
279
279
  <span class="truncate text-foreground">{name()}</span>
280
- <span class="shrink-0 text-2xs">
280
+ <span class="shrink-0 text-code">
281
281
  <span class="text-foreground">+{stats().added}</span>{' '}
282
282
  <span class="text-foreground">−{stats().removed}</span>
283
283
  </span>
@@ -1,7 +1,7 @@
1
1
  import { DropdownMenu as KobalteDropdownMenu } from '@kobalte/core/dropdown-menu'
2
2
  import { Check, ChevronRight, Circle } from 'lucide-solid'
3
- import type { ComponentProps } from 'solid-js'
4
- import { splitProps } from 'solid-js'
3
+ import type { Accessor, ComponentProps } from 'solid-js'
4
+ import { createContext, splitProps, useContext } from 'solid-js'
5
5
  import {
6
6
  menuContentPadding,
7
7
  destructiveMenuItem,
@@ -14,6 +14,18 @@ import {
14
14
  } from '../../../lib/overlay'
15
15
  import { cn } from '../../../lib/utils'
16
16
 
17
+ type MenuOrientation = 'horizontal' | 'vertical'
18
+ const DropdownMenuOrientationContext = createContext<Accessor<MenuOrientation | undefined>>()
19
+ type HandlerEvent<Handler> = Handler extends (...args: infer Arguments) => void
20
+ ? Arguments[0]
21
+ : never
22
+ type DropdownMenuTriggerKeyEvent = HandlerEvent<
23
+ NonNullable<ComponentProps<typeof KobalteDropdownMenu.Trigger>['onKeyDown']>
24
+ >
25
+ type DropdownMenuTriggerPointerEvent = HandlerEvent<
26
+ NonNullable<ComponentProps<typeof KobalteDropdownMenu.Trigger>['onPointerDown']>
27
+ >
28
+
17
29
  /**
18
30
  * DropdownMenu.
19
31
  *
@@ -34,12 +46,138 @@ import { cn } from '../../../lib/utils'
34
46
  * with an AlertDialog is the caller's job.
35
47
  */
36
48
  export function DropdownMenu(props: ComponentProps<typeof KobalteDropdownMenu>) {
37
- const [local, rest] = splitProps(props, ['modal'])
38
- return <KobalteDropdownMenu modal={local.modal ?? false} {...rest} />
49
+ const [local, rest] = splitProps(props, ['modal', 'orientation'])
50
+ const orientation = () => local.orientation
51
+ return (
52
+ <DropdownMenuOrientationContext.Provider value={orientation}>
53
+ <KobalteDropdownMenu modal={local.modal ?? false} orientation={local.orientation} {...rest} />
54
+ </DropdownMenuOrientationContext.Provider>
55
+ )
56
+ }
57
+
58
+ const SCROLLABLE_OVERFLOW = /(auto|scroll)/
59
+ const OVERFLOW_PROPERTIES = ['overflow', 'overflow-x', 'overflow-y'] as const
60
+
61
+ type OverflowProperty = (typeof OVERFLOW_PROPERTIES)[number]
62
+ type OverflowDeclaration = Readonly<{
63
+ property: OverflowProperty
64
+ value: string
65
+ priority: string
66
+ }>
67
+
68
+ function readInlineOverflow(element: HTMLElement): OverflowDeclaration[] {
69
+ return OVERFLOW_PROPERTIES.map((property) => ({
70
+ property,
71
+ value: element.style.getPropertyValue(property),
72
+ priority: element.style.getPropertyPriority(property),
73
+ }))
74
+ }
75
+
76
+ function declarationsMatch(element: HTMLElement, declarations: OverflowDeclaration[]) {
77
+ return declarations.every(
78
+ ({ property, value, priority }) =>
79
+ element.style.getPropertyValue(property) === value &&
80
+ element.style.getPropertyPriority(property) === priority
81
+ )
82
+ }
83
+
84
+ function bypassUnboundedScrollLookup(trigger: HTMLElement) {
85
+ const scrollRoot = document.scrollingElement ?? document.documentElement
86
+ if (window.getComputedStyle(scrollRoot).overflow !== 'hidden') return
87
+
88
+ const temporaryStyles: Array<{
89
+ element: HTMLElement
90
+ original: OverflowDeclaration[]
91
+ applied: OverflowDeclaration[]
92
+ }> = []
93
+
94
+ for (let element: HTMLElement | null = trigger; element;) {
95
+ const parent: HTMLElement | null = element.parentElement
96
+ if (element !== scrollRoot) {
97
+ const computed = window.getComputedStyle(element)
98
+ const overflow = `${computed.overflow} ${computed.overflowX} ${computed.overflowY}`
99
+ if (SCROLLABLE_OVERFLOW.test(overflow)) {
100
+ const original = readInlineOverflow(element)
101
+ element.style.setProperty('overflow', 'clip', 'important')
102
+ temporaryStyles.push({ element, original, applied: readInlineOverflow(element) })
103
+ }
104
+ }
105
+ element = parent
106
+ }
107
+
108
+ if (temporaryStyles.length === 0) return
109
+
110
+ // Kobalte's keyboard path synchronously walks scroll parents before it opens
111
+ // the menu. Clipping only non-root scroll ancestors makes that walk stop at
112
+ // the locked viewport. Restore those declarations once the event handler
113
+ // finishes, and leave any newer owner write untouched.
114
+ queueMicrotask(() => {
115
+ for (const { element, original, applied } of temporaryStyles) {
116
+ if (!element.isConnected || !declarationsMatch(element, applied)) continue
117
+ for (const property of OVERFLOW_PROPERTIES) element.style.removeProperty(property)
118
+ for (const { property, value, priority } of original) {
119
+ if (value) element.style.setProperty(property, value, priority)
120
+ }
121
+ }
122
+ })
39
123
  }
40
124
 
41
125
  export function DropdownMenuTrigger(props: ComponentProps<typeof KobalteDropdownMenu.Trigger>) {
42
- return <KobalteDropdownMenu.Trigger {...props} />
126
+ const inheritedOrientation = useContext(DropdownMenuOrientationContext)
127
+ const [local, rest] = splitProps(props, ['disabled', 'onKeyDown', 'onPointerDown'])
128
+ const onPointerDown = (event: DropdownMenuTriggerPointerEvent) => {
129
+ if (typeof local.onPointerDown === 'function') local.onPointerDown(event)
130
+ else if (local.onPointerDown) local.onPointerDown[0](local.onPointerDown[1], event)
131
+
132
+ // WebKit can leave focus on a previous control when a native button gets
133
+ // a pointer click. Focus the trigger before Kobalte opens the menu so an
134
+ // enclosing modal focus scope does not restore focus and dismiss the menu.
135
+ if (
136
+ local.disabled ||
137
+ event.defaultPrevented ||
138
+ !event.isPrimary ||
139
+ event.button !== 0 ||
140
+ event.altKey ||
141
+ event.ctrlKey ||
142
+ event.metaKey ||
143
+ event.shiftKey
144
+ ) {
145
+ return
146
+ }
147
+ const trigger = event.currentTarget
148
+ const isNativeLink = trigger instanceof HTMLAnchorElement && trigger.hasAttribute('href')
149
+ if (trigger instanceof HTMLElement && !isNativeLink) {
150
+ trigger.focus({ preventScroll: true })
151
+ }
152
+ }
153
+ const onKeyDown = (event: DropdownMenuTriggerKeyEvent) => {
154
+ // Kobalte's MenuTrigger does not consult defaultPrevented before its own
155
+ // open-key handling. Preserve its existing callback behavior; this wrapper
156
+ // only makes the pre-open scroll-parent lookup terminate.
157
+ if (typeof local.onKeyDown === 'function') local.onKeyDown(event)
158
+ else if (local.onKeyDown) local.onKeyDown[0](local.onKeyDown[1], event)
159
+
160
+ if (local.disabled) return
161
+ const trigger = event.currentTarget
162
+ if (!(trigger instanceof HTMLElement)) return
163
+ const menubarOrientation = trigger.closest('[role="menubar"]')?.getAttribute('aria-orientation')
164
+ const orientation =
165
+ inheritedOrientation?.() ?? (menubarOrientation === 'vertical' ? 'vertical' : 'horizontal')
166
+ const openingArrow = orientation === 'horizontal' ? 'ArrowDown' : 'ArrowRight'
167
+ if (!['Enter', ' ', openingArrow].includes(event.key)) return
168
+ const isNativeLink = trigger instanceof HTMLAnchorElement && trigger.hasAttribute('href')
169
+ if (isNativeLink && (event.key === 'Enter' || event.key === ' ')) return
170
+ bypassUnboundedScrollLookup(trigger)
171
+ }
172
+
173
+ return (
174
+ <KobalteDropdownMenu.Trigger
175
+ {...rest}
176
+ disabled={local.disabled}
177
+ onPointerDown={onPointerDown}
178
+ onKeyDown={onKeyDown}
179
+ />
180
+ )
43
181
  }
44
182
 
45
183
  export function DropdownMenuPortal(props: ComponentProps<typeof KobalteDropdownMenu.Portal>) {
@@ -113,7 +251,7 @@ export function DropdownMenuItem(
113
251
  {local.shortcut ? (
114
252
  <span
115
253
  aria-hidden="true"
116
- class="text-muted-foreground ms-auto font-mono text-2xs tracking-widest"
254
+ class="text-muted-foreground ms-auto font-code text-code tracking-widest"
117
255
  >
118
256
  {local.shortcut}
119
257
  </span>
@@ -139,7 +277,7 @@ export function DropdownMenuShortcut(props: ComponentProps<'span'>) {
139
277
  <span
140
278
  data-slot="dropdown-menu-shortcut"
141
279
  aria-hidden="true"
142
- class={cn('text-muted-foreground ms-auto font-mono text-2xs tracking-widest', local.class)}
280
+ class={cn('text-muted-foreground ms-auto font-code text-code tracking-widest', local.class)}
143
281
  {...rest}
144
282
  />
145
283
  )