@nordmanai/nif 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 (253) hide show
  1. package/README.md +18 -0
  2. package/bin/nif.mjs +196 -0
  3. package/dist/index.d.ts +30 -1
  4. package/dist/index.js +9661 -2499
  5. package/dist/styles.css +1 -1
  6. package/dist/tokens.css +4 -1
  7. package/dist/ui/components/nm-ai/nm-ai.d.ts +7 -7
  8. package/dist/ui/components/nm-brand-logo/nm-brand-logo.d.ts +10 -0
  9. package/dist/ui/components/nm-breadcrumb/nm-breadcrumb.d.ts +11 -0
  10. package/dist/ui/components/nm-button/nm-button.d.ts +2 -0
  11. package/dist/ui/components/nm-button-group/nm-button-group.d.ts +17 -0
  12. package/dist/ui/components/nm-calendar/nm-calendar.d.ts +3 -0
  13. package/dist/ui/components/nm-card/nm-card.d.ts +34 -0
  14. package/dist/ui/components/nm-chip/nm-chip.d.ts +38 -0
  15. package/dist/ui/components/nm-code-block/nm-code-block.d.ts +55 -0
  16. package/dist/ui/components/nm-color-picker/nm-color-picker.d.ts +15 -0
  17. package/dist/ui/components/nm-combobox/nm-combobox.d.ts +26 -0
  18. package/dist/ui/components/nm-command/nm-command.d.ts +37 -0
  19. package/dist/ui/components/nm-composer/nm-composer.d.ts +4 -0
  20. package/dist/ui/components/nm-context-menu/nm-context-menu.d.ts +9 -0
  21. package/dist/ui/components/nm-date-picker/nm-date-picker.d.ts +35 -0
  22. package/dist/ui/components/nm-dialog/nm-dialog.d.ts +13 -0
  23. package/dist/ui/components/nm-field/nm-field.d.ts +2 -0
  24. package/dist/ui/components/nm-form-field/nm-form-field.d.ts +18 -0
  25. package/dist/ui/components/nm-gallery/nm-gallery.d.ts +8 -0
  26. package/dist/ui/components/nm-input/nm-input.d.ts +5 -0
  27. package/dist/ui/components/nm-input-group/nm-input-group.d.ts +9 -0
  28. package/dist/ui/components/nm-label/nm-label.d.ts +2 -0
  29. package/dist/ui/components/nm-menubar/nm-menubar.d.ts +9 -0
  30. package/dist/ui/components/nm-native-select/nm-native-select.d.ts +5 -0
  31. package/dist/ui/components/nm-panel/nm-panel.d.ts +14 -0
  32. package/dist/ui/components/nm-prose/nm-prose.d.ts +6 -0
  33. package/dist/ui/components/nm-resize-handle/nm-resize-handle.d.ts +18 -0
  34. package/dist/ui/components/nm-select/nm-select.d.ts +22 -0
  35. package/dist/ui/components/nm-side-panel/nm-side-panel.d.ts +33 -0
  36. package/dist/ui/components/nm-slider/nm-slider.d.ts +5 -1
  37. package/dist/ui/components/nm-status-bar/nm-status-bar.d.ts +8 -0
  38. package/dist/ui/components/nm-table/nm-table.d.ts +38 -0
  39. package/dist/ui/components/nm-textarea/nm-textarea.d.ts +2 -0
  40. package/dist/ui/components/nm-toolbar/nm-toolbar.d.ts +21 -0
  41. package/dist/ui/config/nm-ai.d.ts +0 -3
  42. package/dist/ui/config/nm-appearance.d.ts +36 -1
  43. package/dist/ui/config/nm-button.d.ts +2 -1
  44. package/dist/ui/config/nm-composer.d.ts +1 -2
  45. package/dist/ui/config/nm-field.d.ts +2 -0
  46. package/dist/ui/config/nm-icons.d.ts +13 -1
  47. package/dist/ui/config/nm-radius.d.ts +4 -4
  48. package/dist/ui/config/nm-side-panel.d.ts +33 -0
  49. package/dist/ui/lib/appearance/appearanceState.d.ts +1 -0
  50. package/dist/ui/lib/appearance/deriveTokens.d.ts +2 -0
  51. package/package.json +12 -4
  52. package/registry/index.json +1634 -0
  53. package/registry/tokens.css +72 -0
  54. package/registry/ui/components/nm-ai/nm-ai.css +358 -0
  55. package/registry/ui/components/nm-ai/nm-ai.tsx +632 -0
  56. package/registry/ui/components/nm-app-panel/nm-app-panel.css +169 -0
  57. package/registry/ui/components/nm-ask/nm-ask.css +277 -0
  58. package/registry/ui/components/nm-avatar/nm-avatar.css +85 -0
  59. package/registry/ui/components/nm-avatar/nm-avatar.tsx +30 -0
  60. package/registry/ui/components/nm-bar-overflow/nm-bar-overflow.css +43 -0
  61. package/registry/ui/components/nm-bar-overflow/nm-bar-overflow.tsx +61 -0
  62. package/registry/ui/components/nm-bar-overflow/use-bar-overflow.ts +92 -0
  63. package/registry/ui/components/nm-brand-logo/nm-brand-logo.css +20 -0
  64. package/registry/ui/components/nm-brand-logo/nm-brand-logo.tsx +21 -0
  65. package/registry/ui/components/nm-breadcrumb/nm-breadcrumb.css +57 -0
  66. package/registry/ui/components/nm-breadcrumb/nm-breadcrumb.tsx +42 -0
  67. package/registry/ui/components/nm-button/nm-button.css +110 -0
  68. package/registry/ui/components/nm-button/nm-button.tsx +58 -0
  69. package/registry/ui/components/nm-button-group/nm-button-group.css +87 -0
  70. package/registry/ui/components/nm-button-group/nm-button-group.tsx +32 -0
  71. package/registry/ui/components/nm-calendar/nm-calendar.css +178 -0
  72. package/registry/ui/components/nm-calendar/nm-calendar.tsx +43 -0
  73. package/registry/ui/components/nm-card/nm-card.css +130 -0
  74. package/registry/ui/components/nm-card/nm-card.tsx +83 -0
  75. package/registry/ui/components/nm-chatbox/nm-chatbox-icon.tsx +39 -0
  76. package/registry/ui/components/nm-chatbox/nm-chatbox.css +682 -0
  77. package/registry/ui/components/nm-chatbox/nm-chatbox.tsx +101 -0
  78. package/registry/ui/components/nm-checkbox/nm-checkbox.css +61 -0
  79. package/registry/ui/components/nm-checkbox/nm-checkbox.tsx +14 -0
  80. package/registry/ui/components/nm-chip/nm-chip.css +112 -0
  81. package/registry/ui/components/nm-chip/nm-chip.tsx +86 -0
  82. package/registry/ui/components/nm-code-block/nm-code-block.css +240 -0
  83. package/registry/ui/components/nm-code-block/nm-code-block.tsx +196 -0
  84. package/registry/ui/components/nm-collection/nm-collection-bar.tsx +65 -0
  85. package/registry/ui/components/nm-collection/nm-collection-grid.tsx +69 -0
  86. package/registry/ui/components/nm-collection/nm-collection-query.tsx +99 -0
  87. package/registry/ui/components/nm-collection/nm-collection-row-menu.tsx +55 -0
  88. package/registry/ui/components/nm-collection/nm-collection-table.tsx +99 -0
  89. package/registry/ui/components/nm-collection/nm-collection-tile-actions.tsx +50 -0
  90. package/registry/ui/components/nm-collection/nm-collection-title.tsx +25 -0
  91. package/registry/ui/components/nm-collection/nm-collection.css +353 -0
  92. package/registry/ui/components/nm-collection/use-collection-scroll-reveal.ts +38 -0
  93. package/registry/ui/components/nm-color-picker/nm-color-picker.css +67 -0
  94. package/registry/ui/components/nm-color-picker/nm-color-picker.tsx +79 -0
  95. package/registry/ui/components/nm-combobox/nm-combobox.css +72 -0
  96. package/registry/ui/components/nm-combobox/nm-combobox.tsx +80 -0
  97. package/registry/ui/components/nm-command/nm-command.css +190 -0
  98. package/registry/ui/components/nm-command/nm-command.tsx +125 -0
  99. package/registry/ui/components/nm-composer/nm-composer.css +178 -0
  100. package/registry/ui/components/nm-composer/nm-composer.tsx +151 -0
  101. package/registry/ui/components/nm-context-menu/nm-context-menu.tsx +25 -0
  102. package/registry/ui/components/nm-date-picker/nm-date-picker.css +140 -0
  103. package/registry/ui/components/nm-date-picker/nm-date-picker.tsx +153 -0
  104. package/registry/ui/components/nm-dialog/nm-dialog.css +84 -0
  105. package/registry/ui/components/nm-dialog/nm-dialog.tsx +40 -0
  106. package/registry/ui/components/nm-dropdown/nm-dropdown-item.ts +68 -0
  107. package/registry/ui/components/nm-dropdown/nm-dropdown-options.tsx +242 -0
  108. package/registry/ui/components/nm-dropdown/nm-dropdown-panel.tsx +171 -0
  109. package/registry/ui/components/nm-dropdown/nm-dropdown-placement.ts +157 -0
  110. package/registry/ui/components/nm-dropdown/nm-dropdown.css +312 -0
  111. package/registry/ui/components/nm-dropdown/nm-dropdown.tsx +412 -0
  112. package/registry/ui/components/nm-dropdown-menu/nm-dropdown-menu.css +145 -0
  113. package/registry/ui/components/nm-dropdown-menu/nm-dropdown-menu.tsx +118 -0
  114. package/registry/ui/components/nm-field/nm-field.css +356 -0
  115. package/registry/ui/components/nm-field/nm-field.tsx +355 -0
  116. package/registry/ui/components/nm-form-field/nm-form-field.css +103 -0
  117. package/registry/ui/components/nm-form-field/nm-form-field.tsx +71 -0
  118. package/registry/ui/components/nm-gallery/nm-gallery.css +66 -0
  119. package/registry/ui/components/nm-gallery/nm-gallery.tsx +31 -0
  120. package/registry/ui/components/nm-hover-card/nm-hover-card.css +26 -0
  121. package/registry/ui/components/nm-hover-card/nm-hover-card.tsx +28 -0
  122. package/registry/ui/components/nm-icon/nm-icon.css +6 -0
  123. package/registry/ui/components/nm-icon/nm-icon.tsx +69 -0
  124. package/registry/ui/components/nm-icon/nm-lucide.tsx +180 -0
  125. package/registry/ui/components/nm-icon-border/nm-icon-border.tsx +56 -0
  126. package/registry/ui/components/nm-input/nm-input.css +75 -0
  127. package/registry/ui/components/nm-input/nm-input.tsx +7 -0
  128. package/registry/ui/components/nm-input-group/nm-input-group.css +66 -0
  129. package/registry/ui/components/nm-input-group/nm-input-group.tsx +20 -0
  130. package/registry/ui/components/nm-label/nm-label.css +11 -0
  131. package/registry/ui/components/nm-label/nm-label.tsx +6 -0
  132. package/registry/ui/components/nm-list-item/nm-list-item.css +177 -0
  133. package/registry/ui/components/nm-list-item/nm-list-item.tsx +112 -0
  134. package/registry/ui/components/nm-menubar/nm-menubar.css +32 -0
  135. package/registry/ui/components/nm-menubar/nm-menubar.tsx +19 -0
  136. package/registry/ui/components/nm-message/nm-message-actions.tsx +24 -0
  137. package/registry/ui/components/nm-message/nm-message-meta.tsx +49 -0
  138. package/registry/ui/components/nm-message/nm-message.css +686 -0
  139. package/registry/ui/components/nm-message/nm-text-shimmer.tsx +6 -0
  140. package/registry/ui/components/nm-message/nm-trace.tsx +219 -0
  141. package/registry/ui/components/nm-modal/nm-modal.css +763 -0
  142. package/registry/ui/components/nm-modal/nm-modal.tsx +497 -0
  143. package/registry/ui/components/nm-native-select/nm-native-select.css +47 -0
  144. package/registry/ui/components/nm-native-select/nm-native-select.tsx +23 -0
  145. package/registry/ui/components/nm-panel/nm-panel.css +73 -0
  146. package/registry/ui/components/nm-panel/nm-panel.tsx +35 -0
  147. package/registry/ui/components/nm-panel-bar/nm-panel-bar.css +20 -0
  148. package/registry/ui/components/nm-panel-bar/nm-panel-bar.tsx +48 -0
  149. package/registry/ui/components/nm-popover/nm-popover.css +43 -0
  150. package/registry/ui/components/nm-popover/nm-popover.tsx +40 -0
  151. package/registry/ui/components/nm-progress/nm-progress.css +21 -0
  152. package/registry/ui/components/nm-progress/nm-progress.tsx +14 -0
  153. package/registry/ui/components/nm-prose/nm-prose.css +81 -0
  154. package/registry/ui/components/nm-prose/nm-prose.tsx +10 -0
  155. package/registry/ui/components/nm-questionnaire/nm-questionnaire.css +182 -0
  156. package/registry/ui/components/nm-questionnaire/nm-questionnaire.tsx +221 -0
  157. package/registry/ui/components/nm-radio-group/nm-radio-group.css +48 -0
  158. package/registry/ui/components/nm-radio-group/nm-radio-group.tsx +15 -0
  159. package/registry/ui/components/nm-rail-icon/nm-rail-icon.css +6 -0
  160. package/registry/ui/components/nm-rail-icon/nm-rail-icon.tsx +28 -0
  161. package/registry/ui/components/nm-resize-handle/nm-resize-handle.css +45 -0
  162. package/registry/ui/components/nm-resize-handle/nm-resize-handle.tsx +102 -0
  163. package/registry/ui/components/nm-segmented/nm-segmented.css +104 -0
  164. package/registry/ui/components/nm-segmented/nm-segmented.tsx +77 -0
  165. package/registry/ui/components/nm-select/nm-select.css +78 -0
  166. package/registry/ui/components/nm-select/nm-select.tsx +64 -0
  167. package/registry/ui/components/nm-separator/nm-separator.css +14 -0
  168. package/registry/ui/components/nm-separator/nm-separator.tsx +6 -0
  169. package/registry/ui/components/nm-side-panel/nm-side-panel.css +93 -0
  170. package/registry/ui/components/nm-side-panel/nm-side-panel.tsx +86 -0
  171. package/registry/ui/components/nm-slider/nm-slider.css +53 -0
  172. package/registry/ui/components/nm-slider/nm-slider.tsx +40 -0
  173. package/registry/ui/components/nm-small-icon-button/nm-small-icon-button.tsx +66 -0
  174. package/registry/ui/components/nm-status-bar/nm-status-bar.css +50 -0
  175. package/registry/ui/components/nm-status-bar/nm-status-bar.tsx +28 -0
  176. package/registry/ui/components/nm-switch/nm-switch.css +45 -0
  177. package/registry/ui/components/nm-switch/nm-switch.tsx +10 -0
  178. package/registry/ui/components/nm-table/nm-table.css +155 -0
  179. package/registry/ui/components/nm-table/nm-table.tsx +95 -0
  180. package/registry/ui/components/nm-tabs/nm-tabs.css +28 -0
  181. package/registry/ui/components/nm-tabs/nm-tabs.tsx +32 -0
  182. package/registry/ui/components/nm-textarea/nm-textarea.css +41 -0
  183. package/registry/ui/components/nm-textarea/nm-textarea.tsx +6 -0
  184. package/registry/ui/components/nm-toggle/nm-toggle.css +55 -0
  185. package/registry/ui/components/nm-toggle/nm-toggle.tsx +16 -0
  186. package/registry/ui/components/nm-toolbar/nm-toolbar.css +42 -0
  187. package/registry/ui/components/nm-toolbar/nm-toolbar.tsx +54 -0
  188. package/registry/ui/components/nm-tooltip/nm-tooltip.css +37 -0
  189. package/registry/ui/components/nm-tooltip/nm-tooltip.tsx +35 -0
  190. package/registry/ui/components/nm-top-bar/nm-top-bar.css +29 -0
  191. package/registry/ui/components/nm-top-bar/nm-top-bar.tsx +34 -0
  192. package/registry/ui/components/nordman-icon-button/nordman-icon-button.css +119 -0
  193. package/registry/ui/components/nordman-icon-button/nordman-icon-button.tsx +185 -0
  194. package/registry/ui/components/nordman-login/nordman-login.css +195 -0
  195. package/registry/ui/components/nordman-login/nordman-login.tsx +136 -0
  196. package/registry/ui/components/nordman-login-field/nordman-login-field.tsx +55 -0
  197. package/registry/ui/components/nordman-login-forgot/nordman-login-forgot.tsx +56 -0
  198. package/registry/ui/components/nordman-login-google/nordman-login-google.tsx +28 -0
  199. package/registry/ui/components/nordman-login-hero/nordman-login-hero.tsx +38 -0
  200. package/registry/ui/components/nordman-login-register/nordman-login-register.tsx +70 -0
  201. package/registry/ui/components/nordman-login-reset/nordman-login-reset.tsx +71 -0
  202. package/registry/ui/components/nordman-login-reveal/nordman-login-reveal.tsx +41 -0
  203. package/registry/ui/components/nordman-login-sign-in/nordman-login-sign-in.tsx +69 -0
  204. package/registry/ui/components/nordman-toggle/nordman-toggle.css +28 -0
  205. package/registry/ui/components/nordman-toggle/nordman-toggle.tsx +35 -0
  206. package/registry/ui/config/nm-ai.ts +59 -0
  207. package/registry/ui/config/nm-appearance.ts +275 -0
  208. package/registry/ui/config/nm-bar-overflow.ts +10 -0
  209. package/registry/ui/config/nm-button.ts +38 -0
  210. package/registry/ui/config/nm-chatbox-icons.ts +67 -0
  211. package/registry/ui/config/nm-chatbox.ts +42 -0
  212. package/registry/ui/config/nm-collection.ts +101 -0
  213. package/registry/ui/config/nm-composer.ts +39 -0
  214. package/registry/ui/config/nm-dropdown.ts +62 -0
  215. package/registry/ui/config/nm-field.ts +43 -0
  216. package/registry/ui/config/nm-icon-button.ts +35 -0
  217. package/registry/ui/config/nm-icons.ts +417 -0
  218. package/registry/ui/config/nm-list-item.ts +14 -0
  219. package/registry/ui/config/nm-message.ts +106 -0
  220. package/registry/ui/config/nm-modal.ts +76 -0
  221. package/registry/ui/config/nm-panel-bar.ts +11 -0
  222. package/registry/ui/config/nm-questionnaire.ts +53 -0
  223. package/registry/ui/config/nm-radius.ts +56 -0
  224. package/registry/ui/config/nm-rail-icons.ts +27 -0
  225. package/registry/ui/config/nm-segmented.ts +19 -0
  226. package/registry/ui/config/nm-side-panel.ts +16 -0
  227. package/registry/ui/config/nm-site-palette.ts +30 -0
  228. package/registry/ui/config/nm-small-icon-button.ts +17 -0
  229. package/registry/ui/config/nm-tabs.ts +9 -0
  230. package/registry/ui/config/nm-top-bar.ts +4 -0
  231. package/registry/ui/config/nordman-chrome.ts +14 -0
  232. package/registry/ui/config/nordman-login.ts +28 -0
  233. package/registry/ui/content/nm-modal.ts +5 -0
  234. package/registry/ui/content/nordman-login.ts +85 -0
  235. package/registry/ui/kit-logger.ts +1 -0
  236. package/registry/ui/lib/appearance/appearanceState.ts +240 -0
  237. package/registry/ui/lib/appearance/applyAppearance.ts +36 -0
  238. package/registry/ui/lib/appearance/cloudOrbAppearance.ts +36 -0
  239. package/registry/ui/lib/appearance/color.ts +96 -0
  240. package/registry/ui/lib/appearance/deriveTokens.ts +218 -0
  241. package/registry/ui/lib/appearance/index.ts +6 -0
  242. package/registry/ui/lib/appearance/markTokens.ts +49 -0
  243. package/registry/ui/lib/appearance/readLayoutMetrics.ts +28 -0
  244. package/registry/ui/lib/bar-overflow.ts +41 -0
  245. package/registry/ui/lib/logger.ts +53 -0
  246. package/registry/ui/lib/menu-motion.ts +187 -0
  247. package/registry/ui/lib/nm-class.ts +4 -0
  248. package/registry/ui/lib/nordman-ui-locale.ts +43 -0
  249. package/registry/ui/styles/interface-theme.css +196 -0
  250. package/registry/ui/styles/light-appearance.css +72 -0
  251. package/registry/ui/styles/tokens.css +424 -0
  252. package/registry/ui/styles/utilities.css +12 -0
  253. package/dist/ui/components/nm-accordion/nm-accordion.d.ts +0 -6
