@adea-ai/ui 0.125.0 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/NOTICE +40 -0
- package/dist/components/theme/index.d.ts +1 -1
- package/dist/components/theme/index.d.ts.map +1 -1
- package/dist/components/theme/index.js +4 -3
- package/dist/components/theme/theme-document.d.ts +25 -1
- package/dist/components/theme/theme-document.d.ts.map +1 -1
- package/dist/components/theme/theme-document.js +18 -3
- package/dist/components/theme/theme-document.js.map +1 -1
- package/dist/components/theme/theme-persistence.d.ts +87 -0
- package/dist/components/theme/theme-persistence.d.ts.map +1 -1
- package/dist/components/theme/theme-persistence.js +63 -2
- package/dist/components/theme/theme-persistence.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +40 -40
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +57 -52
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/components/theme/theme-script.d.ts +100 -0
- package/dist/components/theme/theme-script.d.ts.map +1 -0
- package/dist/components/theme/theme-script.js +57 -0
- package/dist/components/theme/theme-script.js.map +1 -0
- package/dist/components/theme/theme-state.d.ts +58 -0
- package/dist/components/theme/theme-state.d.ts.map +1 -1
- package/dist/components/theme/theme-state.js +22 -1
- package/dist/components/theme/theme-state.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/lib/font-zoom.d.ts +25 -0
- package/dist/lib/font-zoom.d.ts.map +1 -0
- package/dist/lib/font-zoom.js +45 -0
- package/dist/lib/font-zoom.js.map +1 -0
- package/dist/lib/preference-store.d.ts +126 -0
- package/dist/lib/preference-store.d.ts.map +1 -0
- package/dist/lib/preference-store.js +126 -0
- package/dist/lib/preference-store.js.map +1 -0
- package/dist/lib/theme-surface.d.ts +71 -0
- package/dist/lib/theme-surface.d.ts.map +1 -0
- package/dist/lib/theme-surface.js +62 -0
- package/dist/lib/theme-surface.js.map +1 -0
- package/dist/lib/themes.d.ts +9 -0
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +12 -1
- package/dist/lib/themes.js.map +1 -1
- package/dist/lib/tokens.d.ts.map +1 -1
- package/dist/lib/tokens.js +15 -0
- package/dist/lib/tokens.js.map +1 -1
- package/dist/r/lib.json +15 -0
- package/dist/r/registry.json +20 -0
- package/dist/r/src/components/theme/index.ts +6 -0
- package/dist/r/src/components/theme/theme-document.ts +51 -2
- package/dist/r/src/components/theme/theme-persistence.ts +167 -0
- package/dist/r/src/components/theme/theme-provider.tsx +115 -50
- package/dist/r/src/components/theme/theme-script.ts +201 -0
- package/dist/r/src/components/theme/theme-state.ts +72 -0
- package/dist/r/src/lib/font-zoom.ts +74 -0
- package/dist/r/src/lib/preference-store.ts +216 -0
- package/dist/r/src/lib/theme-surface.ts +110 -0
- package/dist/r/src/lib/themes.ts +14 -0
- package/dist/r/src/lib/tokens.ts +17 -0
- package/dist/r/src/styles/theme.css +38 -0
- package/dist/r/theming.json +5 -0
- package/package.json +1 -1
- package/registry.json +20 -0
- package/src/components/theme/index.ts +6 -0
- package/src/components/theme/theme-document.ts +51 -2
- package/src/components/theme/theme-persistence.ts +167 -0
- package/src/components/theme/theme-provider.tsx +115 -50
- package/src/components/theme/theme-script.ts +201 -0
- package/src/components/theme/theme-state.ts +72 -0
- package/src/index.ts +26 -0
- package/src/lib/font-zoom.ts +74 -0
- package/src/lib/preference-store.ts +216 -0
- package/src/lib/theme-surface.ts +110 -0
- package/src/lib/themes.ts +14 -0
- package/src/lib/tokens.ts +17 -0
- package/src/styles/theme.css +38 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { fontSettingsBootstrapScript } from '../../lib/appearance-font-settings'
|
|
2
|
+
import { isCustomAccent } from '../../lib/accent-roles'
|
|
3
|
+
import { safeScriptStringLiteral } from '../../lib/safe-script'
|
|
4
|
+
import { REDUCED_TRANSPARENCY_QUERY, surfaceAlpha } from '../../lib/theme-surface'
|
|
5
|
+
import { accentPresets } from '../../lib/tokens'
|
|
6
|
+
import { builtinThemes, defaultDarkThemeId, defaultLightThemeId } from '../../lib/themes'
|
|
7
|
+
import type { ThemeStorageSchema } from './theme-persistence'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A string as a JavaScript literal that is also safe inside an inline `<script>`.
|
|
11
|
+
*
|
|
12
|
+
* `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe
|
|
13
|
+
* here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag
|
|
14
|
+
* early and everything after it is parsed as HTML. That is the classic inline-script
|
|
15
|
+
* injection, and it is a real one even though the value is a developer-supplied prop
|
|
16
|
+
* rather than user input — a prop can come from configuration, and a key that happens
|
|
17
|
+
* to contain a closing tag should not be able to execute.
|
|
18
|
+
*
|
|
19
|
+
* The escape is also correct JavaScript: `\u003c` is `<` to the parser, so the value
|
|
20
|
+
* is byte-identical at runtime and inert to the HTML tokenizer. `\u2028` and `\u2029`
|
|
21
|
+
* are escaped for the neighbouring reason — they are legal inside a JSON string and
|
|
22
|
+
* were, before ES2019, line terminators to a JavaScript parser.
|
|
23
|
+
*
|
|
24
|
+
* CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this
|
|
25
|
+
* was found; `tests/theme-script.test.ts` pins it so it cannot come back.
|
|
26
|
+
*/
|
|
27
|
+
export function inlineScriptLiteral(value: string): string {
|
|
28
|
+
return safeScriptStringLiteral(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options for the host-shaped no-flash script: the document's {@link ThemeStorageSchema}
|
|
33
|
+
* plus what the script paints from it.
|
|
34
|
+
*/
|
|
35
|
+
export type ThemeScriptOptions = ThemeStorageSchema & {
|
|
36
|
+
/**
|
|
37
|
+
* The accent painted when the document has none, an unrecognised one, or the
|
|
38
|
+
* legacy `theme` spelling — the host's default (adea: `violet`). Without it those
|
|
39
|
+
* cases paint no `data-accent`, which is what the provider writes for `theme`.
|
|
40
|
+
* Setting it means the `[data-accent]` block of the default preset paints on the
|
|
41
|
+
* first frame instead of after hydration.
|
|
42
|
+
*/
|
|
43
|
+
defaultAccent?: string
|
|
44
|
+
/**
|
|
45
|
+
* Also resolve the surface axis (`data-surface`, `data-reduce-transparency`,
|
|
46
|
+
* `--surface-alpha`) from the document and the operating system's Reduce
|
|
47
|
+
* Transparency setting. A `translucent` preference resolves to `frosted`: the
|
|
48
|
+
* host's translucency capability is not known until the provider mounts, and the
|
|
49
|
+
* provider re-resolves it then. Match the provider's `surface` prop.
|
|
50
|
+
*/
|
|
51
|
+
surface?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Match the provider's `palette` prop. The attributes written are the same either
|
|
54
|
+
* way: a catalogue variant's palette is the host stylesheet's job (`stylesheet`) or
|
|
55
|
+
* the provider's, at mount (`inline`), and a stored theme's own tokens are written
|
|
56
|
+
* inline in both.
|
|
57
|
+
*/
|
|
58
|
+
palette?: 'inline' | 'stylesheet'
|
|
59
|
+
/** The only `version` of the library document read. Default `1`. */
|
|
60
|
+
libraryVersion?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The no-flash script.
|
|
65
|
+
*
|
|
66
|
+
* Rendered inline in `<head>` before any stylesheet that matters. Without it a dark
|
|
67
|
+
* preference paints the light theme first and then swaps, which is the flash every
|
|
68
|
+
* themed application has to solve and the one thing a provider cannot do from
|
|
69
|
+
* inside the body.
|
|
70
|
+
*
|
|
71
|
+
* ## The string form
|
|
72
|
+
*
|
|
73
|
+
* `themeScript('key')` serves a provider that stores an unversioned
|
|
74
|
+
* `localStorage[key]` document. Deliberately tiny and dependency-free: it reads the
|
|
75
|
+
* stored preference, resolves `system`, and sets the class. It does not apply the
|
|
76
|
+
* variant's roles — the default theme in `theme.css` covers the first paint, and the
|
|
77
|
+
* provider writes the rest on mount. Its output is a compatibility surface (a
|
|
78
|
+
* consumer may have it in a snapshot), so it does not change.
|
|
79
|
+
*
|
|
80
|
+
* ## The options form
|
|
81
|
+
*
|
|
82
|
+
* `themeScript({ key, ... })` serves a host with a versioned document (see
|
|
83
|
+
* `createVersionedPersistence`) and writes what the provider will write, so mounting
|
|
84
|
+
* repaints nothing:
|
|
85
|
+
*
|
|
86
|
+
* - `class="dark"`, `color-scheme`, `data-theme`, `data-appearance` and, for a
|
|
87
|
+
* compact document, `data-density`;
|
|
88
|
+
* - `data-accent`: a preset id as stored, `custom` for a `#rrggbb` (never the hex
|
|
89
|
+
* itself, which is the worst possible attribute content), `defaultAccent` when the
|
|
90
|
+
* document has none, nothing for a theme-derived `ansi-*` accent (it has no
|
|
91
|
+
* stylesheet block; the provider paints it inline);
|
|
92
|
+
* - the theme: the stored id when the catalogue or the stored library holds it, else
|
|
93
|
+
* the default of the resolved appearance, exactly as the provider resolves it. A
|
|
94
|
+
* library theme's own tokens are written inline, so an imported theme paints on the
|
|
95
|
+
* first frame;
|
|
96
|
+
* - the surface axis, when `surface` is set;
|
|
97
|
+
* - the font settings, guarded to the document's `version`.
|
|
98
|
+
*
|
|
99
|
+
* The document's `version`, when the schema names one, must match exactly. The legacy
|
|
100
|
+
* key is read only while the main key is absent: an unreadable main document is not
|
|
101
|
+
* "no preference", so it does not resurrect an older value.
|
|
102
|
+
*
|
|
103
|
+
* The script reads values out of `localStorage` and writes them to `dataset` and
|
|
104
|
+
* `style`, which are *property* assignments rather than code: a stored id, accent or
|
|
105
|
+
* token can set an attribute and cannot execute. The values that reach the source as
|
|
106
|
+
* text (keys, ids, the default accent) go through {@link inlineScriptLiteral} for the
|
|
107
|
+
* reason documented there.
|
|
108
|
+
*/
|
|
109
|
+
export function themeScript(storageKey?: string): string
|
|
110
|
+
export function themeScript(options: ThemeScriptOptions): string
|
|
111
|
+
export function themeScript(input: string | ThemeScriptOptions = 'adea-appearance'): string {
|
|
112
|
+
if (typeof input === 'string') return stringFormScript(input)
|
|
113
|
+
return optionsFormScript(input)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stringFormScript(storageKey: string): string {
|
|
117
|
+
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){}})();`
|
|
118
|
+
return `${script}${fontSettingsBootstrapScript(storageKey)}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const literal = safeScriptStringLiteral
|
|
122
|
+
|
|
123
|
+
function optionsFormScript(options: ThemeScriptOptions): string {
|
|
124
|
+
const field = options.appearanceField ?? 'appearance'
|
|
125
|
+
const versionGuard =
|
|
126
|
+
options.version === undefined ? '' : `&&x.version===${Number(options.version)}`
|
|
127
|
+
const presets = accentPresets
|
|
128
|
+
.filter((preset) => preset.id !== 'theme')
|
|
129
|
+
.map((preset) => literal(preset.id))
|
|
130
|
+
const defaultAccent =
|
|
131
|
+
options.defaultAccent === undefined
|
|
132
|
+
? ''
|
|
133
|
+
: isCustomAccent(options.defaultAccent)
|
|
134
|
+
? 'custom'
|
|
135
|
+
: options.defaultAccent
|
|
136
|
+
|
|
137
|
+
const parts: string[] = []
|
|
138
|
+
parts.push(
|
|
139
|
+
`(function(){try{`,
|
|
140
|
+
`var r=document.documentElement,s=r.style;`,
|
|
141
|
+
`var q=function(m){try{return window.matchMedia(m).matches}catch(e){return false}};`,
|
|
142
|
+
`var get=function(k){try{return localStorage.getItem(k)}catch(e){return null}};`,
|
|
143
|
+
// The main document. `raw !== null` rather than truthiness: an empty string is a
|
|
144
|
+
// stored (corrupt) value, so it does not fall through to the legacy key.
|
|
145
|
+
`var raw=get(${literal(options.key)}),doc=null,legacy=null;`,
|
|
146
|
+
`if(raw!==null){try{var x=JSON.parse(raw);if(x&&typeof x==='object'&&!Array.isArray(x)${versionGuard})doc=x}catch(e){}}`
|
|
147
|
+
)
|
|
148
|
+
if (options.legacyAppearanceKey !== undefined) {
|
|
149
|
+
parts.push(`else legacy=get(${literal(options.legacyAppearanceKey)});`)
|
|
150
|
+
}
|
|
151
|
+
parts.push(
|
|
152
|
+
`var m=doc?doc[${literal(field)}]:legacy;`,
|
|
153
|
+
`var mode=m==='light'||m==='dark'?m:'system';`,
|
|
154
|
+
`var dark=mode==='dark'||(mode==='system'&&q('(prefers-color-scheme: dark)'));`,
|
|
155
|
+
`var fallback=dark?${literal(defaultDarkThemeId)}:${literal(defaultLightThemeId)};`,
|
|
156
|
+
`var want=doc?(dark?doc.darkThemeId:doc.lightThemeId):null;`,
|
|
157
|
+
`if(typeof want!=='string')want=fallback;`,
|
|
158
|
+
`var ids=[${builtinThemes.map((theme) => literal(theme.id)).join(',')}];`,
|
|
159
|
+
`var id=ids.indexOf(want)!==-1?want:null,tokens=null;`
|
|
160
|
+
)
|
|
161
|
+
if (options.libraryKey !== undefined) {
|
|
162
|
+
parts.push(
|
|
163
|
+
`if(id===null){try{var lib=JSON.parse(get(${literal(options.libraryKey)})||'null');`,
|
|
164
|
+
`if(lib&&typeof lib==='object'&&lib.version===${Number(options.libraryVersion ?? 1)}&&Array.isArray(lib.themes)){`,
|
|
165
|
+
`for(var i=0;i!==lib.themes.length;i++){var t=lib.themes[i];`,
|
|
166
|
+
`if(t&&t.id===want&&t.flatTokens&&typeof t.flatTokens==='object'){id=want;tokens=t.flatTokens;break}}}}catch(e){}}`
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
parts.push(
|
|
170
|
+
`if(id===null)id=fallback;`,
|
|
171
|
+
`r.classList.toggle('dark',dark);`,
|
|
172
|
+
`s.colorScheme=dark?'dark':'light';`,
|
|
173
|
+
`r.dataset.theme=id;`,
|
|
174
|
+
`r.dataset.appearance=dark?'dark':'light';`
|
|
175
|
+
)
|
|
176
|
+
if (options.libraryKey !== undefined) {
|
|
177
|
+
parts.push(
|
|
178
|
+
`if(tokens){for(var n in tokens){if(Object.prototype.hasOwnProperty.call(tokens,n)&&n.slice(0,2)==='--'&&typeof tokens[n]==='string')s.setProperty(n,tokens[n])}}`
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
parts.push(
|
|
182
|
+
`var a=doc&&typeof doc.accent==='string'?doc.accent:'';`,
|
|
183
|
+
`var accent=[${presets.join(',')}].indexOf(a)!==-1?a:/^#[0-9a-fA-F]{6}$/.test(a)?'custom':a.slice(0,5)==='ansi-'?'':${literal(defaultAccent)};`,
|
|
184
|
+
`if(accent)r.dataset.accent=accent;`,
|
|
185
|
+
`if(doc&&doc.density==='compact')r.dataset.density='compact';`
|
|
186
|
+
)
|
|
187
|
+
if (options.surface) {
|
|
188
|
+
parts.push(
|
|
189
|
+
// Reduce Transparency forces the opaque surface; a translucent request resolves
|
|
190
|
+
// to frosted because the host's capability is unknown before the provider mounts.
|
|
191
|
+
`var reduce=q(${literal(REDUCED_TRANSPARENCY_QUERY)})||!!(doc&&doc.reduceTransparency===true);`,
|
|
192
|
+
`var surface=reduce?'opaque':doc&&(doc.surface==='frosted'||doc.surface==='translucent')?'frosted':'opaque';`,
|
|
193
|
+
`r.dataset.surface=surface;`,
|
|
194
|
+
`r.dataset.reduceTransparency=reduce?'true':'false';`,
|
|
195
|
+
`s.setProperty('--surface-alpha',${JSON.stringify(surfaceAlpha)}[surface]);`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
parts.push(`}catch(e){}})();`)
|
|
199
|
+
|
|
200
|
+
return `${parts.join('')}${fontSettingsBootstrapScript(options.key, options.version)}`
|
|
201
|
+
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
} from '../../lib/appearance-font-settings'
|
|
6
6
|
import type { AccentPreset } from '@adea-ai/themes'
|
|
7
7
|
import { normalizeCustomAccent, type AccentRoles } from '../../lib/accent-roles'
|
|
8
|
+
import { stepAppearanceFontSizes, type FontZoomIntent } from '../../lib/font-zoom'
|
|
9
|
+
import type { ThemeSurface } from '../../lib/theme-surface'
|
|
8
10
|
import {
|
|
9
11
|
accentRolesForSelection,
|
|
10
12
|
defaultDarkThemeId,
|
|
@@ -63,6 +65,30 @@ export type ThemeSelection = {
|
|
|
63
65
|
* keeps working and `ThemeSelection` stays assignable without it.
|
|
64
66
|
*/
|
|
65
67
|
density?: ThemeDensity
|
|
68
|
+
/**
|
|
69
|
+
* The terminal either follows the interface theme (`theme`, or absent) or is pinned
|
|
70
|
+
* to another theme's terminal palette by that theme's id. The pin is written over
|
|
71
|
+
* the interface variant's own `--terminal-*` properties, so a light interface can
|
|
72
|
+
* carry a dark terminal.
|
|
73
|
+
*
|
|
74
|
+
* An id the registry does not hold paints the interface palette and stays in the
|
|
75
|
+
* selection, the same rule as an unknown theme id.
|
|
76
|
+
*
|
|
77
|
+
* Optional and not defaulted: see {@link defaultThemeSelection}.
|
|
78
|
+
*/
|
|
79
|
+
terminalThemeId?: string
|
|
80
|
+
/**
|
|
81
|
+
* How opaque elevated panels are. Written to `<html>` only when the provider's
|
|
82
|
+
* `surface` prop is on or this is set; absent, a host paints no surface axis at
|
|
83
|
+
* all. Resolved against the OS and the host's capability before it is painted —
|
|
84
|
+
* see `resolveSurface` — so the value stored is a preference, never the outcome.
|
|
85
|
+
*/
|
|
86
|
+
surface?: ThemeSurface
|
|
87
|
+
/**
|
|
88
|
+
* The user's own Reduce Transparency switch, which forces the `opaque` surface the
|
|
89
|
+
* same way the operating system's setting does.
|
|
90
|
+
*/
|
|
91
|
+
reduceTransparency?: boolean
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
/**
|
|
@@ -143,6 +169,24 @@ export type ThemeContextValue = {
|
|
|
143
169
|
* registry does not hold: each means "keep the inherited accent".
|
|
144
170
|
*/
|
|
145
171
|
accentRolesFor: (accent: string | null, variantId?: string) => AccentRoles | undefined
|
|
172
|
+
/**
|
|
173
|
+
* The surface the committed selection paints, after Reduce Transparency and the
|
|
174
|
+
* host's translucency capability. `opaque` when the axis is off. Follows the
|
|
175
|
+
* committed selection, not a preview, like {@link ThemeContextValue.variant}.
|
|
176
|
+
*/
|
|
177
|
+
effectiveSurface: () => ThemeSurface
|
|
178
|
+
/**
|
|
179
|
+
* True while the operating system or the selection's `reduceTransparency` forces
|
|
180
|
+
* opaque surfaces. Always false when the surface axis is off, because nothing is
|
|
181
|
+
* then listening to the operating system.
|
|
182
|
+
*/
|
|
183
|
+
reduceTransparencyActive: () => boolean
|
|
184
|
+
/**
|
|
185
|
+
* Step the three text sizes together, or put them back to the defaults: what the
|
|
186
|
+
* `fontZoom` prop's chords do, for a host that binds its own keys or a menu. Goes
|
|
187
|
+
* through {@link ThemeContextValue.setSelection}, so it persists and ends a preview.
|
|
188
|
+
*/
|
|
189
|
+
zoomFonts: (intent: FontZoomIntent) => void
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
/** A selection with the `undefined` keys of a patch left out. */
|
|
@@ -231,6 +275,34 @@ export function resolveThemeState(
|
|
|
231
275
|
}
|
|
232
276
|
}
|
|
233
277
|
|
|
278
|
+
/**
|
|
279
|
+
* The variant whose terminal palette is pinned, or `undefined` when the terminal
|
|
280
|
+
* follows the interface theme.
|
|
281
|
+
*
|
|
282
|
+
* `theme`, an absent id and an id the registry does not hold all mean the interface
|
|
283
|
+
* palette: a terminal must never resolve to a blank or half-painted palette because a
|
|
284
|
+
* theme was deleted after it was pinned.
|
|
285
|
+
*/
|
|
286
|
+
export function resolveTerminalVariant(
|
|
287
|
+
terminalThemeId: string | undefined,
|
|
288
|
+
registry: readonly ThemeVariant[]
|
|
289
|
+
): ThemeVariant | undefined {
|
|
290
|
+
if (terminalThemeId === undefined || terminalThemeId === 'theme') return undefined
|
|
291
|
+
return registry.find((candidate) => candidate.id === terminalThemeId)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The font settings a zoom chord asks for: one pixel up or down on every axis, or
|
|
296
|
+
* the defaults for `reset`.
|
|
297
|
+
*/
|
|
298
|
+
export function zoomedFontSettings(
|
|
299
|
+
selection: ThemeSelection,
|
|
300
|
+
intent: FontZoomIntent
|
|
301
|
+
): AppearanceEditorFontSettings {
|
|
302
|
+
if (intent === 'reset') return { ...DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS }
|
|
303
|
+
return stepAppearanceFontSizes(selection.fonts, intent === 'in' ? 1 : -1)
|
|
304
|
+
}
|
|
305
|
+
|
|
234
306
|
/**
|
|
235
307
|
* The registry a provider resolves against: the catalogue, then the host's extras.
|
|
236
308
|
*
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Wing
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*
|
|
5
|
+
* The platform zoom chords, mapped onto Adea's three text roles.
|
|
6
|
+
*
|
|
7
|
+
* macOS (and Windows/Linux) zoom shortcuts — Cmd/Ctrl with `=`, `+`, `-`, or
|
|
8
|
+
* `0` — normally scale the browser's page zoom. In Adea every text element is
|
|
9
|
+
* already tied to one of the three appearance font tiers (the UI tier drives
|
|
10
|
+
* the whole `text-*` scale through `--font-ui-scale`; content and code roles
|
|
11
|
+
* scale through their own tokens), so stepping those tiers reproduces zoom
|
|
12
|
+
* while keeping the app inside its tokenized, persisted type system: the page
|
|
13
|
+
* layout, menus, sheets, and terminals reflow live from the same projection
|
|
14
|
+
* the appearance editor writes.
|
|
15
|
+
*
|
|
16
|
+
* Translated from Adea's accepted port (`packages/ui/src/components/font-zoom.ts`) so
|
|
17
|
+
* the shared provider and Adea run one implementation. See NOTICE.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
APPEARANCE_EDITOR_FONT_AXES,
|
|
21
|
+
APPEARANCE_EDITOR_FONT_SIZE_MAX,
|
|
22
|
+
APPEARANCE_EDITOR_FONT_SIZE_MIN,
|
|
23
|
+
normalizeAppearanceEditorFontSettings,
|
|
24
|
+
type AppearanceEditorFontSettings,
|
|
25
|
+
} from './appearance-font-settings'
|
|
26
|
+
|
|
27
|
+
/** What the user's zoom chord asked for. */
|
|
28
|
+
export type FontZoomIntent = 'in' | 'out' | 'reset'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify the platform zoom chord: Cmd (macOS) or Ctrl (other platforms)
|
|
32
|
+
* with `=`/`+` zooming in, `-` zooming out, and `0` resetting to the System
|
|
33
|
+
* defaults. Unmodified keys, Alt-modified chords, and IME composition never
|
|
34
|
+
* count, so typing and text editing are untouched.
|
|
35
|
+
*/
|
|
36
|
+
export function fontZoomShortcut(event: {
|
|
37
|
+
key: string
|
|
38
|
+
metaKey: boolean
|
|
39
|
+
ctrlKey: boolean
|
|
40
|
+
altKey: boolean
|
|
41
|
+
isComposing?: boolean
|
|
42
|
+
}): FontZoomIntent | undefined {
|
|
43
|
+
if (event.isComposing || event.altKey) return undefined
|
|
44
|
+
if (!(event.metaKey || event.ctrlKey)) return undefined
|
|
45
|
+
if (event.key === '=' || event.key === '+') return 'in'
|
|
46
|
+
if (event.key === '-') return 'out'
|
|
47
|
+
if (event.key === '0') return 'reset'
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Clamp a pixel size to the shared range the appearance editor enforces. */
|
|
52
|
+
function clampFontSize(size: number): number {
|
|
53
|
+
return Math.min(APPEARANCE_EDITOR_FONT_SIZE_MAX, Math.max(APPEARANCE_EDITOR_FONT_SIZE_MIN, size))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Step every text tier by `delta` pixels, clamped to that range. The axes move
|
|
58
|
+
* together — that is what makes the chord read as zoom — and each keeps its
|
|
59
|
+
* own family choice.
|
|
60
|
+
*/
|
|
61
|
+
export function stepAppearanceFontSizes(
|
|
62
|
+
fonts: unknown,
|
|
63
|
+
delta: 1 | -1
|
|
64
|
+
): AppearanceEditorFontSettings {
|
|
65
|
+
const current = normalizeAppearanceEditorFontSettings(fonts).settings
|
|
66
|
+
return {
|
|
67
|
+
ui: { ...current.ui, size: clampFontSize(current.ui.size + delta) },
|
|
68
|
+
content: { ...current.content, size: clampFontSize(current.content.size + delta) },
|
|
69
|
+
code: { ...current.code, size: clampFontSize(current.code.size + delta) },
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every axis name, for callers that want to report or test the stepping. */
|
|
74
|
+
export const FONT_ZOOM_AXES = APPEARANCE_EDITOR_FONT_AXES
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Wing
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*
|
|
5
|
+
* The recovery behaviour here (quarantine before any write, the envelope shape, the
|
|
6
|
+
* legacy key that is read once and never deleted) is Adea's accepted port of Zeron's
|
|
7
|
+
* preference store (https://github.com/zeronsh/zeron),
|
|
8
|
+
* `packages/ui/src/components/appearance.ts`, moved here so the shared provider and
|
|
9
|
+
* Adea run one implementation. See NOTICE.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A versioned preference store: read one JSON document, refuse to guess about what
|
|
14
|
+
* it cannot read, and keep what it refused.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here knows what a preference *is*. A host hands the engine a key, a
|
|
17
|
+
* version and (through `createVersionedPersistence`) a codec; the engine owns only
|
|
18
|
+
* the part every versioned store gets wrong the same way, which is what happens to
|
|
19
|
+
* a document that is not the one the code expects:
|
|
20
|
+
*
|
|
21
|
+
* - A document that is not JSON, or not a record, or carries a version the code
|
|
22
|
+
* does not know (usually a newer build's) is **quarantined, byte for byte, into a
|
|
23
|
+
* recovery key** before anything else happens. The store then answers as if
|
|
24
|
+
* nothing were stored. Because the quarantine runs at read time, ahead of any
|
|
25
|
+
* write, saving valid preferences later cannot destroy the unread original.
|
|
26
|
+
* - A legacy key is consulted only when the main key is *absent*, never when it is
|
|
27
|
+
* unreadable: a corrupt main document is not "no preference", and falling back to
|
|
28
|
+
* an older, staler key would quietly restore a choice the user has since changed.
|
|
29
|
+
* The legacy key is never deleted.
|
|
30
|
+
* - Blocked storage (a sandboxed iframe, a privacy mode) throws on the mere access
|
|
31
|
+
* of the `localStorage` global. Every path here treats that as "nothing stored".
|
|
32
|
+
*
|
|
33
|
+
* The reads and writes are against a {@link PreferenceStorage}, so the rules run in a
|
|
34
|
+
* test without a DOM.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The part of `Storage` the engine uses. */
|
|
38
|
+
export type PreferenceStorage = Pick<Storage, 'getItem' | 'setItem'>
|
|
39
|
+
|
|
40
|
+
/** Why an unread document was quarantined. */
|
|
41
|
+
export type RecoveryReason = 'corrupt_json' | 'unsupported_record'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What is written to the recovery key.
|
|
45
|
+
*
|
|
46
|
+
* The shape is a compatibility surface: Adea's stored envelopes already have it, and
|
|
47
|
+
* a tool that inspects a user's storage reads it. Do not rename a field.
|
|
48
|
+
*/
|
|
49
|
+
export type RecoveryEnvelope = Readonly<{
|
|
50
|
+
schemaVersion: 1
|
|
51
|
+
reason: RecoveryReason
|
|
52
|
+
/** An ISO-8601 timestamp of the capture; refreshed by each read of the same bytes. */
|
|
53
|
+
capturedAt: string
|
|
54
|
+
/** The document exactly as it was stored. */
|
|
55
|
+
raw: string
|
|
56
|
+
}>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The preference store, or nothing.
|
|
60
|
+
*
|
|
61
|
+
* The `typeof` guard sits inside the `try` for a reason: where storage is blocked,
|
|
62
|
+
* merely *reading* the `localStorage` global throws, so a guard in front of the `try`
|
|
63
|
+
* is itself the crash.
|
|
64
|
+
*/
|
|
65
|
+
export function defaultPreferenceStorage(): PreferenceStorage | undefined {
|
|
66
|
+
try {
|
|
67
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The envelope for a document, stamped with `now`. */
|
|
74
|
+
export function recoveryEnvelope(
|
|
75
|
+
raw: string,
|
|
76
|
+
reason: RecoveryReason,
|
|
77
|
+
now: Date = new Date()
|
|
78
|
+
): RecoveryEnvelope {
|
|
79
|
+
return { schemaVersion: 1, reason, capturedAt: now.toISOString(), raw }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Quarantine an unread document into `recoveryKey`.
|
|
84
|
+
*
|
|
85
|
+
* Idempotent, so re-reading the same unread value refreshes the capture without
|
|
86
|
+
* losing it. Best effort: a store that cannot be written leaves the caller to fail
|
|
87
|
+
* closed on the defaults, which is the same outcome as not having a recovery key at
|
|
88
|
+
* all. Never throws.
|
|
89
|
+
*
|
|
90
|
+
* Exported so a host quarantines its *other* documents (a theme library, say) with
|
|
91
|
+
* the same envelope. Give each document its own `recoveryKey`: two documents that
|
|
92
|
+
* share one overwrite each other's capture.
|
|
93
|
+
*/
|
|
94
|
+
export function quarantineRecord(
|
|
95
|
+
storage: PreferenceStorage,
|
|
96
|
+
recoveryKey: string,
|
|
97
|
+
raw: string,
|
|
98
|
+
reason: RecoveryReason,
|
|
99
|
+
now: () => Date = () => new Date()
|
|
100
|
+
): void {
|
|
101
|
+
try {
|
|
102
|
+
storage.setItem(recoveryKey, JSON.stringify(recoveryEnvelope(raw, reason, now())))
|
|
103
|
+
} catch {
|
|
104
|
+
// Quarantine is best effort; the active preference still fails closed.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Where a versioned document lives and which version the code understands. */
|
|
109
|
+
export type VersionedRecordSchema = {
|
|
110
|
+
/** The storage key of the document. */
|
|
111
|
+
key: string
|
|
112
|
+
/**
|
|
113
|
+
* The only `version` the code reads. Absent, the document is unversioned and any
|
|
114
|
+
* JSON object is accepted; present, a record whose top-level `version` is not
|
|
115
|
+
* exactly this number is unsupported (newer or older), not "close enough".
|
|
116
|
+
*/
|
|
117
|
+
version?: number
|
|
118
|
+
/** Where an unread document is kept. Default `${key}.recovery`. */
|
|
119
|
+
recoveryKey?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What reading the document found.
|
|
124
|
+
*
|
|
125
|
+
* - `absent`: nothing is stored under the key.
|
|
126
|
+
* - `record`: a JSON object this code can read; `raw` is the stored text.
|
|
127
|
+
* - `quarantined`: something was stored and cannot be read. It has already been
|
|
128
|
+
* written to the recovery key.
|
|
129
|
+
* - `unavailable`: the store itself could not be read.
|
|
130
|
+
*/
|
|
131
|
+
export type VersionedRead =
|
|
132
|
+
| { status: 'absent' }
|
|
133
|
+
| { status: 'record'; record: Readonly<Record<string, unknown>>; raw: string }
|
|
134
|
+
| { status: 'quarantined'; reason: RecoveryReason; raw: string }
|
|
135
|
+
| { status: 'unavailable' }
|
|
136
|
+
|
|
137
|
+
/** The default recovery key for a document key. */
|
|
138
|
+
export function recoveryKeyFor(schema: VersionedRecordSchema): string {
|
|
139
|
+
return schema.recoveryKey ?? `${schema.key}.recovery`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the document under `schema.key`.
|
|
144
|
+
*
|
|
145
|
+
* The classification is the contract: a value that is not valid JSON, or is JSON but
|
|
146
|
+
* not an object (`null`, a number, a string), is `corrupt_json`; an object (or array)
|
|
147
|
+
* that is not a record of the supported version is `unsupported_record`. Both are
|
|
148
|
+
* quarantined before this returns. An empty string is a stored value, so it is
|
|
149
|
+
* corrupt rather than absent.
|
|
150
|
+
*/
|
|
151
|
+
export function readVersionedRecord(
|
|
152
|
+
storage: PreferenceStorage,
|
|
153
|
+
schema: VersionedRecordSchema,
|
|
154
|
+
now?: () => Date
|
|
155
|
+
): VersionedRead {
|
|
156
|
+
let raw: string | null
|
|
157
|
+
try {
|
|
158
|
+
raw = storage.getItem(schema.key)
|
|
159
|
+
} catch {
|
|
160
|
+
return { status: 'unavailable' }
|
|
161
|
+
}
|
|
162
|
+
if (raw === null) return { status: 'absent' }
|
|
163
|
+
|
|
164
|
+
const recoveryKey = recoveryKeyFor(schema)
|
|
165
|
+
let parsed: unknown
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(raw)
|
|
168
|
+
} catch {
|
|
169
|
+
quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
|
|
170
|
+
return { status: 'quarantined', reason: 'corrupt_json', raw }
|
|
171
|
+
}
|
|
172
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
173
|
+
quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
|
|
174
|
+
return { status: 'quarantined', reason: 'corrupt_json', raw }
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
Array.isArray(parsed) ||
|
|
178
|
+
(schema.version !== undefined && (parsed as { version?: unknown }).version !== schema.version)
|
|
179
|
+
) {
|
|
180
|
+
quarantineRecord(storage, recoveryKey, raw, 'unsupported_record', now)
|
|
181
|
+
return { status: 'quarantined', reason: 'unsupported_record', raw }
|
|
182
|
+
}
|
|
183
|
+
return { status: 'record', record: parsed as Record<string, unknown>, raw }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The legacy single-key value, or `null`.
|
|
188
|
+
*
|
|
189
|
+
* Only the caller decides whether to look: the engine's rule is to ask when the main
|
|
190
|
+
* key is `absent`, not when it was quarantined.
|
|
191
|
+
*/
|
|
192
|
+
export function readLegacyValue(storage: PreferenceStorage, legacyKey: string): string | null {
|
|
193
|
+
try {
|
|
194
|
+
return storage.getItem(legacyKey)
|
|
195
|
+
} catch {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Write `document` to `key` as JSON. Returns whether it was stored; a full or blocked
|
|
202
|
+
* store returns `false` and never throws, because losing a preference must not break
|
|
203
|
+
* the switch that tried to save it.
|
|
204
|
+
*/
|
|
205
|
+
export function writeVersionedRecord(
|
|
206
|
+
storage: PreferenceStorage,
|
|
207
|
+
key: string,
|
|
208
|
+
document: unknown
|
|
209
|
+
): boolean {
|
|
210
|
+
try {
|
|
211
|
+
storage.setItem(key, JSON.stringify(document))
|
|
212
|
+
return true
|
|
213
|
+
} catch {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|