skinsuite 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.
package/src/types.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 主题包契约
3
+ *
4
+ * === 主题包 = 一整套呈现方案(版式 + 配色),设备是包内的一层 ===
5
+ * ```
6
+ * theme/themes/<包>/ 主题包:一套设计,三端都住这里
7
+ * ├── index.ts 包定义:meta + (可选)views 声明
8
+ * ├── mobile/ 该设计的**手机端**呈现
9
+ * │ ├── <page>/index.vue 版式实现:该包在该设备的**唯一**版式
10
+ * │ └── colors/<配色>/ 手机端配色:index.ts + theme.scss + assets/
11
+ * ├── standard/ 该设计的**桌面主窗口**呈现(结构同上)
12
+ * │ ├── <page>/index.vue
13
+ * │ └── colors/<配色>/
14
+ * └── mini/ 该设计的**iframe 紧凑版**呈现(结构同上)
15
+ * ├── <page>/index.vue
16
+ * └── colors/<配色>/
17
+ * ```
18
+ * 两个 glob 决定注册表:包定义的 `index.ts`(给出 meta 与 views)与
19
+ * `<包>/<设备>/<页面>/colors/<配色>/index.ts`(id = 配色目录名)。
20
+ * 设备段只认 `mobile` / `standard` / `mini` 三个值,写错则整个目录跳过。
21
+ * **加一个包、加一种设备的配色,都不需要改任何其它文件。**
22
+ *
23
+ * 🔴 **一个主题包 = 一种版式**(2026-09-26 定案,用户:「变体版式使用新包」):
24
+ * `<页面>/` 下**只有** `index.vue` 与 `colors/`,**没有**「版式」这一层目录。
25
+ * 要另一种版式 → **新建包**(`themes/compact/<设备>/<页面>/`)。曾支持过的
26
+ * `<页面>/<版式>/colors/` 形态已删除:换版式要复制整套配色,两套必然漂移。
27
+ *
28
+ * ⚠️ 描述 glob 模式时别写出「星号紧跟斜杠」的字符组合:它会**提前闭合本段块注释**,
29
+ * 报错行号会落在注释之后的正文上(本文件踩过一次)。
30
+ *
31
+ * ⚠️ 包定义**不再是**"该包的默认配色":黑白是**两个具名配色目录**
32
+ * (`<设备>/<页面>/colors/{black,white}/`),与 blue/cyan/rainbow 平级。这样"黑白"
33
+ * 能被显式选中、出现在配色列表里,而不是一个没有名字的隐式兜底。
34
+ * (⚠️ 早期注释里的 `mono` 早已改名成 `black` / `white`,`colors/mono/` 不存在。)
35
+ *
36
+ * ⚠️ **版式跟随主题包 + 设备**:某一页的版式只在**当前包、当前设备**下查找
37
+ * (见 `views/registry.ts`),换一套设计就是换一个包。
38
+ * 版式的**契约**与**注册表**是每页一份的页面文件(应用侧 `theme/views/<page>.ts`),
39
+ * 留在应用侧架构层,**每个包共用一份** —— 跟着进包就会变成 N 份副本,必然漂移。
40
+ *
41
+ * ⚠️ 配色 id 的唯一性作用域是「**同包 × 同设备 × 同页面**」(2026-09-26 收窄:
42
+ * 版式不再是独立维度);跨设备、跨包、跨页面都**可以同名**。
43
+ * `mobile/login/colors/black` 与 `standard/login/colors/black` 并存是正常且必要的
44
+ * —— 它们本就是两套各自独立的配色(这正是"手机端、电脑端分别配置主题"的落点)。
45
+ * URL 上的 `?theme=` 只给一个 id,设备那一段由各调用点在解析时补上
46
+ * (`getThemeRecord(id, pkg, device, page, view)`),歧义在入口就被消解。
47
+ *
48
+ * ⚠️ 同一目录下的 `index.ts` 与 `theme.scss` 配套:只有样式、没有 `index.ts` 的目录
49
+ * 视为残缺(样式被忽略);反过来只有 `index.ts` 没有样式是正常情况。
50
+ *
51
+ * === 值怎么给:token 还是 theme.scss ===
52
+ * • 颜色、圆角、间距、字体族名 → `tokens`(走 `--mauth-*` 变量,会被白名单校验)
53
+ * • 背景图片、webfont、装饰边框、动画、结构级差异
54
+ * → `theme.scss`(写真实 CSS,选择器必须带
55
+ * `html[data-mauth-theme='<id>']` 前缀)
56
+ * 分界线的依据:token 取值要能被白名单校验(因此不接受 url()),
57
+ * 凡是必须写 url() 或需要伪元素/keyframes 的,都用 theme.scss。
58
+ *
59
+ * @author yijiu2025
60
+ */
61
+ import type { ThemeTone } from './tone.js';
62
+ import type { ThemeTokenOverrides } from './tokens.js';
63
+
64
+ /**
65
+ * 设备维度取值:**由宿主的设备清单决定**(内核不绑定某个应用的设备集合)
66
+ *
67
+ * 内核只承诺"设备名是个 `[a-z0-9-]` 字符串"这条形态约定;`mobile` / `standard` /
68
+ * `mini` 是 `oauth21` 的词汇表,不是机制的一部分。取值集合、中文名、判定规则、
69
+ * 容器位置都声明在宿主(本仓 = `oauth21/src/theme/devices.ts`),
70
+ * 于是「加一种设备」只需改宿主清单,内核与任何其它前端都无感。
71
+ *
72
+ * 因此这里用宽松的 `string`:内核无从(也不该)知道有哪些设备。
73
+ * 白名单归一在宿主(`asThemeDevice`)。
74
+ */
75
+ type ThemeDevice = string;
76
+
77
+ /**
78
+ * 配色的元信息(用于列表展示与日志,不参与渲染)
79
+ */
80
+ export interface MauthThemeMeta {
81
+ /**
82
+ * 配色 id,以目录名为准:`themes/<包>/<设备>/<页面>/colors/<配色>/` → 末段目录名。
83
+ * 只允许 `[a-z0-9-]`(它会进 `data-mauth-theme` 属性选择器)。
84
+ * 唯一性作用域 = 「同包 × 同设备 × 同页面」;**跨设备 / 跨包 / 跨页面可以同名**
85
+ * (同名不算冲突:取一份配色时这三段必然同时给出)。
86
+ */
87
+ id: string;
88
+ /** 中文显示名 */
89
+ name: string;
90
+ /** 一句话描述,便于部署方挑选 */
91
+ description?: string;
92
+ /** 主色/强调色,供后台预览色卡使用(不参与渲染,渲染以 tokens 为准) */
93
+ preview?: { primary: string; accent: string };
94
+ /** 作者(可选) */
95
+ author?: string;
96
+ }
97
+
98
+ /**
99
+ * 主题包定义(`themes/<包>/index.ts` 的默认导出)
100
+ *
101
+ * ⚠️ 它**不再是**"该包的默认配色":黑白等基础档以实体配色目录存在。本文件只声明
102
+ * 包级信息与可选的版式声明。
103
+ */
104
+ export interface MauthThemePackage {
105
+ meta: MauthThemeMeta;
106
+ /**
107
+ * 包级版式声明(可选)—— 该包**所有设备、所有配色**共用的默认
108
+ *
109
+ * key = 页面名,与 `theme/views/<page>.ts` 的页面名一致(如 `register`)
110
+ * value = 版式 id。**注意版式 id ≡ 主题包名**(一个主题包 = 一种版式),所以合法
111
+ * 取值只有两种:本包自己的名字,或**另一个包的名字**(跨包切版式)。
112
+ *
113
+ * 写在**包定义**(`themes/<包>/index.ts`)→ 全包共用;
114
+ * 写在**配色里**(`<设备>/colors/<配色>/index.ts`)→ 只覆盖那一套配色。
115
+ *
116
+ * 写错 / 未登记一律回退**当前包**的版式(由 `theme/views/<page>.ts` 判定,
117
+ * 不会因为包写错而白屏)。
118
+ *
119
+ * ⚠️ URL `?view=` 优先级更高:声明不会挡住联调时的单次覆盖。
120
+ */
121
+ views?: Record<string, string>;
122
+ /**
123
+ * 本包的**覆盖范围声明**(可选,2026-09-27 起)
124
+ *
125
+ * 主题包可以只覆盖部分设备 / 部分页面(例:`compact` 只覆盖 `mobile` 设备的
126
+ * `register` 页,其余页面回退到 `default` 包)。以前这个"只覆盖一部分"是**数据性
127
+ * 事实**,却只能靠目录口径关卡里的 `if (pkg === 'compact')` 这类**写死的包名**来表达
128
+ * —— 于是每新增一个部分覆盖的包,都要回来改一个上千行的关卡脚本(「关卡即配置」)。
129
+ *
130
+ * 现在把范围写成包**自带的声明**(声明与实现同目录,复制主题包时声明跟着走):
131
+ * • `devices` —— 覆盖哪些设备;缺省 = **全部设备**
132
+ * • `pages` —— 覆盖哪些页面;缺省 = **全部页面**
133
+ * • `requiredColors` —— 每个「包×设备×页面」作用域都应具备的配色目录名;
134
+ * 缺省 = `['black','white']`(黑白是并列实体颜色)
135
+ *
136
+ * 目录口径关卡据此**推导**该包应有的目录,不再写死包名 / 颜色名。
137
+ *
138
+ * ⚠️ 声明**只影响关卡校验**,不改变运行时行为:注册表仍是"glob 扫到什么就登记什么",
139
+ * 声明只是把"这个包本来就只打算覆盖这些范围"从隐式约定变成显式契约。
140
+ * 声明的范围**只能 ≤ 实际目录**(写多会红,写少无害)。
141
+ */
142
+ coverage?: {
143
+ devices?: string[];
144
+ pages?: string[];
145
+ requiredColors?: string[];
146
+ };
147
+ }
148
+
149
+ /**
150
+ * 一套**配色**的定义(`<设备>/colors/<配色>/index.ts` 的默认导出)
151
+ *
152
+ * `tokens` 的语义(2026-09-25 改):
153
+ * • 是**一组扁平值**(`Record<string, string>`),**不分明暗档**
154
+ * • 每套配色**自带完整底色**,选谁就是谁 —— 与当前明暗偏好无关
155
+ * • 明暗(`mode`)仍存在,但只在**基线 SCSS** 那层生效(`html.dark` 选择器)
156
+ *
157
+ * ⚠️ 因此**不要**再写 `{ light, dark }` 两档,也**不要**为"深色版"单开一份 token:
158
+ * 需要深色品牌色就**另加一个颜色目录**(如 `navy`)。用户 2026-09-25 原话:
159
+ * 「没有深浅两档了,深和浅就是两种颜色配置。」
160
+ *
161
+ * ⚠️ 若配色把 `--mauth-header-bg` 设为 `transparent`(把底色交还给页面),
162
+ * 必须同时声明 `--mauth-canvas`(页面最上沿的颜色),否则"页面之外的画布色"
163
+ * 会退回给浏览器内核自己决定 → 真机与电脑不一致。参考 cyan 配色的 theme.scss。
164
+ *
165
+ * ⚠️ 黑白(`black` / `white`)是与 `blue` / `cyan` **完全并列**的普通颜色,
166
+ * 各自写全 token(自带底色),不再是"零 token 的基线配色"。见 README「五色并列」。
167
+ */
168
+ export interface MauthThemeColor {
169
+ meta: MauthThemeMeta;
170
+ /**
171
+ * 这套配色自带的**明暗系别** —— 决定切换夜间模式时如何联动(见 `./tone`)
172
+ *
173
+ * `'light'` 白系:浅底深字(`white` / `blue` / `cyan` / `rainbow`)
174
+ * `'dark'` 黑系:深底浅字(`black`)
175
+ *
176
+ * 🔴 **必填**:漏写直接编译报错。系别判错会让"开夜间自动变黑"整个失效,
177
+ * 而且现象隐蔽(只是不联动、不报错),所以宁可让它构建期就变红。
178
+ *
179
+ * ⚠️ 它描述的是**配色之间**的关系,不是"一套配色内部的明暗两档"
180
+ * (那个概念已于 2026-09-25 取消,见 `tokens` 说明)。
181
+ */
182
+ tone: ThemeTone;
183
+ tokens?: ThemeTokenOverrides;
184
+ /** 该套配色专属的版式声明(覆盖包级声明);一般不必写,见 `MauthThemePackage.views` */
185
+ views?: Record<string, string>;
186
+ }
187
+
188
+ /** 注册表里一条已解析好的配色记录 */
189
+ export interface MauthThemeRecord {
190
+ /**
191
+ * 该配色所属的**主题包** id(= `themes/<包>/` 的目录名)
192
+ *
193
+ * 版式只在当前包内查找(版式跟随主题),所以容器与版式注册表都需要它。
194
+ * 它同时是调试面板上"这套配色来自哪个包"的答案。
195
+ */
196
+ pkg: string;
197
+ /**
198
+ * 该配色所属的**设备**(= `themes/<包>/<设备>/` 的目录名)
199
+ *
200
+ * 决定版式在 `mobile/` / `standard/` / `mini/` 下查找。与 `pkg` 一起构成版式的
201
+ * 查找范围(版式 = 当前包 × 当前设备)。
202
+ */
203
+ device: ThemeDevice;
204
+ /**
205
+ * 该配色所属的**页面**(= `themes/<包>/<设备>/<页面>/` 的目录名,如 `register`)
206
+ *
207
+ * 🔴 配色与**版式同级**(`<页面>/colors/<颜色>/`,2026-09-26 收窄),
208
+ * 所以"这套颜色给哪一页用"是它归属的一部分 —— 同一个颜色 id 在不同页面下
209
+ * 各有一份(那是正常且必要的,两页的尺寸/间距本就不必一致)。
210
+ */
211
+ page: string;
212
+ meta: MauthThemeMeta;
213
+ /**
214
+ * 该配色的**明暗系别**(从配色定义原样带出)—— 供 store 做明暗联动判定
215
+ *
216
+ * ⚠️ 与 `meta` 不同,它**参与逻辑**(决定"开夜间该切到哪套配色"),
217
+ * 因此不塞进 `meta` 这个"只用于展示、不参与渲染"的口袋。
218
+ */
219
+ tone: ThemeTone;
220
+ /** 该主题的 token(已从配色里取出,未做安全校验——校验在注入时统一做) */
221
+ tokens: ThemeTokenOverrides | undefined;
222
+ /** 该主题的版式声明(页面名 → 版式 id);未声明为 undefined */
223
+ views: Record<string, string> | undefined;
224
+ /** 该主题附加样式的惰性加载器;无 theme.scss 时为 undefined */
225
+ loadStyle: (() => Promise<string>) | undefined;
226
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 版式参数的**设备维度**解析 —— `?view.<设备>` 支持
3
+ *
4
+ * === 解决什么 ===
5
+ * 同一条链接要能给三台设备各指定一套版式:
6
+ * ```
7
+ * /login?view=compact 三端共用(哪端没有就自动下沉到当前包)
8
+ * /login?view.mobile=default&view.standard=compact 各端各指定(mini 走通用/包默认)
9
+ * /login?view.mobile=default&view=default 设备专属优先,通用键兜底
10
+ * ```
11
+ *
12
+ * === 取值链(每台设备各一条)===
13
+ * `view.<设备>` → `view` → (返回 undefined:由调用方走包声明 → 环境变量 → 当前包)
14
+ *
15
+ * === 为什么收在内核(而不是各页文件里)===
16
+ * 三个页面(login / register / forgot-password)各有**一份**注册表文件,但"哪个 query
17
+ * key 属于哪台设备"这件事与页面无关 —— 抄三份必然漂移。各页的 `pick*ViewId` 只负责
18
+ * "拿到原始值之后怎么解析(落在哪个包、是否合法)",取哪个键这一步交给本模块。
19
+ * 2026-09-26 抽包 Stage 1 起它住在 `skinsuite`:任何复用这套机制的前端
20
+ * 都直接拿到这份实现,而不是再抄一遍。
21
+ *
22
+ * === 与配色侧同形(很重要)===
23
+ * 配色侧(`oauth21/src/stores/theme.ts` 的 `readUrlIntent`)用同样的键形
24
+ * (`theme.<设备>` / `skin.<设备>`)与同样的"设备专属优先"链。**两侧必须同形**,
25
+ * 否则配置者要记两套规则,`?theme.mobile=blue&view.standard=compact` 这种混搭就写不出来。
26
+ * 但两侧读的入口不同:配色侧读 `window.location.search`(store 是全局的,不依赖路由),
27
+ * 版式侧读**路由 query**(容器手上只有 `route.query`)—— 所以不是同一个函数,是同一套规则。
28
+ *
29
+ * ⚠️ query key 里带 `.` 是合法的(`URLSearchParams` 与 vue-router 都原样保留),
30
+ * 但访问时**必须用下标**(`query['view.standard']`)—— 点号在 JS 里不是合法标识符。
31
+ *
32
+ * @author yijiu2025
33
+ * @since 2026-09-26
34
+ */
35
+ /** 支持设备维度的参数名(与配色侧 `readUrlIntent` 的键保持一致) */
36
+ export type DeviceScopedParam = 'view' | 'theme';
37
+
38
+ export const DEVICE_SCOPED_PARAMS: readonly DeviceScopedParam[] = ['view', 'theme'];
39
+
40
+ /** 值"存在且非空" —— 空串 / 空数组视为**未指定**,不顶掉兜底键 */
41
+ function isPresent(value: unknown): boolean {
42
+ if (value === undefined || value === null) return false;
43
+ if (typeof value === 'string') return value.trim() !== '';
44
+ if (Array.isArray(value)) {
45
+ return value.some(v => typeof v === 'string' && v.trim() !== '');
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * 按**设备**读取版式 / 配色参数
52
+ *
53
+ * @param query 路由 query(`route.query`)
54
+ * @param name 参数名(`'view'` / `'theme'`)
55
+ * @param device 当前设备 —— 容器用自己的设备常量传进来(文件位置即身份)
56
+ * @returns `query['<name>.<device>']` 优先;缺席或空值时退 `query[name]`;
57
+ * 两者都没有则返回 undefined,表示"**这台设备**没有被显式指定"
58
+ *
59
+ * ⚠️ 只决定"取哪个键",**不做合法性校验** —— 校验属于各页 `pick*ViewId` 与配色注册表
60
+ * (本函数也拿不到"当前包里有没有这套版式")。返回值可能是数组(`?view=a&view=b`),
61
+ * 由下游按"非字符串即非法"处理,与改前的行为一致。
62
+ * ⚠️ 空串视为"未指定":`?view.standard=` 不该把 `?view=compact` 顶掉 ——
63
+ * 部署方常把参数留空当作"这台设备不特殊指定"。
64
+ * ⚠️ 设备名是**宿主**的词汇表(本包不认识具体取值),所以这里只把它当字符串拼键;
65
+ * 「这个名字认不认得」由宿主的设备清单归一(`asThemeDevice`)。
66
+ */
67
+ export function readDeviceParam(
68
+ query: Record<string, unknown> | undefined,
69
+ name: DeviceScopedParam,
70
+ device: string
71
+ ): unknown {
72
+ const scoped = query?.[`${name}.${device}`];
73
+ if (isPresent(scoped)) return scoped;
74
+ return query?.[name];
75
+ }