@@ -0,0 +1,240 @@
1
+ import {
2
+ NM_ACCENT_PRESETS,
3
+ NM_APPEARANCE_CONTRAST,
4
+ NM_APPEARANCE_DEFAULTS,
5
+ NM_APPEARANCE_MODES,
6
+ NM_APPEARANCE_STORAGE_KEY,
7
+ NM_UI_FONT_SIZE_PX,
8
+ NM_UI_FONT_WEIGHT,
9
+ NM_UI_ICON_SIZE_PX,
10
+ NM_UI_RADIUS_PERCENT,
11
+ NM_UI_SPACE_PERCENT,
12
+ findThemePreset,
13
+ type NmAccentPreset,
14
+ type NmAppearanceMode,
15
+ } from '../../config/nm-appearance.ts';
16
+ import { normalizeHexColor } from './color.ts';
17
+
18
+ export type NmAppearanceState = {
19
+ mode: NmAppearanceMode;
20
+ themePresetId: string;
21
+ accentPreset: NmAccentPreset;
22
+ customAccentHex: string;
23
+ backgroundHex: string;
24
+ foregroundHex: string;
25
+ uiFontSizePx: number;
26
+ uiFontWeight: number;
27
+ uiIconSizePx: number;
28
+ uiRadiusPercent: number;
29
+ uiSpacePercent: number;
30
+ contrast: number;
31
+ };
32
+
33
+ export { NM_APPEARANCE_STORAGE_KEY };
34
+
35
+ function isMode(value: unknown): value is NmAppearanceMode {
36
+ return typeof value === 'string' && (NM_APPEARANCE_MODES as readonly string[]).includes(value);
37
+ }
38
+
39
+ function isAccentPreset(value: unknown): value is NmAccentPreset {
40
+ return typeof value === 'string' && (NM_ACCENT_PRESETS as readonly string[]).includes(value);
41
+ }
42
+
43
+ function clampInt(value: unknown, min: number, max: number, fallback: number): number {
44
+ const n = typeof value === 'number' ? value : Number(value);
45
+ if (!Number.isFinite(n)) {
46
+ return fallback;
47
+ }
48
+ return Math.min(max, Math.max(min, Math.round(n)));
49
+ }
50
+
51
+ function prefersDarkColorScheme(): boolean {
52
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
53
+ return true;
54
+ }
55
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
56
+ }
57
+
58
+ export function resolveAppearanceScheme(
59
+ mode: NmAppearanceMode,
60
+ prefersDark: boolean = prefersDarkColorScheme(),
61
+ ): 'light' | 'dark' {
62
+ if (mode === 'light') {
63
+ return 'light';
64
+ }
65
+ if (mode === 'dark') {
66
+ return 'dark';
67
+ }
68
+ return prefersDark ? 'dark' : 'light';
69
+ }
70
+
71
+ export function createDefaultAppearanceState(): NmAppearanceState {
72
+ const base: NmAppearanceState = {
73
+ mode: NM_APPEARANCE_DEFAULTS.mode,
74
+ themePresetId: NM_APPEARANCE_DEFAULTS.themePresetId,
75
+ accentPreset: NM_APPEARANCE_DEFAULTS.accentPreset,
76
+ customAccentHex: NM_APPEARANCE_DEFAULTS.customAccentHex,
77
+ backgroundHex: NM_APPEARANCE_DEFAULTS.backgroundHex,
78
+ foregroundHex: NM_APPEARANCE_DEFAULTS.foregroundHex,
79
+ uiFontSizePx: NM_APPEARANCE_DEFAULTS.uiFontSizePx,
80
+ uiFontWeight: NM_APPEARANCE_DEFAULTS.uiFontWeight,
81
+ uiIconSizePx: NM_APPEARANCE_DEFAULTS.uiIconSizePx,
82
+ uiRadiusPercent: NM_APPEARANCE_DEFAULTS.uiRadiusPercent,
83
+ uiSpacePercent: NM_APPEARANCE_DEFAULTS.uiSpacePercent,
84
+ contrast: NM_APPEARANCE_CONTRAST.default,
85
+ };
86
+ return applyThemePresetPalette(base);
87
+ }
88
+
89
+ export function paletteForPreset(
90
+ themePresetId: string,
91
+ scheme: 'light' | 'dark',
92
+ ): { backgroundHex: string; foregroundHex: string } {
93
+ const preset =
94
+ findThemePreset(themePresetId) ?? findThemePreset(NM_APPEARANCE_DEFAULTS.themePresetId)!;
95
+ const pair = scheme === 'dark' ? preset.dark : preset.light;
96
+ return {
97
+ backgroundHex: pair.canvas,
98
+ foregroundHex: pair.foreground,
99
+ };
100
+ }
101
+
102
+ /** Saved settings from before the website palette keep following the Nordman theme. */
103
+ const LEGACY_NORDMAN_COLORS: Readonly<Record<string, 'light' | 'dark'>> = {
104
+ '#000000/#ffffff': 'dark',
105
+ '#fcfaf6/#0a0a0a': 'light',
106
+ };
107
+
108
+ function currentNordmanColors(
109
+ themePresetId: string,
110
+ backgroundHex: string,
111
+ foregroundHex: string,
112
+ ): { backgroundHex: string; foregroundHex: string } {
113
+ const key = `${backgroundHex}/${foregroundHex}`.toLowerCase();
114
+ const scheme = LEGACY_NORDMAN_COLORS[key];
115
+ if (themePresetId !== NM_APPEARANCE_DEFAULTS.themePresetId || scheme == null) {
116
+ return { backgroundHex, foregroundHex };
117
+ }
118
+ return paletteForPreset(themePresetId, scheme);
119
+ }
120
+
121
+ export function parseAppearanceState(raw: unknown): NmAppearanceState {
122
+ const defaults = createDefaultAppearanceState();
123
+ if (!raw || typeof raw !== 'object') {
124
+ return defaults;
125
+ }
126
+ const record = raw as Record<string, unknown>;
127
+ const themePresetId =
128
+ typeof record.themePresetId === 'string' && findThemePreset(record.themePresetId)
129
+ ? record.themePresetId
130
+ : defaults.themePresetId;
131
+
132
+ const colors = currentNordmanColors(
133
+ themePresetId,
134
+ normalizeHexColor(
135
+ typeof record.backgroundHex === 'string' ? record.backgroundHex : defaults.backgroundHex,
136
+ defaults.backgroundHex,
137
+ ),
138
+ normalizeHexColor(
139
+ typeof record.foregroundHex === 'string' ? record.foregroundHex : defaults.foregroundHex,
140
+ defaults.foregroundHex,
141
+ ),
142
+ );
143
+
144
+ return {
145
+ mode: isMode(record.mode) ? record.mode : defaults.mode,
146
+ themePresetId,
147
+ accentPreset: isAccentPreset(record.accentPreset) ? record.accentPreset : defaults.accentPreset,
148
+ customAccentHex: normalizeHexColor(
149
+ typeof record.customAccentHex === 'string'
150
+ ? record.customAccentHex
151
+ : defaults.customAccentHex,
152
+ defaults.customAccentHex,
153
+ ),
154
+ ...colors,
155
+ uiFontSizePx: clampInt(
156
+ record.uiFontSizePx,
157
+ NM_UI_FONT_SIZE_PX.min,
158
+ NM_UI_FONT_SIZE_PX.max,
159
+ defaults.uiFontSizePx,
160
+ ),
161
+ uiFontWeight: Math.round(
162
+ clampInt(record.uiFontWeight, NM_UI_FONT_WEIGHT.min, NM_UI_FONT_WEIGHT.max, defaults.uiFontWeight) / 100,
163
+ ) * 100,
164
+ uiIconSizePx: clampInt(
165
+ record.uiIconSizePx,
166
+ NM_UI_ICON_SIZE_PX.min,
167
+ NM_UI_ICON_SIZE_PX.max,
168
+ defaults.uiIconSizePx,
169
+ ),
170
+ uiRadiusPercent: clampInt(
171
+ record.uiRadiusPercent,
172
+ NM_UI_RADIUS_PERCENT.min,
173
+ NM_UI_RADIUS_PERCENT.max,
174
+ defaults.uiRadiusPercent,
175
+ ),
176
+ uiSpacePercent: clampInt(
177
+ record.uiSpacePercent,
178
+ NM_UI_SPACE_PERCENT.min,
179
+ NM_UI_SPACE_PERCENT.max,
180
+ defaults.uiSpacePercent,
181
+ ),
182
+ contrast: clampInt(
183
+ record.contrast,
184
+ NM_APPEARANCE_CONTRAST.min,
185
+ NM_APPEARANCE_CONTRAST.max,
186
+ defaults.contrast,
187
+ ),
188
+ };
189
+ }
190
+
191
+ export function readStoredAppearanceState(): NmAppearanceState | null {
192
+ if (typeof window === 'undefined') {
193
+ return null;
194
+ }
195
+ try {
196
+ const raw = window.localStorage.getItem(NM_APPEARANCE_STORAGE_KEY);
197
+ if (!raw) {
198
+ return null;
199
+ }
200
+ return parseAppearanceState(JSON.parse(raw));
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ export function writeStoredAppearanceState(state: NmAppearanceState): void {
207
+ if (typeof window === 'undefined') {
208
+ return;
209
+ }
210
+ window.localStorage.setItem(NM_APPEARANCE_STORAGE_KEY, JSON.stringify(state));
211
+ }
212
+
213
+ export function mergeThemePresetColors(
214
+ state: NmAppearanceState,
215
+ scheme: 'light' | 'dark',
216
+ ): NmAppearanceState {
217
+ // The Custom theme keeps whatever colours the person chose.
218
+ if (state.themePresetId === 'custom') {
219
+ return state;
220
+ }
221
+ const { backgroundHex, foregroundHex } = paletteForPreset(state.themePresetId, scheme);
222
+ const preset = findThemePreset(state.themePresetId);
223
+ const next: NmAppearanceState = {
224
+ ...state,
225
+ backgroundHex: normalizeHexColor(backgroundHex, state.backgroundHex),
226
+ foregroundHex: normalizeHexColor(foregroundHex, state.foregroundHex),
227
+ };
228
+ if (next.accentPreset === 'default' && preset) {
229
+ next.customAccentHex = normalizeHexColor(preset.swatch, next.customAccentHex);
230
+ }
231
+ return next;
232
+ }
233
+
234
+ /** Sync background and foreground (and default accent) from the active theme preset + mode. */
235
+ export function applyThemePresetPalette(
236
+ state: NmAppearanceState,
237
+ prefersDark: boolean = prefersDarkColorScheme(),
238
+ ): NmAppearanceState {
239
+ return mergeThemePresetColors(state, resolveAppearanceScheme(state.mode, prefersDark));
240
+ }
@@ -0,0 +1,36 @@
1
+ import type { NmAppearanceState } from './appearanceState.ts';
2
+ import { NM_APPEARANCE_APPLIED_EVENT, NM_APPEARANCE_CSS_VARS } from '../../config/nm-appearance.ts';
3
+ import { resolveAppearanceScheme } from './appearanceState.ts';
4
+ import { deriveAppearanceTokens } from './deriveTokens.ts';
5
+ import { createLogger } from '../../kit-logger.ts';
6
+
7
+ const logger = createLogger('interface-appearance-apply');
8
+
9
+ function prefersDarkColorScheme(): boolean {
10
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
11
+ return true;
12
+ }
13
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
14
+ }
15
+
16
+ export function clearAppliedAppearance(root: HTMLElement = document.documentElement): void {
17
+ NM_APPEARANCE_CSS_VARS.forEach((property) => {
18
+ root.style.removeProperty(property);
19
+ });
20
+ }
21
+
22
+ export function applyAppearanceState(
23
+ state: NmAppearanceState,
24
+ root: HTMLElement = document.documentElement,
25
+ ): { scheme: 'light' | 'dark' } {
26
+ const scheme = resolveAppearanceScheme(state.mode, prefersDarkColorScheme());
27
+ const tokens = deriveAppearanceTokens(state, scheme);
28
+ logger.debug('apply', 'appearance', { mode: state.mode, scheme, theme: state.themePresetId });
29
+ Object.entries(tokens).forEach(([property, value]) => {
30
+ root.style.setProperty(property, value);
31
+ });
32
+ if (typeof window !== 'undefined') {
33
+ window.dispatchEvent(new CustomEvent(NM_APPEARANCE_APPLIED_EVENT));
34
+ }
35
+ return { scheme };
36
+ }
@@ -0,0 +1,36 @@
1
+ import type { CloudAppearance } from 'orb-ui';
2
+ import { mixHex } from './color.ts';
3
+
4
+ export function deriveCloudOrbAppearance(
5
+ accent: string,
6
+ canvas: string,
7
+ fg: string,
8
+ ): CloudAppearance {
9
+ return {
10
+ deepColor: mixHex(accent, canvas, 0.55),
11
+ upperColor: mixHex(accent, fg, 0.28),
12
+ lowerColor: mixHex(accent, canvas, 0.12),
13
+ highlightColor: mixHex(accent, fg, 0.62),
14
+ launchColor: mixHex(accent, canvas, 0.38),
15
+ spinnerColor: accent,
16
+ };
17
+ }
18
+
19
+ export function cloudOrbAppearanceCssVars(appearance: CloudAppearance): Record<string, string> {
20
+ return {
21
+ '--orb-ui-cloud-appearance-deepColor': appearance.deepColor,
22
+ '--orb-ui-cloud-appearance-upperColor': appearance.upperColor,
23
+ '--orb-ui-cloud-appearance-lowerColor': appearance.lowerColor,
24
+ '--orb-ui-cloud-appearance-highlightColor': appearance.highlightColor,
25
+ '--orb-ui-cloud-appearance-launchColor': appearance.launchColor,
26
+ '--orb-ui-cloud-appearance-spinnerColor': appearance.spinnerColor,
27
+ };
28
+ }
29
+
30
+ export function deriveCloudOrbAppearanceTokens(
31
+ accent: string,
32
+ canvas: string,
33
+ fg: string,
34
+ ): Record<string, string> {
35
+ return cloudOrbAppearanceCssVars(deriveCloudOrbAppearance(accent, canvas, fg));
36
+ }
@@ -0,0 +1,96 @@
1
+ const HEX_PATTERN = /^#([0-9a-fA-F]{6})$/;
2
+
3
+ export function isHexColor(value: string): boolean {
4
+ return HEX_PATTERN.test(value);
5
+ }
6
+
7
+ export function normalizeHexColor(value: string, fallback: string): string {
8
+ const trimmed = value.trim();
9
+ if (!trimmed.startsWith('#')) {
10
+ const withHash = `#${trimmed}`;
11
+ if (isHexColor(withHash)) {
12
+ return withHash.toUpperCase();
13
+ }
14
+ }
15
+ if (isHexColor(trimmed)) {
16
+ return trimmed.toUpperCase();
17
+ }
18
+ return fallback.toUpperCase();
19
+ }
20
+
21
+ export function hexToRgbTriplet(hex: string): string {
22
+ const normalized = normalizeHexColor(hex, '#000000').slice(1);
23
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
24
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
25
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
26
+ return `${r} ${g} ${b}`;
27
+ }
28
+
29
+ function channel(value: number): number {
30
+ return Math.min(255, Math.max(0, Math.round(value)));
31
+ }
32
+
33
+ /** Mix hex `color` toward `target` by amount in [0, 1]. */
34
+ export function mixHex(color: string, target: string, amount: number): string {
35
+ const a = normalizeHexColor(color, '#000000').slice(1);
36
+ const b = normalizeHexColor(target, '#000000').slice(1);
37
+ const t = Math.min(1, Math.max(0, amount));
38
+ const parse = (hex: string, start: number) => Number.parseInt(hex.slice(start, start + 2), 16);
39
+ const ar = parse(a, 0);
40
+ const ag = parse(a, 2);
41
+ const ab = parse(a, 4);
42
+ const br = parse(b, 0);
43
+ const bg = parse(b, 2);
44
+ const bb = parse(b, 4);
45
+ const r = channel(ar + (br - ar) * t);
46
+ const g = channel(ag + (bg - ag) * t);
47
+ const bl = channel(ab + (bb - ab) * t);
48
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`.toUpperCase();
49
+ }
50
+
51
+ function relativeLuminance(hex: string): number {
52
+ const [r, g, b] = hexToRgbTriplet(hex).split(' ').map((value) => {
53
+ const unit = Number(value) / 255;
54
+ return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
55
+ });
56
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
57
+ }
58
+
59
+ /** WCAG contrast ratio between two colours, from 1 to 21. */
60
+ export function contrastRatio(first: string, second: string): number {
61
+ const a = relativeLuminance(first);
62
+ const b = relativeLuminance(second);
63
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
64
+ }
65
+
66
+ export function hexToHsl(hex: string): { hue: number; saturation: number; lightness: number } {
67
+ const [r, g, b] = hexToRgbTriplet(hex).split(' ').map((value) => Number(value) / 255);
68
+ const max = Math.max(r, g, b);
69
+ const min = Math.min(r, g, b);
70
+ const delta = max - min;
71
+ const lightness = (max + min) / 2;
72
+ if (delta === 0) {
73
+ return { hue: 0, saturation: 0, lightness };
74
+ }
75
+ const saturation = delta / (1 - Math.abs(2 * lightness - 1));
76
+ let sector = (r - g) / delta + 4;
77
+ if (max === r) {
78
+ sector = ((g - b) / delta + 6) % 6;
79
+ } else if (max === g) {
80
+ sector = (b - r) / delta + 2;
81
+ }
82
+ return { hue: sector * 60, saturation, lightness };
83
+ }
84
+
85
+ export function hslToHex(hue: number, saturation: number, lightness: number): string {
86
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
87
+ const part = (offset: number): number => {
88
+ const turn = (offset + hue / 30) % 12;
89
+ const value = lightness - (chroma / 2) * Math.max(-1, Math.min(turn - 3, 9 - turn, 1));
90
+ return channel(value * 255);
91
+ };
92
+ const hex = [part(0), part(8), part(4)]
93
+ .map((value) => value.toString(16).padStart(2, '0'))
94
+ .join('');
95
+ return `#${hex}`.toUpperCase();
96
+ }
@@ -0,0 +1,218 @@
1
+ import type { NmAppearanceState } from './appearanceState.ts';
2
+ import {
3
+ NM_ACCENT_HEX,
4
+ NM_APPEARANCE_CONTRAST,
5
+ NM_THEME_PRESETS,
6
+ NM_UI_FONT_SIZE_PX,
7
+ NM_UI_FONT_WEIGHT,
8
+ NM_UI_ICON_SIZE_PX,
9
+ NM_UI_RADIUS_PERCENT,
10
+ NM_UI_SPACE_PERCENT,
11
+ findThemePreset,
12
+ } from '../../config/nm-appearance.ts';
13
+ import { NM_SITE_PALETTE } from '../../config/nm-site-palette.ts';
14
+ import { deriveCloudOrbAppearanceTokens } from './cloudOrbAppearance.ts';
15
+ import { hexToRgbTriplet, mixHex, normalizeHexColor } from './color.ts';
16
+ import { deriveMarkTokens } from './markTokens.ts';
17
+ import { NM_RADIUS_SCALE_VAR } from '../../config/nm-radius.ts';
18
+
19
+ export type NmDerivedAppearanceTokens = Record<string, string>;
20
+
21
+ /** The accent colour that is in effect for these settings. */
22
+ export function resolveAccentHex(state: NmAppearanceState): string {
23
+ if (state.accentPreset === 'custom') {
24
+ return normalizeHexColor(state.customAccentHex, '#D2BB9C');
25
+ }
26
+ if (state.accentPreset === 'default' && state.themePresetId === 'custom') {
27
+ return normalizeHexColor(state.customAccentHex, '#D2BB9C');
28
+ }
29
+ if (state.accentPreset === 'default') {
30
+ const preset = findThemePreset(state.themePresetId);
31
+ return preset?.swatch ?? NM_THEME_PRESETS[0].swatch;
32
+ }
33
+ return NM_ACCENT_HEX[state.accentPreset];
34
+ }
35
+
36
+ /** Contrast adjusts container surface mix strength only (0 = subtle, max = strong). */
37
+ function surfaceMixStrength(contrast: number, scheme: 'light' | 'dark'): number {
38
+ const t = contrast / NM_APPEARANCE_CONTRAST.max;
39
+ const min = scheme === 'dark' ? 0.06 : 0.05;
40
+ const max = scheme === 'dark' ? 0.42 : 0.38;
41
+ return min + t * (max - min);
42
+ }
43
+
44
+ function deriveIconTokens(uiIconSizePx: number): Record<string, string> {
45
+ const iconPx = `${uiIconSizePx}px`;
46
+ const smPx = Math.max(NM_UI_ICON_SIZE_PX.min, uiIconSizePx - 4);
47
+ return {
48
+ '--nm-icon-button': iconPx,
49
+ '--nm-icon-sm': `${smPx}px`,
50
+ '--nm-rail-icon': iconPx,
51
+ };
52
+ }
53
+
54
+ function deriveUiControlTokens(uiFontSizePx: number, uiIconSizePx: number): Record<string, string> {
55
+ const padPx = 8;
56
+ const iconControlPx = uiIconSizePx + padPx * 2;
57
+ const chipMinPx = Math.max(iconControlPx, uiFontSizePx + padPx * 2);
58
+ return {
59
+ '--nm-ui-chip-min-height': `${chipMinPx}px`,
60
+ };
61
+ }
62
+
63
+ function deriveRadiusScale(uiRadiusPercent: number): Record<string, string> {
64
+ const scale = uiRadiusPercent / NM_UI_RADIUS_PERCENT.default;
65
+ return {
66
+ [NM_RADIUS_SCALE_VAR]: String(scale),
67
+ };
68
+ }
69
+
70
+ function deriveSpaceScale(uiSpacePercent: number): Record<string, string> {
71
+ return {
72
+ '--nm-space-scale': String(uiSpacePercent / NM_UI_SPACE_PERCENT.default),
73
+ };
74
+ }
75
+
76
+ /** The setting is Regular. The heavier weights step up a hundred each, and light steps down. */
77
+ function deriveWeightTokens(base: number): Record<string, string> {
78
+ const step = (by: number) => String(Math.min(NM_UI_FONT_WEIGHT.max, Math.max(NM_UI_FONT_WEIGHT.min, base + by)));
79
+ return {
80
+ '--nm-weight-light': step(-100),
81
+ '--nm-weight-regular': step(0),
82
+ '--nm-weight-medium': step(100),
83
+ '--nm-weight-semibold': step(200),
84
+ '--nm-weight-bold': step(300),
85
+ };
86
+ }
87
+
88
+ function deriveTypeTokens(uiFontSizePx: number): Record<string, string> {
89
+ const uiScale = uiFontSizePx / NM_UI_FONT_SIZE_PX.default;
90
+ const bodyPx = `${uiFontSizePx}px`;
91
+ const uiSmPx = `${Math.max(NM_UI_FONT_SIZE_PX.min, uiFontSizePx - 2)}px`;
92
+
93
+ return {
94
+ '--nm-type-body': bodyPx,
95
+ '--nm-type-ui': bodyPx,
96
+ '--control-fs': bodyPx,
97
+ '--nm-type-ui-sm': uiSmPx,
98
+ '--nm-type-lede': `${uiScale * 1.5}rem`,
99
+ '--nm-type-section': `${uiScale * 2.5}rem`,
100
+ '--nm-type-card': `${uiScale * 2}rem`,
101
+ '--nm-type-caption': `${uiScale * 0.75}rem`,
102
+ '--markdown-font-size': bodyPx,
103
+ '--font-size-base': bodyPx,
104
+ '--font-size-sm': uiSmPx,
105
+ };
106
+ }
107
+
108
+ /** Overrides ThemeProvider inline colors so Chat chrome uses appearance SSOT. */
109
+ function deriveLibreChatBridge(
110
+ tokens: NmDerivedAppearanceTokens,
111
+ accent: string,
112
+ ): Record<string, string> {
113
+ const accentRgb = hexToRgbTriplet(accent);
114
+ return {
115
+ '--text-primary': tokens['--nm-fg-rgb']!,
116
+ '--text-secondary': tokens['--nm-fg-subtle-rgb']!,
117
+ '--text-tertiary': tokens['--nm-stroke-rgb']!,
118
+ '--text-muted': tokens['--nm-fg-muted-rgb']!,
119
+ '--presentation': tokens['--nm-canvas-rgb']!,
120
+ '--surface-primary': tokens['--nm-canvas-rgb']!,
121
+ '--surface-chat': tokens['--nm-canvas-rgb']!,
122
+ '--surface-secondary': tokens['--nm-panel-rgb']!,
123
+ '--surface-secondary-alt': tokens['--nm-line-rgb']!,
124
+ '--surface-tertiary': tokens['--nm-raised-rgb']!,
125
+ '--surface-primary-alt': tokens['--nm-raised-rgb']!,
126
+ '--surface-dialog': tokens['--nm-panel-rgb']!,
127
+ '--surface-hover': tokens['--nm-panel-rgb']!,
128
+ '--surface-active': tokens['--nm-line-rgb']!,
129
+ '--border-light': tokens['--nm-line-rgb']!,
130
+ '--border-medium': tokens['--nm-stroke-rgb']!,
131
+ '--link': accentRgb,
132
+ '--link-hover': accentRgb,
133
+ '--link-visited': accentRgb,
134
+ '--accent-primary': accentRgb,
135
+ '--ring-primary': accentRgb,
136
+ '--brand-purple': accentRgb,
137
+ '--nordman-accent': accentRgb,
138
+ };
139
+ }
140
+
141
+ /** The untouched Nordman theme shows the website palette; anything else mixes its own surfaces. */
142
+ function usesSitePalette(
143
+ state: NmAppearanceState,
144
+ scheme: 'light' | 'dark',
145
+ canvas: string,
146
+ fg: string,
147
+ ): boolean {
148
+ const site = NM_SITE_PALETTE[scheme];
149
+ return (
150
+ state.themePresetId === NM_THEME_PRESETS[0].id &&
151
+ state.contrast === NM_APPEARANCE_CONTRAST.default &&
152
+ canvas.toLowerCase() === site.canvas &&
153
+ fg.toLowerCase() === site.fg
154
+ );
155
+ }
156
+
157
+ export function deriveAppearanceTokens(
158
+ state: NmAppearanceState,
159
+ scheme: 'light' | 'dark',
160
+ ): NmDerivedAppearanceTokens {
161
+ const canvas = normalizeHexColor(state.backgroundHex, '#000000');
162
+ const fg = normalizeHexColor(state.foregroundHex, '#ffffff');
163
+ const accent = resolveAccentHex(state);
164
+ const mix = surfaceMixStrength(state.contrast, scheme);
165
+
166
+ const site = usesSitePalette(state, scheme, canvas, fg) ? NM_SITE_PALETTE[scheme] : null;
167
+ const panel = site?.panel ?? mixHex(canvas, fg, mix);
168
+ const raised = site?.raised ?? mixHex(canvas, fg, mix * 0.72);
169
+ const inset = site?.inset ?? mixHex(canvas, fg, mix * 0.85);
170
+ const line = site?.line ?? mixHex(canvas, fg, Math.min(0.55, mix + 0.12));
171
+ const lineStrong = site?.lineStrong ?? mixHex(canvas, fg, Math.min(0.6, mix + 0.17));
172
+ const stroke = site?.stroke ?? mixHex(canvas, fg, Math.min(0.65, mix + 0.22));
173
+ const fgMuted = site?.muted ?? mixHex(fg, canvas, 0.22);
174
+ const fgSubtle = site?.subtle ?? mixHex(fg, canvas, 0.38);
175
+
176
+ const nmTokens: NmDerivedAppearanceTokens = {
177
+ '--nm-canvas': canvas,
178
+ '--nm-fg': fg,
179
+ '--nm-fg-muted': fgMuted,
180
+ '--nm-fg-subtle': fgSubtle,
181
+ '--nm-stroke': stroke,
182
+ '--nm-line': line,
183
+ '--nm-line-strong': lineStrong,
184
+ '--nm-divider': mixHex(canvas, fg, (state.contrast / NM_APPEARANCE_CONTRAST.max) * 0.5),
185
+ '--nm-panel': panel,
186
+ '--nm-raised': raised,
187
+ '--nm-inset': inset,
188
+ '--nm-canvas-rgb': hexToRgbTriplet(canvas),
189
+ '--nm-fg-rgb': hexToRgbTriplet(fg),
190
+ '--nm-fg-muted-rgb': hexToRgbTriplet(fgMuted),
191
+ '--nm-fg-subtle-rgb': hexToRgbTriplet(fgSubtle),
192
+ '--nm-stroke-rgb': hexToRgbTriplet(stroke),
193
+ '--nm-line-rgb': hexToRgbTriplet(line),
194
+ '--nm-panel-rgb': hexToRgbTriplet(panel),
195
+ '--nm-raised-rgb': hexToRgbTriplet(raised),
196
+ '--nm-inset-rgb': hexToRgbTriplet(inset),
197
+ '--sand': accent,
198
+ '--nm-accent': accent,
199
+ '--nm-accent-rgb': hexToRgbTriplet(accent),
200
+ ...deriveCloudOrbAppearanceTokens(accent, canvas, fg),
201
+ ...deriveMarkTokens(accent, canvas, fg),
202
+ ...deriveTypeTokens(state.uiFontSizePx),
203
+ ...deriveWeightTokens(state.uiFontWeight),
204
+ ...deriveIconTokens(state.uiIconSizePx),
205
+ ...deriveUiControlTokens(state.uiFontSizePx, state.uiIconSizePx),
206
+ ...deriveRadiusScale(state.uiRadiusPercent),
207
+ ...deriveSpaceScale(state.uiSpacePercent),
208
+ };
209
+
210
+ return {
211
+ ...nmTokens,
212
+ ...deriveLibreChatBridge(nmTokens, accent),
213
+ };
214
+ }
215
+
216
+ export function themeModeFromAppearance(mode: NmAppearanceState['mode']): string {
217
+ return mode;
218
+ }
@@ -0,0 +1,6 @@
1
+ export * from '../../config/nm-appearance.ts';
2
+ export * from './appearanceState.ts';
3
+ export * from './applyAppearance.ts';
4
+ export * from './color.ts';
5
+ export * from './deriveTokens.ts';
6
+ export * from './readLayoutMetrics.ts';
@@ -0,0 +1,49 @@
1
+ import { contrastRatio, hexToHsl, hslToHex, mixHex } from './color.ts';
2
+
3
+ /** Text smaller than 18px needs 4.5:1. The margin covers rounding in the mix. */
4
+ const MARK_MIN_CONTRAST = 4.6;
5
+ const MARK_SURFACE_MIX = 0.09;
6
+ const MARK_HUE_STEP = 120;
7
+ const MARK_SATURATION = { min: 0.4, max: 0.7 };
8
+ const MARK_LIGHTNESS_STEP = 0.02;
9
+
10
+ function readableMark(
11
+ hue: number,
12
+ saturation: number,
13
+ backgrounds: readonly string[],
14
+ fg: string,
15
+ ): string {
16
+ const canvas = backgrounds[0] ?? '#000000';
17
+ const darkCanvas = contrastRatio(canvas, '#FFFFFF') > contrastRatio(canvas, '#000000');
18
+ const direction = darkCanvas ? 1 : -1;
19
+ for (
20
+ let lightness = darkCanvas ? 0.68 : 0.42;
21
+ lightness >= 0 && lightness <= 1;
22
+ lightness += direction * MARK_LIGHTNESS_STEP
23
+ ) {
24
+ const color = hslToHex(hue, saturation, lightness);
25
+ if (backgrounds.every((background) => contrastRatio(color, background) >= MARK_MIN_CONTRAST)) {
26
+ return color;
27
+ }
28
+ }
29
+ return fg;
30
+ }
31
+
32
+ /** Three text highlights from the accent's hue and its two even neighbours, readable on the theme. */
33
+ export function deriveMarkTokens(
34
+ accent: string,
35
+ canvas: string,
36
+ fg: string,
37
+ ): Record<string, string> {
38
+ const surface = mixHex(canvas, fg, MARK_SURFACE_MIX);
39
+ const { hue, saturation } = hexToHsl(accent);
40
+ const vivid = Math.min(MARK_SATURATION.max, Math.max(MARK_SATURATION.min, saturation));
41
+ const mark = (offset: number) =>
42
+ readableMark((hue + offset) % 360, vivid, [canvas, surface], fg);
43
+ return {
44
+ '--nm-mark-primary': mark(0),
45
+ '--nm-mark-secondary': mark(MARK_HUE_STEP),
46
+ '--nm-mark-tertiary': mark(MARK_HUE_STEP * 2),
47
+ '--nm-mark-surface': surface,
48
+ };
49
+ }