melta-app 0.5.0 → 0.5.2
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/README.md +61 -7
- package/docs/patterns.md +2 -0
- package/eslint-rules/melta.mjs +98 -0
- package/lib/module/components/Card.js +26 -1
- package/lib/module/components/Card.js.map +1 -1
- package/lib/module/components/card.validate.js +67 -0
- package/lib/module/components/card.validate.js.map +1 -0
- package/lib/typescript/src/components/Card.d.ts +1 -1
- package/lib/typescript/src/components/Card.d.ts.map +1 -1
- package/lib/typescript/src/components/card.validate.d.ts +25 -0
- package/lib/typescript/src/components/card.validate.d.ts.map +1 -0
- package/llms.txt +1 -1
- package/package.json +5 -2
- package/src/components/Card.tsx +21 -1
- package/src/components/card.validate.ts +91 -0
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
- **Showcase / Live Catalog**: https://app.melta.tsubotax.com (Live Catalog は実 RN コンポーネントの web export。HTML 再現デモではない)
|
|
8
8
|
- **Web 版 showcase**: https://melta.tsubotax.com
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**現在の adopter は 1 つ**(非公開プロジェクトのモバイルアプリ)。dogfood はそこで回している。
|
|
11
|
+
D2I は当初想定していた最初の adopter だが、mobile 側はまだ melta-app を導入していない
|
|
12
|
+
(D2I の web 側が使っているのは melta-ui)。
|
|
11
13
|
|
|
12
14
|
## Install
|
|
13
15
|
|
|
@@ -34,7 +36,7 @@ export default function App() {
|
|
|
34
36
|
}
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
テーマは `ThemeProvider` が OS の light / dark に自動追従(`forcedMode` で固定も可)。トークンは `useTheme()` / `nativeTheme`
|
|
39
|
+
テーマは `ThemeProvider` が OS の light / dark に自動追従(`forcedMode` で固定も可)。トークンは `useTheme()` / `nativeTheme` から取れる。本体エントリ以外は subpath 3 つ([Icon](#subpath-エントリ-iconmelta-appicons) / [SafeArea 差替](#safearea-の差し替えmelta-appsafe-area) / [lint plugin](#lint-pluginmelta-appeslint-plugin))。
|
|
38
40
|
|
|
39
41
|
### ブランドテーマを注入する
|
|
40
42
|
|
|
@@ -74,10 +76,33 @@ const theme = defineTheme({
|
|
|
74
76
|
|
|
75
77
|
> 現状 `color.primary` / `text-accent` / `elevation` / status の light 値は、単一 dark のテーマでも**必須のまま**。省略できるのは `color.semantic` の mode だけで、他の軸を「持たない」と宣言する仕組みは後続で入れる。
|
|
76
78
|
|
|
79
|
+
### 押せるカード(`variant="action"` / `"link"`)
|
|
80
|
+
|
|
81
|
+
**カード面そのものは操作要素ではない。** 面を押せるのはポインタ利用者向けの近道で、
|
|
82
|
+
キーボードとスクリーンリーダーからの到達手段は**カード内の操作要素**が担う。
|
|
83
|
+
そのため `primaryAction` が必須になっている。
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
<Card
|
|
87
|
+
variant="action"
|
|
88
|
+
onPress={openLog}
|
|
89
|
+
primaryAction={<Button label="製作ログを見る" onPress={openLog} />}
|
|
90
|
+
>
|
|
91
|
+
<Text variant="lg" role="heading">工房 #12</Text>
|
|
92
|
+
</Card>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `primaryAction` は面の `onPress` と**同じ操作**を指す(別の遷移先にしない)
|
|
96
|
+
- ラベルは `primaryAction` 側に付ける。カードは名前を持たない(`role="article"` の領域)
|
|
97
|
+
- カード内に操作要素が複数あってよい。`primaryAction` に置くのは**主アクション**(面を押したときの遷移先と一致するもの)
|
|
98
|
+
- 面に `accessibilityRole="button"` は付かない。`Button` を内包しても DOM が壊れない
|
|
99
|
+
|
|
100
|
+
0.4.x からの移行は [CHANGELOG](./CHANGELOG.md#050--2026-07-29) を参照。
|
|
101
|
+
|
|
77
102
|
## 設計の核
|
|
78
103
|
|
|
79
104
|
- **契約は共有、実装は各最適**: tokens / 禁止ルール / component 契約は `melta-contracts`(JSON)が SSOT。melta-app に token は持たない(二重化を物理防止)。
|
|
80
|
-
- **公開 DS の純度を守る**: 汎用 UI
|
|
105
|
+
- **公開 DS の純度を守る**: 汎用 UI プリミティブだけを置く。アプリ固有の UI(特定サービスの画面・機能・語彙)は**各アプリ側**に置き、ここには混ぜない。
|
|
81
106
|
- **依存最小**: 初期は RN `StyleSheet` 固定(nativewind / unistyles 等の runtime styling lib を入れない)。Storybook RN も使わず自前カタログ。
|
|
82
107
|
|
|
83
108
|
## AI-Ready
|
|
@@ -196,9 +221,10 @@ npm run ios # または android / web
|
|
|
196
221
|
| tooltip | — | 🚫 not-planned | — | hover 前提のため。iPhone HIG に tooltip 概念なし。必要になれば長押し Hint として別契約を切る |
|
|
197
222
|
<!-- END GENERATED: component-status -->
|
|
198
223
|
|
|
199
|
-
###
|
|
224
|
+
### subpath エントリ: Icon(`melta-app/icons`)
|
|
200
225
|
|
|
201
|
-
Icon
|
|
226
|
+
subpath は Icon / SafeArea 差替(`melta-app/safe-area`)/ lint plugin(`melta-app/eslint-plugin`)の 3 つ。
|
|
227
|
+
このうち Icon は唯一 `react-native-svg`(optional peerDependency)に依存するため、本体エントリから分離している。
|
|
202
228
|
本体 `melta-app` は依存ゼロのまま — Icon を使うアプリだけが以下を行う:
|
|
203
229
|
|
|
204
230
|
```bash
|
|
@@ -247,18 +273,46 @@ enableSafeAreaContext({ edges: ["top"] });
|
|
|
247
273
|
- safe-area と合成する padding は**数値のみ**サポート(`"5%"` 等の非数値は基底として扱えず、
|
|
248
274
|
対象 edge は inset 値に置き換わる)。RTL / `paddingStart`・`paddingEnd` は対応済み
|
|
249
275
|
|
|
276
|
+
### lint plugin(`melta-app/eslint-plugin`)
|
|
277
|
+
|
|
278
|
+
melta 契約からの逸脱を機械検知する eslint カスタムルール 4 本を同梱している。
|
|
279
|
+
消費者プロジェクトの flat config に組み込むと、生値の直書きが lint で止まる:
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
// eslint.config.mjs
|
|
283
|
+
import { meltaPlugin } from "melta-app/eslint-plugin";
|
|
284
|
+
|
|
285
|
+
export default [
|
|
286
|
+
{
|
|
287
|
+
plugins: { melta: meltaPlugin },
|
|
288
|
+
rules: {
|
|
289
|
+
"melta/no-raw-color": "error", // 生 hex/rgb/hsl → theme.color.*
|
|
290
|
+
"melta/no-raw-radius": "error", // borderRadius 数値直書き → theme.radius.*
|
|
291
|
+
"melta/no-raw-spacing": "warn", // padding/margin/gap 数値直書き(false positive あり)
|
|
292
|
+
"melta/no-raw-fontsize": "warn", // fontSize 数値直書き
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- 依存ゼロの自己完結 ESM(eslint 本体以外に何も要らない)。ESLint 9+ の flat config を想定(このリポでは 10.x で検証)
|
|
299
|
+
- `meltaPlugin` は **named export のみ**(default export なし)
|
|
300
|
+
- 検知は AST の構文形状ベースの補助線。変数経由・spread は漏れるので、値の純度の本丸は
|
|
301
|
+
token 経由(`theme.*`)で書く習慣の側にある
|
|
302
|
+
|
|
250
303
|
## ステータス
|
|
251
304
|
|
|
252
305
|
- ✅ ライブラリ化(root=ライブラリ / example=カタログアプリ、peerDeps react + react-native、runtime deps ゼロ)
|
|
253
306
|
- ✅ `melta-contracts` を npm 依存として購読(recipes/app の styleRefs 同梱)
|
|
254
307
|
- ✅ conformance: 契約源 ↔ 生成メタ ↔ `__contract` 宣言の照合 + consumer テスト(契約 subset / token 実在 / contractVersion 同期)+ styleRefs conformance(全実装コンポーネント展開済み)+ RN mount smoke(light/dark × 全公開コンポーネント)
|
|
255
|
-
- ✅ ハーネス: design lint(CI `--max-warnings 0` + PostToolUse hook)/ drift 検査(README・catalog・allowlist 突合)/ installability ゲート(pack → install → import →
|
|
308
|
+
- ✅ ハーネス: design lint(CI `--max-warnings 0` + PostToolUse hook)/ drift 検査(README・catalog・allowlist 突合)/ installability ゲート(pack → tarball 実体検査 → fixture へ install → 本体 / icons / safe-area の import + typecheck → eslint plugin を実 import してルール 4 本の実在を照合 → exports の解決先確認。`npm run release` チェーンの必須ステップ)
|
|
256
309
|
- ✅ layout 6 個(Stack / Row / Screen / Header / Icon / Avatar)— dogfood 不足 1〜4 を解消、ProjectFeedScreen は公開 primitive だけで構成
|
|
257
310
|
- Screen の SafeArea は adapter registry 化済み: default は RN core SafeAreaView(依存ゼロ維持)、`melta-app/safe-area` の `enableSafeAreaContext()` で react-native-safe-area-context に差し替え可(optional peer)
|
|
258
311
|
- ✅ form / feedback 10 個(TextField / Toggle / Checkbox / Radio / Alert / Toast / Progress / Modal / ActionSheet / BottomSheet)— checkbox / radio は Pressable + 描画(svg 非依存)、ActionSheet / BottomSheet は select / dropdown の adapted 変換先の受け皿
|
|
259
312
|
- ✅ showcase(https://app.melta.tsubotax.com — melta-ui 様式シェル + 実 RN カタログの Live 埋め込み。表・統計は契約からビルド時生成)
|
|
260
313
|
- ✅ AI 入口: [llms.txt](https://app.melta.tsubotax.com/llms.txt)(契約から生成・drift 検査対象)+ [docs/patterns.md](docs/patterns.md)(フォームの組み方規範。スニペットは実コードと機械同期)
|
|
261
|
-
- ✅
|
|
314
|
+
- ✅ lint 強制層の npm 配布(0.5.2): `melta-app/eslint-plugin` を公開 subpath 化。消費者プロジェクトの flat config に組めば、生値の直書きが消費者側でも lint で止まる
|
|
315
|
+
- ✅ [npm publish(0.5.2)](https://www.npmjs.com/package/melta-app)
|
|
262
316
|
- ⬜ React Native Directory 登録([PR #2606](https://github.com/react-native-community/directory/pull/2606) レビュー待ち)
|
|
263
317
|
|
|
264
318
|
詳細は D2I リポの `.team/specs/requirements-melta-app.md`。
|
package/docs/patterns.md
CHANGED
|
@@ -261,3 +261,5 @@ form ライブラリを DS として強制しない。素の React で必要十
|
|
|
261
261
|
| disabled-until-valid の常用 | 「なぜ押せないか」を伝えられない。常時活性 + submit 時全量検証 + サマリー Alert |
|
|
262
262
|
| placeholder をラベル代わりにする | 入力を始めた瞬間に文脈が消える。placeholder は例示専用 |
|
|
263
263
|
| 生値スタイル(hex / 数値 spacing 直書き) | lint(--max-warnings 0)が落とす。`theme.color.*` / `theme.spacing[*]` 経由のみ |
|
|
264
|
+
|
|
265
|
+
※ この lint はこのリポジトリ専用ではない。消費者プロジェクトでも `melta-app/eslint-plugin` を flat config に組めば(`plugins: { melta: meltaPlugin }` + `melta/no-raw-color` 等)、同じルールが同じように効く。設定は README の「lint plugin」節を参照。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* melta design lint ルール(設計書 §5、D-1)。
|
|
3
|
+
*
|
|
4
|
+
* RN で機械検知できる raw 値の 4 類型のみを対象とする補助線(AST 検知は変数経由/spread で漏れるので
|
|
5
|
+
* 純度の本丸は A-3 conformance)。token 経由(theme.* の MemberExpression)は許可、生値を弾く。
|
|
6
|
+
*
|
|
7
|
+
* - no-raw-color : hex/rgb/hsl の文字列リテラル → error
|
|
8
|
+
* - no-raw-radius : borderRadius 系プロパティに数値リテラル直書き → error
|
|
9
|
+
* - no-raw-spacing : padding/margin/gap 系に数値リテラル直書き → warn(false positive 多)
|
|
10
|
+
* - no-raw-fontsize: fontSize に数値リテラル直書き → warn
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(/;
|
|
14
|
+
// RN の特殊カラーキーワードは token 化対象外として許可する(subtle variant の背景透明など)。
|
|
15
|
+
const SAFE_COLORS = new Set(["transparent", "currentColor", "inherit", "none"]);
|
|
16
|
+
|
|
17
|
+
/** Property の key 名を取る(Identifier / 文字列 Literal の両対応)。 */
|
|
18
|
+
function keyName(node) {
|
|
19
|
+
if (!node.key) return null;
|
|
20
|
+
if (node.key.type === "Identifier") return node.key.name;
|
|
21
|
+
if (node.key.type === "Literal" && typeof node.key.value === "string") return node.key.value;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** value が数値リテラル直書きか(負数 UnaryExpression も拾う)。token 由来の MemberExpression は false。 */
|
|
26
|
+
function isRawNumber(value) {
|
|
27
|
+
if (!value) return false;
|
|
28
|
+
if (value.type === "Literal" && typeof value.value === "number") return true;
|
|
29
|
+
if (value.type === "UnaryExpression" && value.operator === "-") return isRawNumber(value.argument);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isRadiusKey = (k) => typeof k === "string" && /^border([A-Za-z]*)Radius$/.test(k);
|
|
34
|
+
const isSpacingKey = (k) =>
|
|
35
|
+
typeof k === "string" &&
|
|
36
|
+
(/^(padding|margin)([A-Za-z]*)?$/.test(k) || k === "gap" || k === "rowGap" || k === "columnGap");
|
|
37
|
+
|
|
38
|
+
export const meltaPlugin = {
|
|
39
|
+
rules: {
|
|
40
|
+
"no-raw-color": {
|
|
41
|
+
meta: { type: "problem", docs: { description: "色は theme.color.* を使う(生 hex/rgb/hsl 禁止)" } },
|
|
42
|
+
create(context) {
|
|
43
|
+
const check = (node, text) => {
|
|
44
|
+
if (typeof text === "string" && !SAFE_COLORS.has(text) && COLOR_RE.test(text)) {
|
|
45
|
+
context.report({ node, message: `生の色 "${text}" は禁止。theme.color.* を使う。` });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
Literal(node) {
|
|
50
|
+
check(node, node.value);
|
|
51
|
+
},
|
|
52
|
+
// テンプレートリテラル内の生色も拾う(`#fff` や `rgb(...)` を quasi に直書きするケース)。
|
|
53
|
+
TemplateLiteral(node) {
|
|
54
|
+
for (const quasi of node.quasis) {
|
|
55
|
+
check(quasi, quasi.value.cooked);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
"no-raw-radius": {
|
|
62
|
+
meta: { type: "problem", docs: { description: "borderRadius は theme.radius.* を使う" } },
|
|
63
|
+
create(context) {
|
|
64
|
+
return {
|
|
65
|
+
Property(node) {
|
|
66
|
+
if (isRadiusKey(keyName(node)) && isRawNumber(node.value)) {
|
|
67
|
+
context.report({ node, message: "borderRadius の生数値は禁止。theme.radius.* を使う。" });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
"no-raw-spacing": {
|
|
74
|
+
meta: { type: "suggestion", docs: { description: "padding/margin/gap は theme.spacing.* を使う" } },
|
|
75
|
+
create(context) {
|
|
76
|
+
return {
|
|
77
|
+
Property(node) {
|
|
78
|
+
if (isSpacingKey(keyName(node)) && isRawNumber(node.value)) {
|
|
79
|
+
context.report({ node, message: "spacing の生数値は theme.spacing.* 推奨。" });
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
"no-raw-fontsize": {
|
|
86
|
+
meta: { type: "suggestion", docs: { description: "fontSize は theme.typography.fontSize.* を使う" } },
|
|
87
|
+
create(context) {
|
|
88
|
+
return {
|
|
89
|
+
Property(node) {
|
|
90
|
+
if (keyName(node) === "fontSize" && isRawNumber(node.value)) {
|
|
91
|
+
context.report({ node, message: "fontSize の生数値は theme.typography.fontSize.* 推奨。" });
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -33,14 +33,27 @@
|
|
|
33
33
|
* - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { useEffect } from "react";
|
|
36
37
|
import { Pressable, View } from "react-native";
|
|
37
38
|
import { useTheme } from "../theme/index.js";
|
|
38
39
|
import { CONTRACTS } from "../contracts/contract-types.js";
|
|
40
|
+
import { isDev } from "../theme/define-theme.js";
|
|
39
41
|
import { CARD_INTERACTIVE, resolveCardShape, resolveCardBodyStyle } from "./card.styles.js";
|
|
42
|
+
import { validateCardProps } from "./card.validate.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 同じ問題を毎レンダー報告しないためのラッチ。
|
|
46
|
+
*
|
|
47
|
+
* **報告は effect の中で行う**(render 中ではない)。render 中だと、React が破棄する
|
|
48
|
+
* speculative render や StrictMode の二重 render でもラッチが消費され、実際に commit された
|
|
49
|
+
* 違反が報告されなくなりうる。検査の中身は card.validate.ts の純関数側でテストする。
|
|
50
|
+
*/
|
|
51
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
52
|
+
const reported = new Set();
|
|
40
53
|
|
|
41
54
|
// action/link(インタラクティブ)は onPress と primaryAction が必須、
|
|
42
55
|
// basic/media はどちらも取らない(§4 DU 精神)。
|
|
43
|
-
|
|
56
|
+
|
|
44
57
|
export function Card({
|
|
45
58
|
variant = "basic",
|
|
46
59
|
header,
|
|
@@ -57,6 +70,18 @@ export function Card({
|
|
|
57
70
|
mode
|
|
58
71
|
} = useTheme();
|
|
59
72
|
const interactive = CARD_INTERACTIVE[variant] && onPress != null;
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isDev) return;
|
|
75
|
+
for (const problem of validateCardProps({
|
|
76
|
+
variant,
|
|
77
|
+
onPress,
|
|
78
|
+
primaryAction
|
|
79
|
+
})) {
|
|
80
|
+
if (reported.has(problem)) continue;
|
|
81
|
+
reported.add(problem);
|
|
82
|
+
console.error(`melta: ${problem}`);
|
|
83
|
+
}
|
|
84
|
+
}, [variant, onPress, primaryAction]);
|
|
60
85
|
const isMedia = variant === "media";
|
|
61
86
|
const inner = /*#__PURE__*/_jsxs(_Fragment, {
|
|
62
87
|
children: [media != null && /*#__PURE__*/_jsx(View, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["Pressable","View","useTheme","CONTRACTS","CARD_INTERACTIVE","resolveCardShape","resolveCardBodyStyle","jsx","_jsx","jsxs","_jsxs","Fragment","_Fragment","Card","variant","header","footer","media","onPress","primaryAction","style","testID","children","theme","mode","interactive","isMedia","inner","content","borderRadius","radius","lg","overflow","role","accessible","pressed","__contract","card"],"sourceRoot":"../../../src","sources":["components/Card.tsx"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;
|
|
1
|
+
{"version":3,"names":["useEffect","Pressable","View","useTheme","CONTRACTS","isDev","CARD_INTERACTIVE","resolveCardShape","resolveCardBodyStyle","validateCardProps","jsx","_jsx","jsxs","_jsxs","Fragment","_Fragment","reported","Set","Card","variant","header","footer","media","onPress","primaryAction","style","testID","children","theme","mode","interactive","problem","has","add","console","error","isMedia","inner","content","borderRadius","radius","lg","overflow","role","accessible","pressed","__contract","card"],"sourceRoot":"../../../src","sources":["components/Card.tsx"],"mappings":"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","ignoreList":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
|
|
5
|
+
*
|
|
6
|
+
* **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
|
|
7
|
+
* JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
|
|
8
|
+
*
|
|
9
|
+
* react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
|
|
10
|
+
* 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
|
|
11
|
+
* (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isValidElement } from "react";
|
|
15
|
+
import { CARD_INTERACTIVE } from "./card.styles.js";
|
|
16
|
+
/**
|
|
17
|
+
* 問題の一覧を返す(空配列 = 問題なし)。
|
|
18
|
+
*
|
|
19
|
+
* 検出できるのは「欠落」と「明らかに型が違う」ところまで。
|
|
20
|
+
* `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
|
|
21
|
+
* 判定できない(そこは契約の文と README の説明が担う)。
|
|
22
|
+
*/
|
|
23
|
+
export function validateCardProps({
|
|
24
|
+
variant,
|
|
25
|
+
onPress,
|
|
26
|
+
primaryAction
|
|
27
|
+
}) {
|
|
28
|
+
// 未知の variant は CARD_INTERACTIVE で undefined になり、以降の分岐が
|
|
29
|
+
// 「非インタラクティブ」に倒れて静かに通ってしまう。型を持たない利用者の typo
|
|
30
|
+
//(variant="acton" 等)が押せないカードとして出荷されるので、最初に弾く。
|
|
31
|
+
//
|
|
32
|
+
// ⚠️ 文字列型の確認を hasOwnProperty より**先**に置くこと。プロパティキーは
|
|
33
|
+
// ToPropertyKey で文字列化されるため、
|
|
34
|
+
// - `new String("action")` や `{ toString: () => "action" }` が正規 variant として通る
|
|
35
|
+
// - `Object.create(null)` は文字列化できず **TypeError で検査自体が落ちる**
|
|
36
|
+
// (dev の防御が防御にならない)
|
|
37
|
+
// いずれも実測で再現済み(Codex レビュー3周目)。
|
|
38
|
+
if (typeof variant !== "string") {
|
|
39
|
+
return [`Card の variant は文字列で渡す(受け取ったのは ${variant === null ? "null" : typeof variant})。`];
|
|
40
|
+
}
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(CARD_INTERACTIVE, variant)) {
|
|
42
|
+
return [`Card の variant="${String(variant)}" は存在しない(` + `${Object.keys(CARD_INTERACTIVE).join(" / ")} のいずれか)。`];
|
|
43
|
+
}
|
|
44
|
+
if (!CARD_INTERACTIVE[variant]) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
if (onPress != null) {
|
|
47
|
+
problems.push(`Card variant="${variant}" は onPress を取らない(押せるのは action / link)。`);
|
|
48
|
+
}
|
|
49
|
+
if (primaryAction != null) {
|
|
50
|
+
problems.push(`Card variant="${variant}" は primaryAction を取らない(押せるのは action / link)。`);
|
|
51
|
+
}
|
|
52
|
+
return problems;
|
|
53
|
+
}
|
|
54
|
+
const problems = [];
|
|
55
|
+
if (onPress == null) {
|
|
56
|
+
problems.push(`Card variant="${variant}" は onPress が要る。渡さないと押せない見た目のまま非対話になる。`);
|
|
57
|
+
} else if (typeof onPress !== "function") {
|
|
58
|
+
problems.push(`Card variant="${variant}" の onPress が関数でない(${typeof onPress})。`);
|
|
59
|
+
}
|
|
60
|
+
if (primaryAction == null) {
|
|
61
|
+
problems.push(`Card variant="${variant}" は primaryAction が要る(contract 2.1.0)。` + "カード面は操作要素ではないので、これが無いとキーボード / スクリーンリーダーから" + "カードのアクションに到達できない。面の onPress と同じ操作を指す Button 等を渡すこと。");
|
|
62
|
+
} else if (! /*#__PURE__*/isValidElement(primaryAction)) {
|
|
63
|
+
problems.push(`Card variant="${variant}" の primaryAction が React 要素でない(${typeof primaryAction})。` + "文字列や真偽値では操作要素にならない。");
|
|
64
|
+
}
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=card.validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["isValidElement","CARD_INTERACTIVE","validateCardProps","variant","onPress","primaryAction","Object","prototype","hasOwnProperty","call","String","keys","join","problems","push"],"sourceRoot":"../../../src","sources":["components/card.validate.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA,SAASA,cAAc,QAAQ,OAAO;AAEtC,SAASC,gBAAgB,QAAQ,kBAAe;AAQhD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,iBAAiBA,CAAC;EAChCC,OAAO;EACPC,OAAO;EACPC;AACsB,CAAC,EAAY;EACnC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,IAAI,OAAOF,OAAO,KAAK,QAAQ,EAAE;IAC/B,OAAO,CACL,kCAAkCA,OAAO,KAAK,IAAI,GAAG,MAAM,GAAG,OAAOA,OAAO,IAAI,CACjF;EACH;EACA,IAAI,CAACG,MAAM,CAACC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACR,gBAAgB,EAAEE,OAAO,CAAC,EAAE;IACpE,OAAO,CACL,mBAAmBO,MAAM,CAACP,OAAO,CAAC,WAAW,GAC3C,GAAGG,MAAM,CAACK,IAAI,CAACV,gBAAgB,CAAC,CAACW,IAAI,CAAC,KAAK,CAAC,UAAU,CACzD;EACH;EAEA,IAAI,CAACX,gBAAgB,CAACE,OAAO,CAAC,EAAE;IAC9B,MAAMU,QAAkB,GAAG,EAAE;IAC7B,IAAIT,OAAO,IAAI,IAAI,EAAE;MACnBS,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,yCAAyC,CAAC;IAClF;IACA,IAAIE,aAAa,IAAI,IAAI,EAAE;MACzBQ,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,+CAA+C,CAAC;IACxF;IACA,OAAOU,QAAQ;EACjB;EAEA,MAAMA,QAAkB,GAAG,EAAE;EAE7B,IAAIT,OAAO,IAAI,IAAI,EAAE;IACnBS,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,wCAC1B,CAAC;EACH,CAAC,MAAM,IAAI,OAAOC,OAAO,KAAK,UAAU,EAAE;IACxCS,QAAQ,CAACC,IAAI,CAAC,iBAAiBX,OAAO,sBAAsB,OAAOC,OAAO,IAAI,CAAC;EACjF;EAEA,IAAIC,aAAa,IAAI,IAAI,EAAE;IACzBQ,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,wCAAwC,GAC9D,2CAA2C,GAC3C,qDACJ,CAAC;EACH,CAAC,MAAM,IAAI,eAACH,cAAc,CAACK,aAAa,CAAC,EAAE;IACzCQ,QAAQ,CAACC,IAAI,CACX,iBAAiBX,OAAO,mCAAmC,OAAOE,aAAa,IAAI,GACjF,qBACJ,CAAC;EACH;EAEA,OAAOQ,QAAQ;AACjB","ignoreList":[]}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* 呼び出し側(例: D2I)は用途に応じて slot に Image+Metric を compose する。
|
|
31
31
|
* - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
|
|
32
32
|
*/
|
|
33
|
-
import type
|
|
33
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
34
34
|
import { type StyleProp, type ViewStyle } from "react-native";
|
|
35
35
|
interface CardBase {
|
|
36
36
|
header?: ReactNode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../../../src/components/Card.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"Card.d.ts","sourceRoot":"","sources":["../../../../src/components/Card.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAa,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACrE,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAgB/E,UAAU,QAAQ;IAChB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY;IACZ,QAAQ,EAAE,SAAS,CAAC;CACrB;AAID,KAAK,SAAS,GAAG,QAAQ,GACvB,CACI;IAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GACvE;IACE,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;;OAIG;IACH,aAAa,EAAE,YAAY,CAAC;CAC7B,CACJ,CAAC;AAEJ,wBAAgB,IAAI,CAAC,EACnB,OAAiB,EACjB,MAAM,EACN,MAAM,EACN,KAAK,EACL,OAAO,EACP,aAAa,EACb,KAAK,EACL,MAAM,EACN,QAAQ,GACT,EAAE,SAAS,2CAyDX;yBAnEe,IAAI"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
|
|
3
|
+
*
|
|
4
|
+
* **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
|
|
5
|
+
* JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
|
|
6
|
+
*
|
|
7
|
+
* react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
|
|
8
|
+
* 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
|
|
9
|
+
* (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
|
|
10
|
+
*/
|
|
11
|
+
import type { CardVariant } from "./card.styles";
|
|
12
|
+
export interface CardPropsForValidation {
|
|
13
|
+
variant: CardVariant;
|
|
14
|
+
onPress?: unknown;
|
|
15
|
+
primaryAction?: unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 問題の一覧を返す(空配列 = 問題なし)。
|
|
19
|
+
*
|
|
20
|
+
* 検出できるのは「欠落」と「明らかに型が違う」ところまで。
|
|
21
|
+
* `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
|
|
22
|
+
* 判定できない(そこは契約の文と README の説明が担う)。
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateCardProps({ variant, onPress, primaryAction, }: CardPropsForValidation): string[];
|
|
25
|
+
//# sourceMappingURL=card.validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"card.validate.d.ts","sourceRoot":"","sources":["../../../../src/components/card.validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,OAAO,EACP,aAAa,GACd,EAAE,sBAAsB,GAAG,MAAM,EAAE,CA0DnC"}
|
package/llms.txt
CHANGED
|
@@ -58,5 +58,5 @@ UI を組む前に docs/patterns.md(組み方の規範)を読むこと。exa
|
|
|
58
58
|
## Optional
|
|
59
59
|
|
|
60
60
|
- [melta-ui llms.txt](https://melta.tsubotax.com/llms.txt): web 版デザインシステムの AI 入口(デザイン言語の一次資料)
|
|
61
|
-
- [npm](https://www.npmjs.com/package/melta-app):
|
|
61
|
+
- [npm](https://www.npmjs.com/package/melta-app): パッケージ(サブパスは Icon = melta-app/icons、SafeArea 差替 = melta-app/safe-area、lint 強制層 = melta-app/eslint-plugin の 3 つ)
|
|
62
62
|
- [THIRD_PARTY_LICENSES.md](https://github.com/tsubotax/melta-app/blob/main/THIRD_PARTY_LICENSES.md): Icon グリフの帰属表示
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "melta-app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "melta for APP — melta デザインシステムの React Native UI kit。melta-contracts(デザイン契約)を single source of truth に RN 実装でその契約を満たす。アプリ本体ではなくライブラリ。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://app.melta.tsubotax.com",
|
|
@@ -37,12 +37,15 @@
|
|
|
37
37
|
"import": "./lib/module/safe-area/index.js",
|
|
38
38
|
"default": "./lib/module/safe-area/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./eslint-plugin": "./eslint-rules/melta.mjs",
|
|
41
|
+
"./eslint-rules/melta.mjs": "./eslint-rules/melta.mjs",
|
|
40
42
|
"./package.json": "./package.json"
|
|
41
43
|
},
|
|
42
44
|
"files": [
|
|
43
45
|
"lib",
|
|
44
46
|
"src",
|
|
45
47
|
"!src/**/*.test.*",
|
|
48
|
+
"eslint-rules",
|
|
46
49
|
"llms.txt",
|
|
47
50
|
"docs",
|
|
48
51
|
"THIRD_PARTY_LICENSES.md"
|
|
@@ -104,7 +107,7 @@
|
|
|
104
107
|
"check:drift": "tsx scripts/check-drift.ts",
|
|
105
108
|
"check:installability": "bash scripts/check-installability.sh",
|
|
106
109
|
"check:build-fresh": "tsx scripts/check-build-fresh.ts",
|
|
107
|
-
"release": "npm run build && npm run typecheck && npm run lint && npm test && npm run test:rn && npm run check:drift && npm run check:build-fresh && npm publish",
|
|
110
|
+
"release": "npm run build && npm run typecheck && npm run lint && npm test && npm run test:rn && npm run check:drift && npm run check:build-fresh && npm run check:installability && npm publish",
|
|
108
111
|
"build:llms": "tsx scripts/build-llms-txt.ts"
|
|
109
112
|
},
|
|
110
113
|
"jest": {
|
package/src/components/Card.tsx
CHANGED
|
@@ -31,11 +31,22 @@
|
|
|
31
31
|
* - media variant は overflow:hidden で角丸に画像をクリップし、body だけにパディング(contract と整合)。
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import type
|
|
34
|
+
import { useEffect, type ReactElement, type ReactNode } from "react";
|
|
35
35
|
import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
|
|
36
36
|
import { useTheme } from "../theme";
|
|
37
37
|
import { CONTRACTS } from "../contracts/contract-types";
|
|
38
|
+
import { isDev } from "../theme/define-theme";
|
|
38
39
|
import { CARD_INTERACTIVE, resolveCardShape, resolveCardBodyStyle } from "./card.styles";
|
|
40
|
+
import { validateCardProps } from "./card.validate";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 同じ問題を毎レンダー報告しないためのラッチ。
|
|
44
|
+
*
|
|
45
|
+
* **報告は effect の中で行う**(render 中ではない)。render 中だと、React が破棄する
|
|
46
|
+
* speculative render や StrictMode の二重 render でもラッチが消費され、実際に commit された
|
|
47
|
+
* 違反が報告されなくなりうる。検査の中身は card.validate.ts の純関数側でテストする。
|
|
48
|
+
*/
|
|
49
|
+
const reported = new Set<string>();
|
|
39
50
|
|
|
40
51
|
interface CardBase {
|
|
41
52
|
header?: ReactNode;
|
|
@@ -77,6 +88,15 @@ export function Card({
|
|
|
77
88
|
}: CardProps) {
|
|
78
89
|
const { theme, mode } = useTheme();
|
|
79
90
|
const interactive = CARD_INTERACTIVE[variant] && onPress != null;
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!isDev) return;
|
|
94
|
+
for (const problem of validateCardProps({ variant, onPress, primaryAction })) {
|
|
95
|
+
if (reported.has(problem)) continue;
|
|
96
|
+
reported.add(problem);
|
|
97
|
+
console.error(`melta: ${problem}`);
|
|
98
|
+
}
|
|
99
|
+
}, [variant, onPress, primaryAction]);
|
|
80
100
|
const isMedia = variant === "media";
|
|
81
101
|
|
|
82
102
|
const inner = (
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* card.validate — Card の props が contract 2.1.0 を満たしているかの検査(純関数)。
|
|
3
|
+
*
|
|
4
|
+
* **なぜ実行時に検査するか**: props の型は TypeScript を使う利用者にしか効かない。
|
|
5
|
+
* JavaScript から使われたとき、contract の要求が静かに破られるのを防ぐ。
|
|
6
|
+
*
|
|
7
|
+
* react-native を import しない純粋モジュールにして、node テスト(tsx --test)から
|
|
8
|
+
* 直接網羅できるようにしている。Card 側は結果を dev で1回だけ報告するだけ
|
|
9
|
+
* (警告のラッチに依存せずに検査内容そのものをテストできる形にするため)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isValidElement } from "react";
|
|
13
|
+
import type { CardVariant } from "./card.styles";
|
|
14
|
+
import { CARD_INTERACTIVE } from "./card.styles";
|
|
15
|
+
|
|
16
|
+
export interface CardPropsForValidation {
|
|
17
|
+
variant: CardVariant;
|
|
18
|
+
onPress?: unknown;
|
|
19
|
+
primaryAction?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 問題の一覧を返す(空配列 = 問題なし)。
|
|
24
|
+
*
|
|
25
|
+
* 検出できるのは「欠落」と「明らかに型が違う」ところまで。
|
|
26
|
+
* `primaryAction` が**本当に操作可能か**、面と**同じ遷移先か**は任意の ReactElement からは
|
|
27
|
+
* 判定できない(そこは契約の文と README の説明が担う)。
|
|
28
|
+
*/
|
|
29
|
+
export function validateCardProps({
|
|
30
|
+
variant,
|
|
31
|
+
onPress,
|
|
32
|
+
primaryAction,
|
|
33
|
+
}: CardPropsForValidation): string[] {
|
|
34
|
+
// 未知の variant は CARD_INTERACTIVE で undefined になり、以降の分岐が
|
|
35
|
+
// 「非インタラクティブ」に倒れて静かに通ってしまう。型を持たない利用者の typo
|
|
36
|
+
//(variant="acton" 等)が押せないカードとして出荷されるので、最初に弾く。
|
|
37
|
+
//
|
|
38
|
+
// ⚠️ 文字列型の確認を hasOwnProperty より**先**に置くこと。プロパティキーは
|
|
39
|
+
// ToPropertyKey で文字列化されるため、
|
|
40
|
+
// - `new String("action")` や `{ toString: () => "action" }` が正規 variant として通る
|
|
41
|
+
// - `Object.create(null)` は文字列化できず **TypeError で検査自体が落ちる**
|
|
42
|
+
// (dev の防御が防御にならない)
|
|
43
|
+
// いずれも実測で再現済み(Codex レビュー3周目)。
|
|
44
|
+
if (typeof variant !== "string") {
|
|
45
|
+
return [
|
|
46
|
+
`Card の variant は文字列で渡す(受け取ったのは ${variant === null ? "null" : typeof variant})。`,
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
if (!Object.prototype.hasOwnProperty.call(CARD_INTERACTIVE, variant)) {
|
|
50
|
+
return [
|
|
51
|
+
`Card の variant="${String(variant)}" は存在しない(` +
|
|
52
|
+
`${Object.keys(CARD_INTERACTIVE).join(" / ")} のいずれか)。`,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!CARD_INTERACTIVE[variant]) {
|
|
57
|
+
const problems: string[] = [];
|
|
58
|
+
if (onPress != null) {
|
|
59
|
+
problems.push(`Card variant="${variant}" は onPress を取らない(押せるのは action / link)。`);
|
|
60
|
+
}
|
|
61
|
+
if (primaryAction != null) {
|
|
62
|
+
problems.push(`Card variant="${variant}" は primaryAction を取らない(押せるのは action / link)。`);
|
|
63
|
+
}
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const problems: string[] = [];
|
|
68
|
+
|
|
69
|
+
if (onPress == null) {
|
|
70
|
+
problems.push(
|
|
71
|
+
`Card variant="${variant}" は onPress が要る。渡さないと押せない見た目のまま非対話になる。`,
|
|
72
|
+
);
|
|
73
|
+
} else if (typeof onPress !== "function") {
|
|
74
|
+
problems.push(`Card variant="${variant}" の onPress が関数でない(${typeof onPress})。`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (primaryAction == null) {
|
|
78
|
+
problems.push(
|
|
79
|
+
`Card variant="${variant}" は primaryAction が要る(contract 2.1.0)。` +
|
|
80
|
+
"カード面は操作要素ではないので、これが無いとキーボード / スクリーンリーダーから" +
|
|
81
|
+
"カードのアクションに到達できない。面の onPress と同じ操作を指す Button 等を渡すこと。",
|
|
82
|
+
);
|
|
83
|
+
} else if (!isValidElement(primaryAction)) {
|
|
84
|
+
problems.push(
|
|
85
|
+
`Card variant="${variant}" の primaryAction が React 要素でない(${typeof primaryAction})。` +
|
|
86
|
+
"文字列や真偽値では操作要素にならない。",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return problems;
|
|
91
|
+
}
|