melta-app 0.2.3 → 0.4.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/LICENSE +2 -2
- package/README.md +39 -0
- package/docs/patterns.md +263 -0
- package/lib/module/components/Radio.js +11 -7
- package/lib/module/components/Radio.js.map +1 -1
- package/lib/module/components/TextField.js +31 -5
- package/lib/module/components/TextField.js.map +1 -1
- package/lib/module/safe-area/index.js +29 -24
- package/lib/module/safe-area/index.js.map +1 -1
- package/lib/module/theme/ThemeProvider.js +45 -6
- package/lib/module/theme/ThemeProvider.js.map +1 -1
- package/lib/module/theme/define-theme.js +360 -0
- package/lib/module/theme/define-theme.js.map +1 -0
- package/lib/module/theme/index.js +2 -0
- package/lib/module/theme/index.js.map +1 -1
- package/lib/typescript/src/components/Radio.d.ts.map +1 -1
- package/lib/typescript/src/components/TextField.d.ts +21 -4
- package/lib/typescript/src/components/TextField.d.ts.map +1 -1
- package/lib/typescript/src/safe-area/index.d.ts +7 -3
- package/lib/typescript/src/safe-area/index.d.ts.map +1 -1
- package/lib/typescript/src/theme/ThemeProvider.d.ts +26 -5
- package/lib/typescript/src/theme/ThemeProvider.d.ts.map +1 -1
- package/lib/typescript/src/theme/define-theme.d.ts +113 -0
- package/lib/typescript/src/theme/define-theme.d.ts.map +1 -0
- package/lib/typescript/src/theme/index.d.ts +3 -0
- package/lib/typescript/src/theme/index.d.ts.map +1 -1
- package/lib/typescript/src/theme/types.d.ts +5 -0
- package/lib/typescript/src/theme/types.d.ts.map +1 -1
- package/llms.txt +62 -0
- package/package.json +5 -2
- package/src/components/Radio.tsx +9 -6
- package/src/components/TextField.tsx +48 -6
- package/src/theme/ThemeProvider.tsx +74 -12
- package/src/theme/define-theme.ts +426 -0
- package/src/theme/index.ts +16 -0
- package/src/theme/types.ts +6 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* define-theme — 消費者ブランドの theme を注入するための入口(設計書 Step 2-①)。
|
|
3
|
+
*
|
|
4
|
+
* melta 既定の theme は melta-contracts から codegen した `nativeTheme` だが、消費者が自分の
|
|
5
|
+
* ブランドトークンで塗り替えられる正規の口をここで提供する。`defineTheme()` を通した成果物だけを
|
|
6
|
+
* `<ThemeProvider theme={...}>` が受け取る(Chakra の `$$chakra` と同じ brand 方式。
|
|
7
|
+
* 生オブジェクトの直渡しを型で塞ぎ、validation を必ず1回通すため)。
|
|
8
|
+
*
|
|
9
|
+
* 設計の要点:
|
|
10
|
+
* - **capability は宣言でなく導出する**。「この theme は dark しか持たない」は
|
|
11
|
+
* `color.semantic` のキー集合そのものが語る(Primer の colorSchemes / DTCG Resolver の contexts と同じ流儀)。
|
|
12
|
+
* 別欄で宣言すると「宣言と実体の不一致」という新種の嘘が生まれ、それを警備する検査が要るだけになる。
|
|
13
|
+
* - **嘘の値で埋めない**。単一 colorScheme の theme は存在しない mode を **書かない**。
|
|
14
|
+
* ゼロ埋め・他 mode のコピー・センチネル値はいずれも「その mode が存在する」という嘘をトークンに残す。
|
|
15
|
+
* - **Proxy は使わない**。Hermes は Proxy が使われていなくても全プロパティアクセスに分岐が入る
|
|
16
|
+
* (hermes#33)。存在しない mode の検出は non-enumerable な getter で行う(下記)。
|
|
17
|
+
*
|
|
18
|
+
* 注: accent(primary パレット / text-accent)・elevation・status の light 値は、単一 dark の
|
|
19
|
+
* theme でも現状 **必須のまま**。①の射程は colorScheme 軸だけで、それらの capability 化は
|
|
20
|
+
* 後続(accent 依存の除去)で扱う。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ElevationKey,
|
|
25
|
+
FontSizeKey,
|
|
26
|
+
FontWeightKey,
|
|
27
|
+
NativeTheme,
|
|
28
|
+
PrimaryScale,
|
|
29
|
+
RadiusKey,
|
|
30
|
+
SemanticColors,
|
|
31
|
+
SpacingKey,
|
|
32
|
+
StatusColors,
|
|
33
|
+
ThemeColor,
|
|
34
|
+
ThemeMode,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// dev 判定
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 開発ビルドか。**裸の `__DEV__` を直接書かないこと。**
|
|
43
|
+
*
|
|
44
|
+
* `__DEV__` は Metro のグローバルで、Metro なら prod ビルド時に `false` へ畳み込まれて
|
|
45
|
+
* dev ブロックごと消える。一方 melta は package.json の exports の `import` / `default` 条件で
|
|
46
|
+
* 同じ ESM を非 Metro の web バンドラにも配っており、react-native-web は `__DEV__` を
|
|
47
|
+
* **定義しない**(vendored 側で `process.env.NODE_ENV` にローカル shim している)。
|
|
48
|
+
* そこで `typeof` で存在を確かめ、無い環境では `NODE_ENV === "development"` のときだけ有効にする
|
|
49
|
+
* (判定不能なら dev 機能を**切る**側に倒す = 本番に dev コストを漏らさない)。
|
|
50
|
+
* `process` を識別子として直接参照しないのは、それを書くと tsconfig.build.json(bob build)だけが
|
|
51
|
+
* TS2591 で落ちるため(typecheck は通ってしまうので気づけない)。
|
|
52
|
+
*/
|
|
53
|
+
export const isDev: boolean =
|
|
54
|
+
typeof __DEV__ !== "undefined"
|
|
55
|
+
? __DEV__
|
|
56
|
+
: (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV ===
|
|
57
|
+
"development";
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// 型
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** theme が持つ配色の能力。`color.semantic` のキー集合から導出される(宣言ではない)。 */
|
|
64
|
+
export type ColorSchemeCapability = "light-dark" | "single-light" | "single-dark";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 解決済みの capability 集合。context 経由で全 component が読める。
|
|
68
|
+
* 後続で accent / elevation の軸が増えるのもこの器(それらはキー集合から導出できないので宣言になる)。
|
|
69
|
+
*/
|
|
70
|
+
export interface ResolvedCapabilities {
|
|
71
|
+
colorScheme: ColorSchemeCapability;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `defineTheme()` の入力。`NativeTheme` との差は `color.semantic` が mode ごとに任意な点だけ。
|
|
76
|
+
* 持っていない mode は **書かない**(capability はキー集合から導出される)。
|
|
77
|
+
*/
|
|
78
|
+
export interface ThemeOptions extends Omit<NativeTheme, "color"> {
|
|
79
|
+
/** 診断・エラーメッセージ用の識別子(任意)。preset compiler の成果物では preset 名を入れる。 */
|
|
80
|
+
readonly id?: string;
|
|
81
|
+
readonly version?: string;
|
|
82
|
+
color: Omit<ThemeColor, "semantic"> & {
|
|
83
|
+
semantic: Partial<Record<ThemeMode, SemanticColors>>;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `defineTheme()` の成果物。`ThemeProvider` の `theme` prop が受け取れる唯一の形。
|
|
89
|
+
* `$$melta` brand により生オブジェクトの直渡しを型で塞ぐ(= validation を必ず1回通す)。
|
|
90
|
+
*/
|
|
91
|
+
export interface ResolvedNativeTheme extends ThemeOptions {
|
|
92
|
+
readonly $$melta: true;
|
|
93
|
+
readonly capabilities: ResolvedCapabilities;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 解決した mode が theme の能力と食い違ったこと。ThemeProvider は dev で報告し、値は clamp する。 */
|
|
97
|
+
export interface ThemeModeViolation {
|
|
98
|
+
kind: "forced-mode-unsupported";
|
|
99
|
+
/** 呼び出し側が forcedMode で明示した mode。 */
|
|
100
|
+
requested: ThemeMode;
|
|
101
|
+
/** 実際に使われる mode。 */
|
|
102
|
+
resolved: ThemeMode;
|
|
103
|
+
colorScheme: ColorSchemeCapability;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// キー集合(型と実行時の二重管理を防ぐ: Record<K, true> で網羅性を tsc に検査させる)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
function keysOf<T extends object>(map: T): (keyof T)[] {
|
|
111
|
+
return Object.keys(map) as (keyof T)[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MODES = keysOf<Record<ThemeMode, true>>({ light: true, dark: true });
|
|
115
|
+
|
|
116
|
+
const SEMANTIC_KEYS = keysOf<Record<keyof SemanticColors, true>>({
|
|
117
|
+
"bg-page": true,
|
|
118
|
+
"bg-page-alt": true,
|
|
119
|
+
"bg-surface": true,
|
|
120
|
+
"bg-surface-alt": true,
|
|
121
|
+
"text-heading": true,
|
|
122
|
+
"text-default": true,
|
|
123
|
+
"text-muted": true,
|
|
124
|
+
"border-default": true,
|
|
125
|
+
"border-strong": true,
|
|
126
|
+
"input-bg": true,
|
|
127
|
+
"input-border": true,
|
|
128
|
+
"text-accent": true,
|
|
129
|
+
"text-on-accent": true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const PRIMARY_KEYS = keysOf<Record<PrimaryScale, true>>({
|
|
133
|
+
"50": true,
|
|
134
|
+
"100": true,
|
|
135
|
+
"200": true,
|
|
136
|
+
"300": true,
|
|
137
|
+
"400": true,
|
|
138
|
+
"500": true,
|
|
139
|
+
"600": true,
|
|
140
|
+
"700": true,
|
|
141
|
+
"800": true,
|
|
142
|
+
"900": true,
|
|
143
|
+
"950": true,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const STATUS_KINDS = keysOf<Record<keyof ThemeColor["status"], true>>({
|
|
147
|
+
success: true,
|
|
148
|
+
warning: true,
|
|
149
|
+
danger: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const STATUS_COLOR_KEYS = keysOf<Record<keyof StatusColors, true>>({
|
|
153
|
+
base: true,
|
|
154
|
+
subtleLight: true,
|
|
155
|
+
textLight: true,
|
|
156
|
+
subtleDark: true,
|
|
157
|
+
textDark: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const SPACING_KEYS = keysOf<Record<SpacingKey, true>>({
|
|
161
|
+
"1": true,
|
|
162
|
+
"2": true,
|
|
163
|
+
"3": true,
|
|
164
|
+
"4": true,
|
|
165
|
+
"5": true,
|
|
166
|
+
"6": true,
|
|
167
|
+
"8": true,
|
|
168
|
+
"10": true,
|
|
169
|
+
"12": true,
|
|
170
|
+
"14": true,
|
|
171
|
+
"16": true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const RADIUS_KEYS = keysOf<Record<RadiusKey, true>>({
|
|
175
|
+
sm: true,
|
|
176
|
+
md: true,
|
|
177
|
+
lg: true,
|
|
178
|
+
full: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const ELEVATION_KEYS = keysOf<Record<ElevationKey, true>>({
|
|
182
|
+
none: true,
|
|
183
|
+
sm: true,
|
|
184
|
+
md: true,
|
|
185
|
+
overlay: true,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const FONT_SIZE_KEYS = keysOf<Record<FontSizeKey, true>>({
|
|
189
|
+
xxs: true,
|
|
190
|
+
xs: true,
|
|
191
|
+
sm: true,
|
|
192
|
+
base: true,
|
|
193
|
+
lg: true,
|
|
194
|
+
xl: true,
|
|
195
|
+
"2xl": true,
|
|
196
|
+
"3xl": true,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const FONT_WEIGHT_KEYS = keysOf<Record<FontWeightKey, true>>({
|
|
200
|
+
normal: true,
|
|
201
|
+
medium: true,
|
|
202
|
+
semibold: true,
|
|
203
|
+
bold: true,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// capability の導出
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** theme が実際に値を持っている mode(enumerable なキーのみ = dev getter を踏まない)。 */
|
|
211
|
+
export function declaredModes(semantic: Partial<Record<ThemeMode, SemanticColors>>): ThemeMode[] {
|
|
212
|
+
return MODES.filter((mode) => Object.prototype.hasOwnProperty.call(semantic, mode));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** colorScheme capability を `color.semantic` のキー集合から導出する。 */
|
|
216
|
+
export function deriveColorScheme(
|
|
217
|
+
semantic: Partial<Record<ThemeMode, SemanticColors>>,
|
|
218
|
+
): ColorSchemeCapability {
|
|
219
|
+
const modes = declaredModes(semantic);
|
|
220
|
+
if (modes.length === 2) return "light-dark";
|
|
221
|
+
if (modes[0] === "dark") return "single-dark";
|
|
222
|
+
if (modes[0] === "light") return "single-light";
|
|
223
|
+
throw new Error(
|
|
224
|
+
"melta: theme.color.semantic に light / dark のどちらも無い。最低1つの mode が要る。",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** capability が実際に描ける mode。 */
|
|
229
|
+
export function supportedModes(colorScheme: ColorSchemeCapability): ThemeMode[] {
|
|
230
|
+
if (colorScheme === "single-dark") return ["dark"];
|
|
231
|
+
if (colorScheme === "single-light") return ["light"];
|
|
232
|
+
return ["light", "dark"];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// mode 解決
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 表示 mode を解決する(純関数。ThemeProvider はこの結果に従うだけで判定はしない)。
|
|
241
|
+
*
|
|
242
|
+
* - **OS 由来の不一致は無反応で clamp する**。「light テーマを作らない」は消費者の意図的な設計判断で、
|
|
243
|
+
* OS が light なのは環境の事実にすぎない。ここで警告を出すと light 設定のユーザ全員に出てしまう
|
|
244
|
+
* (CSS の `color-scheme` / Expo の `userInterfaceStyle` も黙って clamp する)。
|
|
245
|
+
* - **`forcedMode` との不一致は報告する**。宣言同士の矛盾=呼び出し側の間違いなので violation を返す。
|
|
246
|
+
* ただし throw はしない(clamp して描き続ける)。厳格に落としたいテストや compiler が
|
|
247
|
+
* violation を error に昇格させる側の責務にする。
|
|
248
|
+
*/
|
|
249
|
+
export function resolveMode(
|
|
250
|
+
colorScheme: ColorSchemeCapability,
|
|
251
|
+
forcedMode: ThemeMode | undefined,
|
|
252
|
+
systemMode: ThemeMode,
|
|
253
|
+
): { mode: ThemeMode; violation?: ThemeModeViolation } {
|
|
254
|
+
const supported = supportedModes(colorScheme);
|
|
255
|
+
const requested = forcedMode ?? systemMode;
|
|
256
|
+
if (supported.includes(requested)) return { mode: requested };
|
|
257
|
+
|
|
258
|
+
const resolved = supported[0];
|
|
259
|
+
// OS 由来の clamp は「事故」ではないので violation を立てない。
|
|
260
|
+
if (forcedMode === undefined) return { mode: resolved };
|
|
261
|
+
return {
|
|
262
|
+
mode: resolved,
|
|
263
|
+
violation: { kind: "forced-mode-unsupported", requested, resolved, colorScheme },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// validation(開発時のみ。純関数なのでテストからも直接呼べる)
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
function missingKeys(target: object | undefined, keys: readonly (string | number)[]): string[] {
|
|
272
|
+
if (target === null || typeof target !== "object") return [...keys].map(String);
|
|
273
|
+
return keys.filter((key) => !Object.prototype.hasOwnProperty.call(target, key)).map(String);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* theme の形を検査して問題の一覧を返す(空配列 = 問題なし)。
|
|
278
|
+
*
|
|
279
|
+
* 「宣言した mode の色が欠けている」「as キャストでキーが抜けた」といった、型を通り抜けた欠落を拾う。
|
|
280
|
+
* `defineTheme()` が dev で1回だけ呼ぶ。**render 中に呼ばないこと**(theme-ui は Provider の
|
|
281
|
+
* render body で毎レンダー全色を再帰走査して性能を落とした前例がある)。
|
|
282
|
+
*/
|
|
283
|
+
export function validateTheme(options: ThemeOptions): string[] {
|
|
284
|
+
const problems: string[] = [];
|
|
285
|
+
const where = options.id ? `theme "${options.id}"` : "theme";
|
|
286
|
+
|
|
287
|
+
const semantic = options.color?.semantic;
|
|
288
|
+
if (semantic === null || typeof semantic !== "object") {
|
|
289
|
+
problems.push(`${where}: color.semantic が無い`);
|
|
290
|
+
return problems;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const unknownModes = Object.keys(semantic).filter(
|
|
294
|
+
(key) => !(MODES as string[]).includes(key),
|
|
295
|
+
);
|
|
296
|
+
for (const key of unknownModes) {
|
|
297
|
+
problems.push(`${where}: color.semantic の未知のキー "${key}"(light / dark のみ)`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const modes = declaredModes(semantic);
|
|
301
|
+
if (modes.length === 0) {
|
|
302
|
+
problems.push(`${where}: color.semantic に light / dark のどちらも無い`);
|
|
303
|
+
}
|
|
304
|
+
for (const mode of modes) {
|
|
305
|
+
const missing = missingKeys(semantic[mode], SEMANTIC_KEYS);
|
|
306
|
+
if (missing.length > 0) {
|
|
307
|
+
problems.push(`${where}: color.semantic.${mode} に欠けている色 — ${missing.join(", ")}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const groups: [string, object | undefined, readonly string[]][] = [
|
|
312
|
+
["color.primary", options.color?.primary, PRIMARY_KEYS],
|
|
313
|
+
["spacing", options.spacing, SPACING_KEYS],
|
|
314
|
+
["radius", options.radius, RADIUS_KEYS],
|
|
315
|
+
["elevation", options.elevation, ELEVATION_KEYS],
|
|
316
|
+
["typography.fontSize", options.typography?.fontSize, FONT_SIZE_KEYS],
|
|
317
|
+
["typography.fontWeight", options.typography?.fontWeight, FONT_WEIGHT_KEYS],
|
|
318
|
+
["color.status", options.color?.status, STATUS_KINDS],
|
|
319
|
+
];
|
|
320
|
+
for (const [label, target, keys] of groups) {
|
|
321
|
+
const missing = missingKeys(target, keys);
|
|
322
|
+
if (missing.length > 0) {
|
|
323
|
+
problems.push(`${where}: ${label} に欠けているキー — ${missing.join(", ")}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const status = options.color?.status;
|
|
328
|
+
if (status !== null && typeof status === "object") {
|
|
329
|
+
for (const kind of STATUS_KINDS) {
|
|
330
|
+
if (!Object.prototype.hasOwnProperty.call(status, kind)) continue;
|
|
331
|
+
const missing = missingKeys(status[kind], STATUS_COLOR_KEYS);
|
|
332
|
+
if (missing.length > 0) {
|
|
333
|
+
problems.push(`${where}: color.status.${kind} に欠けているキー — ${missing.join(", ")}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return problems;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// defineTheme
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 持っていない mode に「読んだら落ちる」getter を置く(**dev / production 共通**)。
|
|
347
|
+
*
|
|
348
|
+
* 値は捏造しない(他 mode のコピーもゼロ埋めもしない)。放置して `undefined` にすると
|
|
349
|
+
* 実際に踏んだとき `Cannot read properties of undefined (reading 'bg-page')` としか出ず、
|
|
350
|
+
* どの theme のどの mode の話か分からない。**落ちること自体は getter の有無に関わらず同じ**なので、
|
|
351
|
+
* 原因を名指しするメッセージを本番のクラッシュレポートにも残す方を選ぶ。
|
|
352
|
+
*
|
|
353
|
+
* **non-enumerable にするのが要点**: spread / `Object.keys` / `JSON.stringify` /
|
|
354
|
+
* React DevTools の context 列挙では踏まれない。`theme.color.semantic.light` と
|
|
355
|
+
* 明示的に書いたときだけ落ちる。
|
|
356
|
+
*
|
|
357
|
+
* ホットパス(全 style resolver が引く `semantic[mode]`)にアクセサを混ぜることになるが、
|
|
358
|
+
* getter が付くのは **単一 colorScheme の theme を注入した場合だけ**。既定の light-dark theme
|
|
359
|
+
* には1つも付かないので、既存消費者の実行経路は変わらない。
|
|
360
|
+
*/
|
|
361
|
+
function installMissingModeGuards(
|
|
362
|
+
semantic: Partial<Record<ThemeMode, SemanticColors>>,
|
|
363
|
+
where: string,
|
|
364
|
+
colorScheme: ColorSchemeCapability,
|
|
365
|
+
): void {
|
|
366
|
+
for (const mode of MODES) {
|
|
367
|
+
if (Object.prototype.hasOwnProperty.call(semantic, mode)) continue;
|
|
368
|
+
Object.defineProperty(semantic, mode, {
|
|
369
|
+
enumerable: false,
|
|
370
|
+
configurable: true,
|
|
371
|
+
get(): never {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`melta: ${where} は colorScheme=${colorScheme} なので color.semantic.${mode} を持たない。` +
|
|
374
|
+
`現在の mode の色は useTheme().colors(または color.semantic[mode])から取ること。`,
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** dev 限定の再帰 freeze。enumerable なキーだけ辿るので上の guard getter は踏まない。 */
|
|
382
|
+
function deepFreeze(value: unknown): void {
|
|
383
|
+
if (value === null || typeof value !== "object") return;
|
|
384
|
+
if (Object.isFrozen(value)) return;
|
|
385
|
+
Object.freeze(value);
|
|
386
|
+
for (const key of Object.keys(value)) {
|
|
387
|
+
deepFreeze((value as Record<string, unknown>)[key]);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* ブランド theme を組み立てる。`ThemeProvider` の `theme` prop に渡せるのはこの戻り値だけ。
|
|
393
|
+
*
|
|
394
|
+
* **module スコープで1回だけ呼ぶこと**(render の中で呼ぶと毎レンダー新しい参照になり、
|
|
395
|
+
* context の consumer が全部再レンダーする)。styled-components / Emotion が docs で
|
|
396
|
+
* 同じことを求めているのと同じ理由。
|
|
397
|
+
*
|
|
398
|
+
* dev では validateTheme を1回通し、問題があれば throw する(構造が壊れた theme は
|
|
399
|
+
* 後段で必ず事故になるので早く落とす)。production では検査ごと省く。
|
|
400
|
+
*/
|
|
401
|
+
export function defineTheme(options: ThemeOptions): ResolvedNativeTheme {
|
|
402
|
+
if (isDev) {
|
|
403
|
+
const problems = validateTheme(options);
|
|
404
|
+
if (problems.length > 0) {
|
|
405
|
+
throw new Error(`melta: theme の形が不正。\n- ${problems.join("\n- ")}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const semantic: Partial<Record<ThemeMode, SemanticColors>> = { ...options.color.semantic };
|
|
410
|
+
const colorScheme = deriveColorScheme(semantic);
|
|
411
|
+
|
|
412
|
+
const theme: ResolvedNativeTheme = {
|
|
413
|
+
...options,
|
|
414
|
+
color: { ...options.color, semantic },
|
|
415
|
+
capabilities: { colorScheme },
|
|
416
|
+
$$melta: true,
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// guard は本番でも置く(値は捏造せず、踏んだときのメッセージだけ良くする)。
|
|
420
|
+
// freeze より先に置くこと(freeze 後は defineProperty できない)。
|
|
421
|
+
installMissingModeGuards(semantic, options.id ? `theme "${options.id}"` : "theme", colorScheme);
|
|
422
|
+
|
|
423
|
+
if (isDev) deepFreeze(theme);
|
|
424
|
+
|
|
425
|
+
return theme;
|
|
426
|
+
}
|
package/src/theme/index.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* melta-app theme エントリ。
|
|
3
3
|
* 型は types.ts、値は native-theme.ts(melta-contracts から自動生成)。
|
|
4
|
+
* 消費者ブランドの theme を注入する口は define-theme.ts(`defineTheme`)。
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
export * from "./types";
|
|
7
8
|
export { nativeTheme } from "./native-theme";
|
|
8
9
|
export { ThemeProvider, useTheme } from "./ThemeProvider";
|
|
9
10
|
export type { ThemeContextValue, ThemeMode } from "./ThemeProvider";
|
|
11
|
+
export {
|
|
12
|
+
defineTheme,
|
|
13
|
+
validateTheme,
|
|
14
|
+
resolveMode,
|
|
15
|
+
supportedModes,
|
|
16
|
+
deriveColorScheme,
|
|
17
|
+
declaredModes,
|
|
18
|
+
} from "./define-theme";
|
|
19
|
+
export type {
|
|
20
|
+
ColorSchemeCapability,
|
|
21
|
+
ResolvedCapabilities,
|
|
22
|
+
ResolvedNativeTheme,
|
|
23
|
+
ThemeModeViolation,
|
|
24
|
+
ThemeOptions,
|
|
25
|
+
} from "./define-theme";
|
|
10
26
|
|
|
11
27
|
/**
|
|
12
28
|
* letterSpacingRatio(em 相当の比率)を、適用先の fontSize から RN の letterSpacing(pt) に解決する。
|
package/src/theme/types.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ViewStyle } from "react-native";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* 表示モード。ThemeProvider が OS の colorScheme / forcedMode / theme の能力から解決する。
|
|
13
|
+
* (define-theme.ts と ThemeProvider.tsx の相互 import を避けるため型の実体はここに置く)
|
|
14
|
+
*/
|
|
15
|
+
export type ThemeMode = "light" | "dark";
|
|
16
|
+
|
|
11
17
|
/** primary パレット(50〜950)。値は hex 文字列。 */
|
|
12
18
|
export type PrimaryScale =
|
|
13
19
|
| "50"
|