@lynn123411/dsh-maple-theme 0.0.0-stage → 0.1.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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * 宿主「插件」页上的配置卡片:Maple 主题的全部旋钮。
3
+ *
4
+ * 改动即时生效:控件的每一次变化都直接写进配置表单,表单回写快照,投影订阅把值
5
+ * 写到 <html> 上,样式表不重建。没有「保存」按钮——这几个旋钮都是所见即所得的,
6
+ * 加一步保存只会让人以为没生效。
7
+ */
8
+ import { useEffect, useState } from 'react';
9
+ import * as React from 'react';
10
+ import { Input, SegmentedControl, Switch } from '@deepseek-ai/dsh-client-ui-primitives';
11
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
12
+ import { readSettings, type MapleForm } from './binding.ts';
13
+ import { DEFAULT_SETTINGS, type MapleSettings } from './settings.ts';
14
+ import { NS } from './locales.ts';
15
+
16
+ /** 标题配色的三个选项:值、文案键、面板 id 后缀。 */
17
+ const HEADING_OPTIONS = [
18
+ { value: 'colorful', key: 'card.headingColorColorful' },
19
+ { value: 'accent', key: 'card.headingColorAccent' },
20
+ { value: 'text', key: 'card.headingColorText' },
21
+ ] as const;
22
+
23
+ /** 一行「标题 + 说明 + 控件」。 */
24
+ function Row({ title, hint, children }: {
25
+ title: string;
26
+ hint?: string | undefined;
27
+ children: React.ReactNode;
28
+ }): React.ReactElement {
29
+ return (
30
+ <div className="dsh-maple-row">
31
+ <div className="dsh-maple-row-info">
32
+ <div className="dsh-maple-row-title">{title}</div>
33
+ {hint !== undefined && <div className="dsh-maple-row-hint">{hint}</div>}
34
+ </div>
35
+ <div className="dsh-maple-row-field">{children}</div>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * 一个数字旋钮:本地保存输入中的字符串,失焦或回车时才提交。
42
+ *
43
+ * 不逐键提交:输入 `1.75` 的中途会经过 `1`、`1.` 这些值,逐键写会让页面在中间态闪一下。
44
+ * @param props.value - 当前生效的值。
45
+ * @param props.onCommit - 提交一个合法值。
46
+ */
47
+ function NumberField({ value, min, max, step, unit, label, onCommit }: {
48
+ value: number;
49
+ min: number;
50
+ max: number;
51
+ step: number;
52
+ unit: string;
53
+ label: string;
54
+ onCommit: (next: number) => void;
55
+ }): React.ReactElement {
56
+ const [text, setText] = useState(String(value));
57
+ // 外部改了值(别处写入、恢复默认)就跟着换,但正在编辑时不打断。
58
+ useEffect(() => { setText(String(value)) }, [value]);
59
+ const commit = (): void => {
60
+ const parsed = Number(text);
61
+ if (!Number.isFinite(parsed)) { setText(String(value)); return; }
62
+ const clamped = Math.min(max, Math.max(min, Math.round(parsed / step) * step));
63
+ setText(String(clamped));
64
+ if (clamped !== value) onCommit(clamped);
65
+ };
66
+ return (
67
+ <>
68
+ <Input
69
+ className="dsh-maple-number"
70
+ type="number"
71
+ inputMode="decimal"
72
+ min={min}
73
+ max={max}
74
+ step={step}
75
+ value={text}
76
+ aria-label={label}
77
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => { setText(event.target.value) }}
78
+ onBlur={commit}
79
+ onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
80
+ if (event.key === 'Enter') { event.currentTarget.blur(); }
81
+ }}
82
+ />
83
+ <span className="dsh-maple-unit">{unit}</span>
84
+ </>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * 渲染配置卡片。
90
+ * @param props - 座位给的 `t`,加上本插件在 `inject` 里投出去的那张表单。
91
+ * @returns 卡片的元素树。
92
+ */
93
+ export function MapleThemeCard(
94
+ props: PropsRuntime<'plugins.bundle.config'> & PropsLocale<typeof NS> & { form: MapleForm },
95
+ ): React.ReactElement {
96
+ // 座位在 `locale: NS` 时把绑定好的 t 挂在 `t` 上;类型声明里它是运行期注入的,这里取出来。
97
+ const { form } = props;
98
+ const t = (key: keyof typeof import('./locales.ts').en): string =>
99
+ (props as unknown as { t: (k: string) => string }).t(key);
100
+ const [settings, setSettings] = useState<MapleSettings | undefined>(() => readSettings(form));
101
+ const [available, setAvailable] = useState(() => form.getSnapshot().status !== 'unavailable');
102
+
103
+ useEffect(() => {
104
+ const sync = (): void => {
105
+ setSettings(readSettings(form));
106
+ setAvailable(form.getSnapshot().status !== 'unavailable');
107
+ };
108
+ sync();
109
+ return form.subscribe(sync);
110
+ }, [form]);
111
+
112
+ if (!available) return <div className="dsh-maple-card"><div className="dsh-maple-empty">{t('card.unavailable')}</div></div>;
113
+
114
+ const current = settings ?? DEFAULT_SETTINGS;
115
+ const write = (field: keyof MapleSettings, value: unknown): void => { void form.set(field, value); };
116
+
117
+ return (
118
+ <div className="dsh-maple-card">
119
+ <section className="dsh-maple-group">
120
+ <div className="dsh-maple-group-title">{t('card.colors')}</div>
121
+ <Row title={t('card.hueLight')} hint={t('card.hueLightHint')}>
122
+ <NumberField value={current.hueLight} min={0} max={360} step={1} unit="°"
123
+ label={t('card.hueLight')} onCommit={(next) => { write('hueLight', next) }} />
124
+ </Row>
125
+ <Row title={t('card.hueDark')} hint={t('card.hueDarkHint')}>
126
+ <NumberField value={current.hueDark} min={0} max={360} step={1} unit="°"
127
+ label={t('card.hueDark')} onCommit={(next) => { write('hueDark', next) }} />
128
+ </Row>
129
+ </section>
130
+
131
+ <section className="dsh-maple-group">
132
+ <div className="dsh-maple-group-title">{t('card.typography')}</div>
133
+ <Row title={t('card.lineHeight')} hint={t('card.lineHeightHint')}>
134
+ <NumberField value={current.lineHeight} min={1.4} max={2} step={0.05} unit="×"
135
+ label={t('card.lineHeight')} onCommit={(next) => { write('lineHeight', next) }} />
136
+ </Row>
137
+ <Row title={t('card.paragraphGap')} hint={t('card.paragraphGapHint')}>
138
+ <NumberField value={current.paragraphGap} min={4} max={24} step={1} unit="px"
139
+ label={t('card.paragraphGap')} onCommit={(next) => { write('paragraphGap', next) }} />
140
+ </Row>
141
+ </section>
142
+
143
+ <section className="dsh-maple-group">
144
+ <div className="dsh-maple-group-title">{t('card.headings')}</div>
145
+ <Row title={t('card.headingColor')}>
146
+ <SegmentedControl
147
+ id="dsh-maple-heading"
148
+ value={current.headingColor}
149
+ label={t('card.headingColor')}
150
+ options={HEADING_OPTIONS.map(option => ({ value: option.value, label: t(option.key) }))}
151
+ onChange={(next) => { write('headingColor', next) }}
152
+ />
153
+ </Row>
154
+ <Row title={t('card.headingIndicator')} hint={t('card.headingIndicatorHint')}>
155
+ <Switch checked={current.headingIndicator} label={t('card.headingIndicator')}
156
+ onChange={(next) => { write('headingIndicator', next) }} />
157
+ </Row>
158
+ </section>
159
+
160
+ <section className="dsh-maple-group">
161
+ <div className="dsh-maple-group-title">{t('card.code')}</div>
162
+ <Row title={t('card.codePalette')} hint={t('card.codePaletteHint')}>
163
+ <Switch checked={current.codePalette} label={t('card.codePalette')}
164
+ onChange={(next) => { write('codePalette', next) }} />
165
+ </Row>
166
+ </section>
167
+
168
+ <section className="dsh-maple-group">
169
+ <div className="dsh-maple-group-title">{t('card.shell')}</div>
170
+ <Row title={t('card.shellSkin')} hint={t('card.shellSkinHint')}>
171
+ <Switch checked={current.shellSkin} label={t('card.shellSkin')}
172
+ onChange={(next) => { write('shellSkin', next) }} />
173
+ </Row>
174
+ </section>
175
+
176
+ <div className="dsh-maple-note">{t('card.scope')}</div>
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,5 @@
1
+ /** `?inline` 导入的样式表是构建期内联进来的字符串,不是要交给打包器处理的 CSS 模块。 */
2
+ declare module '*.css?inline' {
3
+ const css: string;
4
+ export default css;
5
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Maple 主题的浏览器半边:注入样式表、把配置投影到 `<html>`、在宿主「插件」页上挂配置卡片。
3
+ *
4
+ * 三件事各有归属:
5
+ * 1. 样式表在 `apply()` 里注入,标签带 `data-plugin` / `data-plugin-css`:client-modules
6
+ * 按前者在卸载与热更时删掉它们(docs/rules/plugin-ui.md「覆盖宿主控件的样式」)。
7
+ * 2. 投影订阅本插件那一行的配置表单,值变了只改 `<html>` 上的属性与变量,样式表不重建。
8
+ * 3. 卡片走 `plugins.bundle.config`,key 是 bundle 的 npm 包名;宿主「插件」页没挂载时
9
+ * 这个座位不存在,所以注册必须包在 `slots.inject` 里,并与 `configForms.whileServed`
10
+ * 串起来——宿主没服务这份配置,就一个字的界面都不出现。
11
+ */
12
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
13
+ // 类型只读:这几个包的浏览器半边把座位契约、`ctx.configForms` 与 `ctx.locale` 合并进 Context。
14
+ import type {} from '@deepseek-ai/dsh-client-locale/client';
15
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client';
16
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client';
17
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
18
+ import { bindProjection, BUNDLE_NAME, BUNDLE_CONFIG_SLOT, SETTINGS_NAMESPACE, type MapleForm } from './binding.ts';
19
+ import { CARD_CSS } from './card-css.ts';
20
+ import { MapleThemeCard } from './card.tsx';
21
+ import { NS, en, zh } from './locales.ts';
22
+ import tokens from './styles/tokens.css?inline';
23
+ import { MAPLE_TOKENS, THEME_SOURCE } from './tokens.ts';
24
+ import markdown from './styles/markdown.css?inline';
25
+ import shell from './styles/shell.css?inline';
26
+
27
+ export { NS } from './locales.ts';
28
+ export {
29
+ BUNDLE_CONFIG_SLOT, BUNDLE_NAME, SETTINGS_NAMESPACE, bindProjection, readSettings,
30
+ } from './binding.ts';
31
+ export { MapleThemeCard } from './card.tsx';
32
+ export {
33
+ DEFAULT_SETTINGS, HEADING_ATTRIBUTE, INDICATOR_ATTRIBUTE, CODE_ATTRIBUTE, ROOT_ATTRIBUTE, ROOT_VARIABLES,
34
+ applyProjection, clearProjection, clampNumber, normalizeSettings, projectSettings,
35
+ } from './settings.ts';
36
+
37
+ /** 包名:样式表标签的归属键,也是浏览器 bundle 的自注册 id。 */
38
+ export const name = '@lynn123411/dsh-maple-theme';
39
+
40
+ /**
41
+ * 声明的服务:`configForms` 读本插件那一行的配置,`slots` 与 `locale` 画配置卡片,
42
+ * `theme` 把两套调色板交给宿主的主题通道。
43
+ * 漏声明不会报错,只会让整个浏览器半边在页面里消失(docs/rules/client-wiring.md)。
44
+ */
45
+ export const inject = ['configForms', 'slots', 'locale', 'theme'];
46
+
47
+ /** 本插件的样式表:归属键按「包名/文件名」拼,卸载时整批删。 */
48
+ const SHEETS: readonly (readonly [string, string])[] = [
49
+ ['tokens.css', tokens],
50
+ ['markdown.css', markdown],
51
+ ['shell.css', shell],
52
+ ['card.css', CARD_CSS],
53
+ ];
54
+
55
+ /**
56
+ * 注入本插件的样式表;同一个键已存在时替换内容而不是再插一张。
57
+ * 键按文件走:热更后重新执行这个模块时,新内容覆盖旧标签,不会叠出第二份。
58
+ */
59
+ function injectStyles(): void {
60
+ if (typeof document === 'undefined') return;
61
+ for (const [file, css] of SHEETS) {
62
+ const key = `${name}/${file}`;
63
+ const existing = document.head.querySelector<HTMLStyleElement>(`style[data-plugin-css="${key}"]`);
64
+ const tag = existing ?? document.createElement('style');
65
+ tag.dataset.plugin = name;
66
+ tag.dataset.pluginCss = key;
67
+ tag.textContent = css;
68
+ if (existing === null) document.head.appendChild(tag);
69
+ }
70
+ }
71
+
72
+ /** 撤掉本插件的样式标签。 */
73
+ function removeStyles(): void {
74
+ if (typeof document === 'undefined') return;
75
+ for (const tag of document.head.querySelectorAll(`style[data-plugin="${name}"]`)) tag.remove();
76
+ }
77
+
78
+ /** 浏览器上下文里本插件用到的成员。 */
79
+ interface ClientServices {
80
+ configForms: {
81
+ get(namespace: string): MapleForm;
82
+ whileServed(namespaces: readonly string[], register: (served: ReadonlySet<string>) => () => void): () => void;
83
+ };
84
+ slots: {
85
+ inject(slot: string, register: () => () => void): () => void;
86
+ register(options: Record<string, unknown>, component: unknown): () => void;
87
+ };
88
+ locale: {
89
+ register(namespace: string, dictionaries: { zh: unknown; en: unknown }): () => void;
90
+ };
91
+ theme: {
92
+ overrideTokens(source: string, tokens: Record<string, { light: string; dark: string }>): () => void;
93
+ };
94
+ }
95
+
96
+ /**
97
+ * 插件本体。
98
+ * @param ctx - 客户端根上下文。
99
+ */
100
+ export function apply(ctx: ClientContext): void {
101
+ const services = ctx as unknown as ClientServices;
102
+
103
+ ctx.effect(() => {
104
+ injectStyles();
105
+ return () => { removeStyles(); };
106
+ }, 'dsh-maple-theme: stylesheets');
107
+
108
+ // 配色:走宿主的主题通道,由它写到 body 的行内样式上(docs/web-styling.md 的所有权归主题,
109
+ // 功能包消费令牌而不是另立一份全局主题)。disposer 会把这层覆盖整份撤掉。
110
+ ctx.effect(
111
+ () => services.theme.overrideTokens(THEME_SOURCE, MAPLE_TOKENS),
112
+ 'dsh-maple-theme: palette',
113
+ );
114
+
115
+ ctx.effect(
116
+ () => services.locale.register(NS, { zh, en }),
117
+ 'dsh-maple-theme: dictionaries',
118
+ );
119
+
120
+ // 投影:配置一变就重写 <html>,卸载时把痕迹撤干净。
121
+ ctx.effect(() => {
122
+ if (typeof document === 'undefined') return () => {};
123
+ return bindProjection(document.documentElement, services.configForms.get(SETTINGS_NAMESPACE));
124
+ }, 'dsh-maple-theme: projection');
125
+
126
+ // 配置卡片:宿主「插件」页挂载时才注册,宿主没服务这份配置就不出现。
127
+ ctx.effect(
128
+ () => services.configForms.whileServed([SETTINGS_NAMESPACE], () => services.slots.inject(
129
+ BUNDLE_CONFIG_SLOT,
130
+ () => slotsRegister(services, () => ({
131
+ name: BUNDLE_CONFIG_SLOT,
132
+ key: BUNDLE_NAME,
133
+ locale: NS,
134
+ inject: () => ({ form: services.configForms.get(SETTINGS_NAMESPACE) }),
135
+ })),
136
+ )),
137
+ 'dsh-maple-theme: plugins page config',
138
+ );
139
+ }
140
+
141
+ /**
142
+ * 把「注册项的选项」与组件绑在一起,抽出来只为让上面那段读起来是三层而不是五层。
143
+ * @param services - 上下文里用到的服务。
144
+ * @param options - 注册项的选项工厂(每次注册都新建一份)。
145
+ * @returns 注册的 disposer。
146
+ */
147
+ function slotsRegister(services: ClientServices, options: () => Record<string, unknown>): () => void {
148
+ return services.slots.register(options(), MapleThemeCard);
149
+ }
@@ -0,0 +1,60 @@
1
+ /** Maple 主题的界面文案。键集与英文逐一对齐,自检会查这一条。 */
2
+ export const NS = 'mapletheme';
3
+
4
+ /** 英文文案。 */
5
+ export const en = {
6
+ 'card.colors': 'Colors',
7
+ 'card.hueLight': 'Light hue',
8
+ 'card.hueLightHint': 'Base hue the whole light palette is derived from. Maple ships 35 (warm white).',
9
+ 'card.hueDark': 'Dark hue',
10
+ 'card.hueDarkHint': 'Base hue the whole dark palette is derived from. Maple ships 207 (cool grey).',
11
+ 'card.typography': 'Typing',
12
+ 'card.lineHeight': 'Line height',
13
+ 'card.lineHeightHint': 'Line height for conversation body text. Tighter than Maple’s 1.8 because chat lines run narrower.',
14
+ 'card.paragraphGap': 'Paragraph gap',
15
+ 'card.paragraphGapHint': 'Space between two paragraphs in an answer, in pixels. Looser than Maple’s 8.',
16
+ 'card.headings': 'Headings',
17
+ 'card.headingColor': 'Heading color',
18
+ 'card.headingColorColorful': 'Six colors',
19
+ 'card.headingColorAccent': 'Accent',
20
+ 'card.headingColorText': 'Body text',
21
+ 'card.headingIndicator': 'Level marker',
22
+ 'card.headingIndicatorHint': 'Show H1–H6 to the left of a heading while the pointer rests on it.',
23
+ 'card.code': 'Code',
24
+ 'card.codePalette': 'Maple syntax colors',
25
+ 'card.codePaletteHint': 'Recolor fenced code blocks with the Maple syntax palette.',
26
+ 'card.shell': 'Surroundings',
27
+ 'card.shellSkin': 'Sidebar and tabs',
28
+ 'card.shellSkinHint': 'Round the sidebar rows and the active tab, drop the seam under the session header, and give the input card Maple paper.',
29
+ 'card.scope': 'The palette is global; headings, quotes, tables, lists and rules only change inside conversation answers.',
30
+ 'card.unavailable': 'The Host does not serve this configuration right now.',
31
+ } as const;
32
+
33
+ /** 中文文案。 */
34
+ export const zh: Record<keyof typeof en, string> = {
35
+ 'card.colors': '配色',
36
+ 'card.hueLight': '浅色基础色相',
37
+ 'card.hueLightHint': '浅色整套颜色由这个色相推导。Maple 出厂值是 35(暖白)。',
38
+ 'card.hueDark': '深色基础色相',
39
+ 'card.hueDarkHint': '深色整套颜色由这个色相推导。Maple 出厂值是 207(冷灰)。',
40
+ 'card.typography': '打字体验',
41
+ 'card.lineHeight': '行高',
42
+ 'card.lineHeightHint': '对话正文的行高倍数。比 Maple 的 1.8 紧一档,对话的行宽比笔记窄。',
43
+ 'card.paragraphGap': '段落间距',
44
+ 'card.paragraphGapHint': '一个回答里两段之间空多少像素。比 Maple 的 8 松一档。',
45
+ 'card.headings': '标题',
46
+ 'card.headingColor': '标题颜色',
47
+ 'card.headingColorColorful': '六级换色',
48
+ 'card.headingColorAccent': '强调色',
49
+ 'card.headingColorText': '正文色',
50
+ 'card.headingIndicator': '等级标记',
51
+ 'card.headingIndicatorHint': '指针停在标题上时,在左边显示 H1–H6。',
52
+ 'card.code': '代码',
53
+ 'card.codePalette': 'Maple 语法配色',
54
+ 'card.codePaletteHint': '用 Maple 的调色板给围栏代码块换语法色。',
55
+ 'card.shell': '周边',
56
+ 'card.shellSkin': '侧栏与标签条',
57
+ 'card.shellSkinHint': '侧栏行与活动标签改成圆角,去掉会话头部下面那条接缝,输入卡换成 Maple 的纸色。',
58
+ 'card.scope': '配色是全局的;标题、引用、表格、列表与分隔线只在对话正文里换样子。',
59
+ 'card.unavailable': '宿主当前没有提供这份配置。',
60
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 配置到 DOM 的投影:把一份已解析的配置算成 `<html>` 上要写的属性与自定义变量。
3
+ *
4
+ * 这里是纯函数,不碰 document、不读配置源——浏览器半边负责把它的结果写到页面上,
5
+ * 自检直接 import 这个文件断言映射,不用起 DOM。
6
+ *
7
+ * 为什么写在 `<html>` 而不是 `body`:宿主把令牌声明在 `body` 上,我们的样式表也要覆盖
8
+ * 同名变量,写在同一层只能靠注入顺序取胜;写到 `<html>` 上,`body` 里那些 `var(--maple-*)`
9
+ * 一律读得到,而两套配色各自的选择器 (`body` / `body[data-ds-dark-theme]`) 与宿主同形,
10
+ * 只靠后注入这一条优势。**但开关必须写在祖先上**:属性选择器要多一个 key 才能稳定压过宿主。
11
+ */
12
+
13
+ /** 本插件在 `<html>` 上用的根属性:开着时才加,关掉整层样式一并失效。 */
14
+ export const ROOT_ATTRIBUTE = 'data-maple-theme';
15
+
16
+ /** 标题配色方式落到 `<html>` 上的属性名。 */
17
+ export const HEADING_ATTRIBUTE = 'data-maple-heading';
18
+
19
+ /** 标题等级标记的开关属性名。 */
20
+ export const INDICATOR_ATTRIBUTE = 'data-maple-indicator';
21
+
22
+ /** 代码配色的开关属性名。 */
23
+ export const CODE_ATTRIBUTE = 'data-maple-code';
24
+
25
+ /** 外壳装饰(侧栏、标签条、输入区)的开关属性名。 */
26
+ export const SHELL_ATTRIBUTE = 'data-maple-shell';
27
+
28
+ /** 一份配置投影出来的结果:要写的属性与变量。 */
29
+ export interface MapleProjection {
30
+ /** `<html>` 上要设的属性(值为 `null` 表示删掉)。 */
31
+ attributes: Record<string, string | null>;
32
+ /** `<html>` 行内样式上要写的自定义变量(值为 `null` 表示删掉)。 */
33
+ variables: Record<string, string | null>;
34
+ }
35
+
36
+ /** 本插件写在 `<html>` 上的全部变量名:撤痕迹时按这张表逐个删,不靠猜。 */
37
+ export const ROOT_VARIABLES = [
38
+ '--maple-hue-light',
39
+ '--maple-hue-dark',
40
+ '--maple-line-height',
41
+ '--maple-p-gap',
42
+ ] as const;
43
+
44
+ /** 配置里取色相关字段的兜底:宿主没给值时按 Maple 出厂值走。 */
45
+ export interface MapleSettings {
46
+ hueLight: number;
47
+ hueDark: number;
48
+ lineHeight: number;
49
+ paragraphGap: number;
50
+ headingColor: 'colorful' | 'accent' | 'text';
51
+ headingIndicator: boolean;
52
+ codePalette: boolean;
53
+ shellSkin: boolean;
54
+ }
55
+
56
+ /** 出厂值,与宿主半边 Config 的默认值同一组数字。 */
57
+ export const DEFAULT_SETTINGS: Readonly<MapleSettings> = Object.freeze({
58
+ hueLight: 35,
59
+ hueDark: 207,
60
+ lineHeight: 1.75,
61
+ paragraphGap: 10,
62
+ headingColor: 'colorful',
63
+ headingIndicator: true,
64
+ codePalette: true,
65
+ shellSkin: true,
66
+ });
67
+
68
+ /**
69
+ * 把一个数夹进闭区间;不是有限数就用兜底值。
70
+ * 手改过的设置文档可能存进越界值或字符串,投影这一步不信任它。
71
+ * @param value - 候选值。
72
+ * @param min - 下界。
73
+ * @param max - 上界。
74
+ * @param fallback - 不是有限数时用它。
75
+ * @returns 夹好的数。
76
+ */
77
+ export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
78
+ if (typeof value !== 'number' && typeof value !== 'string') return fallback;
79
+ // 空串、全空白,以及 null 被 Number 读成 0 的那一类,都不算「给了一个数」。
80
+ if (typeof value === 'string' && value.trim() === '') return fallback;
81
+ const parsed = typeof value === 'number' ? value : Number(value);
82
+ if (!Number.isFinite(parsed)) return fallback;
83
+ return Math.min(max, Math.max(min, parsed));
84
+ }
85
+
86
+ /**
87
+ * 把宿主可能给出的任意值收成一份可信的配置。
88
+ * @param raw - 配置源里的原始值(可能缺字段、可能越界)。
89
+ * @returns 每个字段都在合法范围内的一份配置。
90
+ */
91
+ export function normalizeSettings(raw: Partial<MapleSettings> | undefined | null): MapleSettings {
92
+ const source = raw ?? {};
93
+ const heading = source.headingColor;
94
+ return {
95
+ hueLight: clampNumber(source.hueLight, 0, 360, DEFAULT_SETTINGS.hueLight),
96
+ hueDark: clampNumber(source.hueDark, 0, 360, DEFAULT_SETTINGS.hueDark),
97
+ lineHeight: clampNumber(source.lineHeight, 1.4, 2, DEFAULT_SETTINGS.lineHeight),
98
+ paragraphGap: clampNumber(source.paragraphGap, 4, 24, DEFAULT_SETTINGS.paragraphGap),
99
+ headingColor: heading === 'colorful' || heading === 'accent' || heading === 'text'
100
+ ? heading
101
+ : DEFAULT_SETTINGS.headingColor,
102
+ headingIndicator: source.headingIndicator ?? DEFAULT_SETTINGS.headingIndicator,
103
+ codePalette: source.codePalette ?? DEFAULT_SETTINGS.codePalette,
104
+ shellSkin: source.shellSkin ?? DEFAULT_SETTINGS.shellSkin,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * 算出一份配置该在 `<html>` 上留下什么。
110
+ *
111
+ * 关掉的开关写成 `null`(调用方删属性),而不是写 `off`:属性不存在时那批规则整段不匹配,
112
+ * 少一个匹配分支也少一处能跟宿主撞车的地方。
113
+ * @param settings - 已收拢到合法范围的配置。
114
+ * @param enabled - 整个主题是否启用;关掉时只留一个「关」标记,其余全部撤掉。
115
+ * @returns 要写的属性与变量。
116
+ */
117
+ export function projectSettings(settings: MapleSettings, enabled = true): MapleProjection {
118
+ if (!enabled) {
119
+ const variables: Record<string, null> = {};
120
+ for (const name of ROOT_VARIABLES) variables[name] = null;
121
+ return {
122
+ attributes: {
123
+ [ROOT_ATTRIBUTE]: null,
124
+ [HEADING_ATTRIBUTE]: null,
125
+ [INDICATOR_ATTRIBUTE]: null,
126
+ [CODE_ATTRIBUTE]: null,
127
+ [SHELL_ATTRIBUTE]: null,
128
+ },
129
+ variables,
130
+ };
131
+ }
132
+ return {
133
+ attributes: {
134
+ [ROOT_ATTRIBUTE]: 'on',
135
+ [HEADING_ATTRIBUTE]: settings.headingColor,
136
+ [INDICATOR_ATTRIBUTE]: settings.headingIndicator ? 'on' : null,
137
+ [CODE_ATTRIBUTE]: settings.codePalette ? 'on' : null,
138
+ [SHELL_ATTRIBUTE]: settings.shellSkin ? 'on' : null,
139
+ },
140
+ variables: {
141
+ '--maple-hue-light': String(settings.hueLight),
142
+ '--maple-hue-dark': String(settings.hueDark),
143
+ '--maple-line-height': String(settings.lineHeight),
144
+ '--maple-p-gap': `${settings.paragraphGap}px`,
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * 把投影写到页面上;同一个元素重复调用是幂等的。
151
+ * @param root - 文档根元素(真实浏览器里是 `document.documentElement`)。
152
+ * @param projection - {@link projectSettings} 的结果。
153
+ */
154
+ export function applyProjection(root: HTMLElement, projection: MapleProjection): void {
155
+ for (const [attribute, value] of Object.entries(projection.attributes)) {
156
+ if (value === null) root.removeAttribute(attribute);
157
+ else root.setAttribute(attribute, value);
158
+ }
159
+ for (const [property, value] of Object.entries(projection.variables)) {
160
+ if (value === null) root.style.removeProperty(property);
161
+ else root.style.setProperty(property, value);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 撤掉本插件在页面上留下的一切痕迹(卸载与关掉主题都走这一条)。
167
+ * @param root - 文档根元素。
168
+ */
169
+ export function clearProjection(root: HTMLElement): void {
170
+ applyProjection(root, projectSettings(normalizeSettings(undefined), false));
171
+ }