@mawaru/sdk 0.39.0 → 0.41.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.
Files changed (79) hide show
  1. package/_schemas/graph.test.ts +15 -9
  2. package/_schemas/graph.ts +15 -6
  3. package/_schemas/human-manifest.test.ts +67 -0
  4. package/_schemas/human-manifest.ts +31 -0
  5. package/_schemas/port-spec.test.ts +33 -92
  6. package/_schemas/port-spec.ts +36 -59
  7. package/dist/_cli/previewCommand.js +3 -3
  8. package/dist/_cli/previewFiles.js +2 -3
  9. package/dist/_cli/previewSlack.d.ts +1 -1
  10. package/dist/_cli/previewSlack.js +1 -1
  11. package/dist/_schemas/graph.d.ts +6 -3
  12. package/dist/_schemas/graph.js +12 -6
  13. package/dist/_schemas/human-manifest.d.ts +9 -0
  14. package/dist/_schemas/human-manifest.js +29 -0
  15. package/dist/_schemas/port-spec.d.ts +5 -11
  16. package/dist/_schemas/port-spec.js +28 -37
  17. package/dist/cli.js +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/init.js +22 -3
  21. package/dist/typegen.d.ts +3 -1
  22. package/dist/typegen.js +96 -14
  23. package/dist/validate.js +57 -0
  24. package/dist/views/_bridge.d.ts +12 -0
  25. package/dist/views/_bridge.js +10 -0
  26. package/dist/views/_humanProps.d.ts +10 -0
  27. package/dist/views/_humanProps.js +11 -0
  28. package/dist/views/_parts/DocumentCard.d.ts +2 -0
  29. package/dist/views/_parts/DocumentCard.js +16 -0
  30. package/dist/views/_parts/FilePreview.d.ts +5 -0
  31. package/dist/views/_parts/FilePreview.js +101 -0
  32. package/dist/views/_parts/Linkify.d.ts +7 -0
  33. package/dist/views/_parts/Linkify.js +28 -0
  34. package/dist/views/_parts/Markdown.d.ts +44 -0
  35. package/dist/views/_parts/Markdown.js +313 -0
  36. package/dist/views/_parts/SchemaForm.d.ts +19 -0
  37. package/dist/views/_parts/SchemaForm.js +310 -0
  38. package/dist/views/_parts/fileRef.d.ts +7 -0
  39. package/dist/views/_parts/fileRef.js +11 -0
  40. package/dist/views/_parts/useNarrow.d.ts +5 -0
  41. package/dist/views/_parts/useNarrow.js +23 -0
  42. package/dist/views/components.d.ts +7 -0
  43. package/dist/views/components.js +12 -0
  44. package/dist/views/host.d.ts +23 -0
  45. package/dist/views/host.js +20 -0
  46. package/dist/views/human.d.ts +54 -0
  47. package/dist/views/human.js +5 -0
  48. package/dist/views/protocol.d.ts +44 -0
  49. package/dist/views/protocol.js +5 -0
  50. package/dist/views/runtime.d.ts +2 -0
  51. package/dist/views/runtime.js +86 -0
  52. package/dist/views/sample.d.ts +22 -0
  53. package/dist/views/sample.js +38 -0
  54. package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
  55. package/docs/development.md +39 -2
  56. package/index.ts +1 -0
  57. package/package.json +40 -4
  58. package/skills/create-loop/SKILL.md +7 -3
  59. package/templates/CLAUDE.md +2 -0
  60. package/views/_bridge.tsx +36 -0
  61. package/views/_humanProps.ts +27 -0
  62. package/views/_parts/DocumentCard.ts +18 -0
  63. package/views/_parts/FilePreview.tsx +180 -0
  64. package/views/_parts/Linkify.test.ts +40 -0
  65. package/views/_parts/Linkify.tsx +40 -0
  66. package/views/_parts/Markdown.test.ts +131 -0
  67. package/views/_parts/Markdown.tsx +472 -0
  68. package/views/_parts/SchemaForm.tsx +742 -0
  69. package/views/_parts/fileRef.ts +21 -0
  70. package/views/_parts/useNarrow.ts +24 -0
  71. package/views/components.ts +16 -0
  72. package/views/host.ts +27 -0
  73. package/views/human.ts +83 -0
  74. package/views/protocol.ts +63 -0
  75. package/views/runtime.tsx +123 -0
  76. package/views/sample.tsx +94 -0
  77. package/views/slack.test.ts +40 -0
  78. package/{slack-view.ts → views/slack.ts} +3 -3
  79. /package/dist/{slack-view.js → views/slack.js} +0 -0
@@ -0,0 +1,310 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState, } from "react";
3
+ import { useViewBridge } from "../_bridge.js";
4
+ import { isFileRef } from "./fileRef.js";
5
+ import { isLongText, Markdown, useAutoGrow } from "./Markdown.js";
6
+ const isFileSchema = (schema) => schema?.["x-mawaru-type"] === "file";
7
+ /** フォーム描画できる schema(properties を持つ object)なら型を付けて返す */
8
+ export const asFormSchema = (schema) => {
9
+ if (schema == null || typeof schema !== "object")
10
+ return null;
11
+ const s = schema;
12
+ if (s.type !== "object")
13
+ return null;
14
+ if (!s.properties || Object.keys(s.properties).length === 0)
15
+ return null;
16
+ return s;
17
+ };
18
+ const nameStyle = {
19
+ fontFamily: "var(--font-mono)",
20
+ fontSize: "var(--text-sm)",
21
+ fontWeight: "var(--weight-medium)",
22
+ color: "var(--ink)",
23
+ whiteSpace: "nowrap",
24
+ };
25
+ // 見出し行の右に置く小さな操作(「編集」「表示に戻す」)
26
+ const headActionStyle = {
27
+ minHeight: "22px",
28
+ padding: "0 var(--space-2)",
29
+ fontSize: "var(--text-xs)",
30
+ fontWeight: "var(--weight-normal)",
31
+ flex: "none",
32
+ };
33
+ const FieldHead = ({ name, schema, required, marker, action, }) => (_jsxs("span", { style: {
34
+ display: "flex",
35
+ // boolean ではチェックボックスと横並びの flex アイテムになる。残り幅に広げて説明を出す
36
+ flex: 1,
37
+ minWidth: 0,
38
+ alignItems: "baseline",
39
+ gap: "var(--space-2)",
40
+ fontSize: "var(--text-xs)",
41
+ color: "var(--muted)",
42
+ }, children: [marker, _jsx("span", { style: nameStyle, children: name }), required && (_jsx("span", { style: { color: "var(--accent)", flex: "none" }, children: "*" })), _jsx("span", { style: {
43
+ flex: 1,
44
+ width: 0,
45
+ overflow: "hidden",
46
+ textOverflow: "ellipsis",
47
+ whiteSpace: "nowrap",
48
+ }, children: schema.description }), action] }));
49
+ // array・未知の型のフォールバック:フィールド単位の JSON 入力。
50
+ // 入力途中の不正 JSON は text に留め、valid になった時点で親へ反映する。
51
+ const JsonField = ({ value, onChange, }) => {
52
+ const [text, setText] = useState(() => JSON.stringify(value ?? null, null, 2));
53
+ const [invalid, setInvalid] = useState(false);
54
+ const ref = useAutoGrow(text);
55
+ return (_jsxs("div", { style: { display: "grid", gap: "2px" }, children: [_jsx("textarea", { ref: ref, rows: 3, value: text, onChange: (e) => {
56
+ setText(e.target.value);
57
+ try {
58
+ onChange(JSON.parse(e.target.value));
59
+ setInvalid(false);
60
+ }
61
+ catch {
62
+ setInvalid(true);
63
+ }
64
+ }, style: {
65
+ fontFamily: "var(--font-mono)",
66
+ fontSize: "var(--text-xs)",
67
+ lineHeight: 1.6,
68
+ maxHeight: "60vh",
69
+ resize: "vertical",
70
+ } }), invalid && (_jsx("p", { role: "alert", style: { fontSize: "var(--text-xs)" }, children: "JSON \u3068\u3057\u3066\u4E0D\u6B63\u3067\u3059" }))] }));
71
+ };
72
+ // ---- file 型(x-mawaru-type: "file")の添付フィールド ----
73
+ // 選択と同時に backend(POST /tenants/:id/files)へアップロードし、フォーム値には
74
+ // path 参照(fileRef)を入れる。実体は private バケットに置かれ、実行時に署名 URL が発行される
75
+ const fileRefOf = (value) => isFileRef(value) ? value : null;
76
+ const formatSize = (size) => {
77
+ if (size == null)
78
+ return null;
79
+ if (size >= 1024 * 1024)
80
+ return `${(size / 1024 / 1024).toFixed(1)}MB`;
81
+ return `${Math.max(1, Math.round(size / 1024))}KB`;
82
+ };
83
+ const useFileUpload = () => {
84
+ const bridge = useViewBridge();
85
+ const [uploading, setUploading] = useState(false);
86
+ const [error, setError] = useState(null);
87
+ const upload = async (list) => {
88
+ setUploading(true);
89
+ setError(null);
90
+ try {
91
+ const refs = [];
92
+ for (const file of Array.from(list)) {
93
+ refs.push(await bridge.uploadFile(file));
94
+ }
95
+ return refs;
96
+ }
97
+ catch (err) {
98
+ setError(err instanceof Error ? err.message : String(err));
99
+ return [];
100
+ }
101
+ finally {
102
+ setUploading(false);
103
+ }
104
+ };
105
+ return { upload, uploading, error };
106
+ };
107
+ const FileChip = ({ file, onRemove, }) => (_jsxs("span", { style: {
108
+ display: "inline-flex",
109
+ alignItems: "center",
110
+ gap: "var(--space-1)",
111
+ maxWidth: "100%",
112
+ border: "var(--border-w) solid var(--border)",
113
+ borderRadius: "var(--radius-s)",
114
+ background: "var(--surface-2)",
115
+ padding: "2px var(--space-2)",
116
+ fontSize: "var(--text-xs)",
117
+ }, children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDCCE" }), _jsx("span", { style: {
118
+ minWidth: 0,
119
+ overflow: "hidden",
120
+ textOverflow: "ellipsis",
121
+ whiteSpace: "nowrap",
122
+ }, children: file.name }), formatSize(file.size) && (_jsx("span", { style: { color: "var(--muted)", flex: "none" }, children: formatSize(file.size) })), _jsx("button", { type: "button", className: "ghost", onClick: onRemove, "aria-label": `${file.name} を外す`, style: {
123
+ minHeight: "18px",
124
+ padding: "0 4px",
125
+ fontSize: "var(--text-xs)",
126
+ }, children: "\u2715" })] }));
127
+ // 破線のドロップゾーン兼選択ボタン。クリックで隠し input[type=file] を開き、
128
+ // ドラッグ&ドロップも受け付ける(アップロード中は無効化)
129
+ const FilePicker = ({ label, multiple, uploading, onPick, }) => {
130
+ const inputRef = useRef(null);
131
+ // dragenter/leave は子要素をまたぐたびに発火するため深さを数えて判定する
132
+ const depthRef = useRef(0);
133
+ const [dragOver, setDragOver] = useState(false);
134
+ const hasFiles = (e) => Array.from(e.dataTransfer.types).includes("Files");
135
+ return (_jsxs(_Fragment, { children: [_jsx("input", { ref: inputRef, type: "file", multiple: multiple, style: { display: "none" }, onChange: (e) => {
136
+ if (e.target.files && e.target.files.length > 0) {
137
+ onPick(e.target.files);
138
+ }
139
+ e.target.value = "";
140
+ } }), _jsxs("button", { type: "button", onClick: () => inputRef.current?.click(), disabled: uploading, onDragEnter: (e) => {
141
+ if (uploading || !hasFiles(e))
142
+ return;
143
+ e.preventDefault();
144
+ depthRef.current += 1;
145
+ setDragOver(true);
146
+ }, onDragOver: (e) => {
147
+ if (uploading || !hasFiles(e))
148
+ return;
149
+ e.preventDefault();
150
+ }, onDragLeave: () => {
151
+ if (depthRef.current > 0)
152
+ depthRef.current -= 1;
153
+ if (depthRef.current === 0)
154
+ setDragOver(false);
155
+ }, onDrop: (e) => {
156
+ e.preventDefault();
157
+ depthRef.current = 0;
158
+ setDragOver(false);
159
+ if (uploading)
160
+ return;
161
+ if (e.dataTransfer.files.length > 0)
162
+ onPick(e.dataTransfer.files);
163
+ }, style: {
164
+ display: "flex",
165
+ alignItems: "center",
166
+ justifyContent: "center",
167
+ gap: "var(--space-1)",
168
+ width: "100%",
169
+ minHeight: "40px",
170
+ padding: "var(--space-2)",
171
+ border: `var(--border-w) dashed ${dragOver ? "var(--accent)" : "var(--border)"}`,
172
+ borderRadius: "var(--radius-s)",
173
+ background: dragOver ? "var(--surface-2)" : "transparent",
174
+ color: dragOver ? "var(--accent)" : "var(--muted)",
175
+ fontSize: "var(--text-xs)",
176
+ }, children: [_jsx("span", { "aria-hidden": "true", children: "\uD83D\uDCCE" }), uploading ? "アップロード中…" : `${label}、またはドラッグ&ドロップ`] })] }));
177
+ };
178
+ // file 単体:未選択(null)⇄ 選択済みチップ
179
+ const FileField = ({ value, onChange, }) => {
180
+ const { upload, uploading, error } = useFileUpload();
181
+ const file = fileRefOf(value);
182
+ const pick = async (list) => {
183
+ const refs = await upload(list);
184
+ if (refs[0])
185
+ onChange(refs[0]);
186
+ };
187
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [file ? (_jsx(FileChip, { file: file, onRemove: () => onChange(null) })) : (_jsx(FilePicker, { label: "\u30D5\u30A1\u30A4\u30EB\u3092\u9078\u629E", uploading: uploading, onPick: pick })), error && (_jsx("p", { role: "alert", style: { margin: 0, fontSize: "var(--text-xs)" }, children: error }))] }));
188
+ };
189
+ // file 配列:チップのリスト+追加(複数選択可)
190
+ const FileArrayField = ({ value, onChange, }) => {
191
+ const { upload, uploading, error } = useFileUpload();
192
+ const files = Array.isArray(value)
193
+ ? value.map(fileRefOf).filter((f) => f !== null)
194
+ : [];
195
+ const pick = async (list) => {
196
+ const refs = await upload(list);
197
+ if (refs.length > 0)
198
+ onChange([...files, ...refs]);
199
+ };
200
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [files.map((file, i) => (_jsx(FileChip, { file: file, onRemove: () => onChange(files.filter((_, j) => j !== i)) }, file.path))), _jsx(FilePicker, { label: "\u30D5\u30A1\u30A4\u30EB\u3092\u8FFD\u52A0", multiple: true, uploading: uploading, onPick: pick }), error && (_jsx("p", { role: "alert", style: { margin: 0, fontSize: "var(--text-xs)" }, children: error }))] }));
201
+ };
202
+ // 長文の raw 編集欄:内容の高さまで伸び、上限(60vh)を超えたらその中でスクロールする
203
+ const LongTextArea = ({ value, onChange, autoFocus, }) => {
204
+ const ref = useAutoGrow(value);
205
+ return (_jsx("textarea", { ref: ref,
206
+ // biome-ignore lint/a11y/noAutofocus: 「編集」を押して現れる欄なので即入力できる方が自然
207
+ autoFocus: autoFocus, rows: 3, value: value, onChange: (e) => onChange(e.target.value), style: {
208
+ fontSize: "var(--text-base)",
209
+ lineHeight: 1.9,
210
+ maxHeight: "60vh",
211
+ resize: "vertical",
212
+ } }));
213
+ };
214
+ // string フィールド:長文・改行入りの値は textarea に昇格する(AI の下書き等)。
215
+ // 入力中に input ⇄ textarea が flip してフォーカスが飛ばないよう、昇格したら戻さない
216
+ const StringInput = ({ value, onChange, }) => {
217
+ const long = isLongText(value);
218
+ const [multiline, setMultiline] = useState(long);
219
+ useEffect(() => {
220
+ if (long)
221
+ setMultiline(true);
222
+ }, [long]);
223
+ return multiline ? (_jsx(LongTextArea, { value: value, onChange: onChange })) : (_jsx("input", { type: "text", value: value, onChange: (e) => onChange(e.target.value) }));
224
+ };
225
+ // 値の入った長文 string=読み物。既定は Markdown の整形表示で、「編集」を押した時だけ
226
+ // raw に入れ替わる。長文が複数あるときの2つ目以降は畳んだ状態で始まる(見出しだけ出す)
227
+ const LongTextField = ({ name, schema, required, value, editable, defaultCollapsed, onChange, }) => {
228
+ // 開閉は <details>:読み取り専用の局面で書類全体を <fieldset disabled> で包んでも
229
+ // (フォーム部品ではないので)畳んだ本文を開いて読める
230
+ const [open, setOpen] = useState(!defaultCollapsed);
231
+ const [editing, setEditing] = useState(false);
232
+ const lines = value.split("\n").length;
233
+ return (_jsxs("details", { open: open, onToggle: (e) => setOpen(e.currentTarget.open), children: [_jsx("summary", { style: { cursor: "pointer", listStyle: "none" }, children: _jsx(FieldHead, { name: name, schema: schema, required: required, marker: _jsx("span", { "aria-hidden": "true", style: { flex: "none" }, children: open ? "▾" : "▸" }), action: open ? (editable ? (_jsx("button", { type: "button", className: "ghost",
234
+ // summary の中なので、押しても開閉させない
235
+ onClick: (e) => {
236
+ e.preventDefault();
237
+ setEditing((current) => !current);
238
+ }, style: headActionStyle, children: editing ? "表示に戻す" : "編集" })) : null) : (_jsxs("span", { style: {
239
+ flex: "none",
240
+ fontVariantNumeric: "var(--numeric)",
241
+ }, children: [lines, "\u884C"] })) }) }), _jsx("div", { style: {
242
+ display: "grid",
243
+ marginTop: "var(--space-3)",
244
+ borderTop: "var(--border-w) solid var(--border)",
245
+ paddingTop: "var(--space-3)",
246
+ }, children: editing ? (_jsx(LongTextArea, { value: value, onChange: onChange, autoFocus: true })) : (_jsx(Markdown, { text: value })) })] }));
247
+ };
248
+ const Field = ({ name, schema, required, value, readOnly, asDocument, defaultCollapsed, onChange, }) => {
249
+ const head = _jsx(FieldHead, { name: name, schema: schema, required: required });
250
+ // file 型は enum / type 判定より先に見る(実体は object / array なので)
251
+ if (isFileSchema(schema)) {
252
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [head, _jsx(FileField, { value: value, onChange: onChange })] }));
253
+ }
254
+ if (schema.type === "array" && isFileSchema(schema.items)) {
255
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [head, _jsx(FileArrayField, { value: value, onChange: onChange })] }));
256
+ }
257
+ const options = schema.enum;
258
+ if (options && options.length > 0) {
259
+ const idx = options.findIndex((opt) => JSON.stringify(opt) === JSON.stringify(value));
260
+ return (_jsxs("label", { children: [head, _jsxs("select", { value: String(idx), onChange: (e) => onChange(options[Number(e.target.value)]), children: [idx < 0 && (_jsx("option", { value: "-1", disabled: true, children: "\u9078\u629E\u3057\u3066\u304F\u3060\u3055\u3044" })), options.map((opt, i) => (_jsx("option", { value: String(i), children: typeof opt === "string" ? opt : JSON.stringify(opt) }, JSON.stringify(opt))))] })] }));
261
+ }
262
+ switch (schema.type) {
263
+ case "string": {
264
+ const text = typeof value === "string" ? value : "";
265
+ if (asDocument) {
266
+ return (_jsx(LongTextField, { name: name, schema: schema, required: required, value: text, editable: !readOnly, defaultCollapsed: defaultCollapsed, onChange: onChange }));
267
+ }
268
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [head, _jsx(StringInput, { value: text, onChange: onChange })] }));
269
+ }
270
+ case "number":
271
+ case "integer":
272
+ return (_jsxs("label", { children: [head, _jsx("input", { type: "number", step: schema.type === "integer" ? 1 : "any", value: typeof value === "number" ? value : "", onChange: (e) => {
273
+ const raw = e.target.value;
274
+ onChange(raw === "" ? null : Number(raw));
275
+ } })] }));
276
+ case "boolean":
277
+ return (_jsxs("label", { style: {
278
+ display: "flex",
279
+ alignItems: "center",
280
+ gap: "var(--space-2)",
281
+ }, children: [_jsx("input", { type: "checkbox", checked: Boolean(value), onChange: (e) => onChange(e.target.checked), style: { minHeight: 0, width: "14px", height: "14px" } }), head] }));
282
+ case "object": {
283
+ if (!schema.properties || Object.keys(schema.properties).length === 0) {
284
+ break;
285
+ }
286
+ const obj = (value != null && typeof value === "object" ? value : {});
287
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-2)" }, children: [head, _jsx("div", { style: {
288
+ borderLeft: "var(--border-w) solid var(--border)",
289
+ paddingLeft: "var(--space-3)",
290
+ }, children: _jsx(SchemaForm, { schema: schema, value: obj, readOnly: readOnly, onChange: onChange }) })] }));
291
+ }
292
+ }
293
+ return (_jsxs("div", { style: { display: "grid", gap: "var(--space-1)" }, children: [head, _jsx(JsonField, { value: value, onChange: onChange })] }));
294
+ };
295
+ export const SchemaForm = ({ schema, value, readOnly = false, onChange, }) => {
296
+ const fields = Object.entries(schema.properties ?? {});
297
+ // 中身の入った長文=読み物(Markdown 表示)。開いた時点の値で決め、以後は変えない:
298
+ // 入力中に 80 文字を超えた瞬間に入力欄が表示に化けてフォーカスが飛ぶのを避ける
299
+ // (空欄から書き始める手動実行の入力フォームは従来どおり入力欄のまま)
300
+ const [documentKeys] = useState(() => fields
301
+ .filter(([key]) => {
302
+ const field = value[key];
303
+ return typeof field === "string" && isLongText(field);
304
+ })
305
+ .map(([key]) => key));
306
+ return (_jsx("div", { style: { display: "grid", gap: "var(--space-5)" }, children: fields.map(([key, child]) => (_jsx(Field, { name: key, schema: child, required: schema.required?.includes(key) ?? false, value: value[key], readOnly: readOnly, asDocument: documentKeys.includes(key),
307
+ // 読み物が複数あるとき、先頭(=主役の成果物)だけ開いて以降は畳む。
308
+ // key 名には依存せず、スキーマの並び順だけで決める
309
+ defaultCollapsed: key !== documentKeys[0], onChange: (v) => onChange({ ...value, [key]: v }) }, key))) }));
310
+ };
@@ -0,0 +1,7 @@
1
+ export type FileRef = {
2
+ path: string;
3
+ name: string;
4
+ mime_type?: string;
5
+ size?: number;
6
+ };
7
+ export declare const isFileRef: (value: unknown) => value is FileRef;
@@ -0,0 +1,11 @@
1
+ export const isFileRef = (value) => {
2
+ if (typeof value !== "object" || value === null)
3
+ return false;
4
+ const v = value;
5
+ return (typeof v.path === "string" &&
6
+ v.path !== "" &&
7
+ typeof v.name === "string" &&
8
+ v.name !== "" &&
9
+ (v.mime_type === undefined || typeof v.mime_type === "string") &&
10
+ (v.size === undefined || typeof v.size === "number"));
11
+ };
@@ -0,0 +1,5 @@
1
+ export declare const NARROW_WIDTH = 560;
2
+ export declare const useNarrow: <T extends HTMLElement>() => {
3
+ ref: import("react").RefObject<T | null>;
4
+ narrow: boolean;
5
+ };
@@ -0,0 +1,23 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ // 承認ビューの幅対応。判定は**ビューポートではなくコンテナの幅**で行う。
3
+ //
4
+ // 開発プレビュー(/dev/views/approval)は幅の確認を「狭いコンテナ」で再現するので、
5
+ // media query で判定すると実機とプレビューで挙動がズレる(プレビューでは常に
6
+ // デスクトップ扱いになる)。ResizeObserver でコンテナ自身を測れば両方で同じになる。
7
+ export const NARROW_WIDTH = 560;
8
+ export const useNarrow = () => {
9
+ const ref = useRef(null);
10
+ const [narrow, setNarrow] = useState(false);
11
+ useEffect(() => {
12
+ const element = ref.current;
13
+ if (!element)
14
+ return;
15
+ const observer = new ResizeObserver(([entry]) => {
16
+ if (entry)
17
+ setNarrow(entry.contentRect.width < NARROW_WIDTH);
18
+ });
19
+ observer.observe(element);
20
+ return () => observer.disconnect();
21
+ }, []);
22
+ return { ref, narrow };
23
+ };
@@ -0,0 +1,7 @@
1
+ export { documentCardStyle } from "./_parts/DocumentCard.js";
2
+ export { FilePreview } from "./_parts/FilePreview.js";
3
+ export { type FileRef, isFileRef } from "./_parts/fileRef.js";
4
+ export { isUrl, Linkify, linkify } from "./_parts/Linkify.js";
5
+ export { isLongText, Markdown, useAutoGrow } from "./_parts/Markdown.js";
6
+ export { asFormSchema, type JsonSchema as FormSchema, SchemaForm, } from "./_parts/SchemaForm.js";
7
+ export { NARROW_WIDTH, useNarrow } from "./_parts/useNarrow.js";
@@ -0,0 +1,12 @@
1
+ // 共通の部品(`@mawaru/sdk/components`)。human ノードの約束(`@mawaru/sdk/human`)には依存しない。
2
+ // mawaru の画面と顧客 repo の human ノードの実装が同じ部品を使う(mawaru が顧客 repo の実装をまとめるとき、
3
+ // mawaru の今の版のこの部品を同梱する)。ファイルの表示・アップロードは、mawaru の画面ではホストの口、
4
+ // sandbox iframe の中では mawaru の承認画面への頼みごととして裏で差し込まれる(_bridge.tsx)。
5
+ // **公開 API**:部品の props は後方互換でしか変えない
6
+ export { documentCardStyle } from "./_parts/DocumentCard.js";
7
+ export { FilePreview } from "./_parts/FilePreview.js";
8
+ export { isFileRef } from "./_parts/fileRef.js";
9
+ export { isUrl, Linkify, linkify } from "./_parts/Linkify.js";
10
+ export { isLongText, Markdown, useAutoGrow } from "./_parts/Markdown.js";
11
+ export { asFormSchema, SchemaForm, } from "./_parts/SchemaForm.js";
12
+ export { NARROW_WIDTH, useNarrow } from "./_parts/useNarrow.js";
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ export { useViewBridge, type ViewBridge, ViewBridgeProvider, } from "./_bridge.js";
3
+ export { type HumanOps, humanPropsOf } from "./_humanProps.js";
4
+ export * from "./protocol.js";
5
+ export declare const viewToHostSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
+ tag: z.ZodLiteral<"mawaru:view">;
7
+ type: z.ZodLiteral<"ready">;
8
+ }, z.core.$strip>, z.ZodObject<{
9
+ tag: z.ZodLiteral<"mawaru:view">;
10
+ type: z.ZodLiteral<"error">;
11
+ message: z.ZodString;
12
+ }, z.core.$strip>, z.ZodObject<{
13
+ tag: z.ZodLiteral<"mawaru:view">;
14
+ type: z.ZodLiteral<"request">;
15
+ id: z.ZodNumber;
16
+ method: z.ZodEnum<{
17
+ out: "out";
18
+ fetchFileUrl: "fetchFileUrl";
19
+ uploadFile: "uploadFile";
20
+ writeText: "writeText";
21
+ }>;
22
+ args: z.ZodUnknown;
23
+ }, z.core.$strip>], "type">;
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { VIEW_MESSAGE_TAG } from "./protocol.js";
3
+ // ホスト(mawaru の画面)だけが使うもの(公開 API ではない):sandbox iframe からのメッセージを確かめる形
4
+ // (protocol.ts の型と同じ。iframe の中は信用できないコードなので、形を確かめてから使う)、
5
+ // 部品が裏で使う口を差す提供部品、human ノードの実装に渡す props の組み立て(標準の承認画面も同じものを使う)
6
+ export { useViewBridge, ViewBridgeProvider, } from "./_bridge.js";
7
+ export { humanPropsOf } from "./_humanProps.js";
8
+ export * from "./protocol.js";
9
+ const tag = z.literal(VIEW_MESSAGE_TAG);
10
+ export const viewToHostSchema = z.discriminatedUnion("type", [
11
+ z.object({ tag, type: z.literal("ready") }),
12
+ z.object({ tag, type: z.literal("error"), message: z.string() }),
13
+ z.object({
14
+ tag,
15
+ type: z.literal("request"),
16
+ id: z.number().int(),
17
+ method: z.enum(["out", "fetchFileUrl", "uploadFile", "writeText"]),
18
+ args: z.unknown(),
19
+ }),
20
+ ]);
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from "react";
2
+ import type { JsonSchema } from "../_schemas/node.js";
3
+ import type { FileRef } from "./_parts/fileRef.js";
4
+ export type HumanProps<TInput = unknown, TOutput = Record<string, unknown>, TRefs = Record<string, unknown>> = {
5
+ /** 届いた値(in ポートの値= step の input) */
6
+ input: TInput;
7
+ /**
8
+ * 人の操作を mawaru に渡す。`{ <出口ポートのkey>: <データ> }`(program の run の戻り値と同じ1キーのオブジェクト)。
9
+ * 人がボタンを押した直後の呼び出しだけを受け付ける(開いただけで決定する画面は書けない)。
10
+ * 送れたら承認画面は run の画面へ戻る。断られたら(担当でない・もう決まっている など)理由つきで失敗する
11
+ */
12
+ out: (output: TOutput) => Promise<void>;
13
+ /** input 以外の材料(program の run の ctx と同じ位置づけ) */
14
+ ctx: {
15
+ input: {
16
+ /** input の型(in ポートの JSON Schema) */
17
+ schema: JsonSchema;
18
+ /** input に含まれるファイルを表示する URL を取る(sandbox の中からは mawaru のファイルを直接読めない) */
19
+ fetchFileUrl: (file: FileRef) => Promise<string>;
20
+ };
21
+ output: {
22
+ /** 出口ごとの型(out ポートの key → JSON Schema。approve・reject・追加の出口) */
23
+ schema: Record<string, JsonSchema>;
24
+ /** 出す値に入れるファイル(差し戻しの参考ファイルなど)を上げて、参照を受け取る */
25
+ uploadFile: (file: File) => Promise<FileRef>;
26
+ };
27
+ clipboard: {
28
+ /** クリップボードに書く(sandbox の中では使えない navigator.clipboard.writeText の代わり) */
29
+ writeText: (text: string) => Promise<void>;
30
+ };
31
+ /**
32
+ * ループ変数(この step が始まった時点の値。program の ctx.refs と同じ)。
33
+ * 予約キー `input` が run の起動入力
34
+ */
35
+ refs: TRefs;
36
+ step: {
37
+ /** 見ている人がこの step の担当か(担当でなければ out は断られる) */
38
+ isAssignee: boolean;
39
+ /**
40
+ * 値を直してよいか:担当で、全員承認のノードでないとき true
41
+ * (全員承認は全員が同じ値を承認するので、届いた値のまま決める)
42
+ */
43
+ editable: boolean;
44
+ };
45
+ };
46
+ };
47
+ /** mawaru が承認画面に描く、human ノードの画面(React のコンポーネント) */
48
+ export type HumanView<TInput = unknown, TOutput = Record<string, unknown>, TRefs = Record<string, unknown>> = (props: HumanProps<TInput, TOutput, TRefs>) => ReactNode;
49
+ /**
50
+ * human ノードの実装を定義する:input と out を受けて描く関数を渡すと、画面を返す。
51
+ * React では props を受けて描く関数がそのままコンポーネントなので、型を付けて返すだけ
52
+ */
53
+ export declare const defineHuman: <TInput = unknown, TOutput = Record<string, unknown>, TRefs = Record<string, unknown>>(render: (props: HumanProps<TInput, TOutput, TRefs>) => ReactNode) => HumanView<TInput, TOutput, TRefs>;
54
+ export type { FileRef };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * human ノードの実装を定義する:input と out を受けて描く関数を渡すと、画面を返す。
3
+ * React では props を受けて描く関数がそのままコンポーネントなので、型を付けて返すだけ
4
+ */
5
+ export const defineHuman = (render) => render;
@@ -0,0 +1,44 @@
1
+ import type { JsonSchema } from "../_schemas/node.js";
2
+ export declare const VIEW_MESSAGE_TAG = "mawaru:view";
3
+ export type ThemeMode = "light" | "dark";
4
+ export type HumanViewData = {
5
+ input: unknown;
6
+ inputSchema: JsonSchema;
7
+ outputSchema: Record<string, JsonSchema>;
8
+ refs: unknown;
9
+ isAssignee: boolean;
10
+ editable: boolean;
11
+ };
12
+ export type HumanRequestMethod = "out" | "fetchFileUrl" | "uploadFile" | "writeText";
13
+ export type ViewToHost = {
14
+ tag: typeof VIEW_MESSAGE_TAG;
15
+ type: "ready";
16
+ } | {
17
+ tag: typeof VIEW_MESSAGE_TAG;
18
+ type: "error";
19
+ message: string;
20
+ } | {
21
+ tag: typeof VIEW_MESSAGE_TAG;
22
+ type: "request";
23
+ id: number;
24
+ method: HumanRequestMethod;
25
+ args: unknown;
26
+ };
27
+ export type HostToView = {
28
+ tag: typeof VIEW_MESSAGE_TAG;
29
+ type: "data";
30
+ data: HumanViewData;
31
+ mode: ThemeMode;
32
+ } | {
33
+ tag: typeof VIEW_MESSAGE_TAG;
34
+ type: "response";
35
+ id: number;
36
+ ok: true;
37
+ value: unknown;
38
+ } | {
39
+ tag: typeof VIEW_MESSAGE_TAG;
40
+ type: "response";
41
+ id: number;
42
+ ok: false;
43
+ error: string;
44
+ };
@@ -0,0 +1,5 @@
1
+ // sandbox iframe の中の実行部分(runtime.tsx)とホスト(mawaru の承認画面)とのメッセージの形。
2
+ // **公開 API ではない**:iframe の中の実行部分とホストは同じデプロイの mawaru のコード(まとめるときに
3
+ // その時点の版を同梱する)なので、いつ変えてもよい。
4
+ // iframe に入る JS を小さく保つため、ここには型と定数だけを置く(ホストの検査は host.ts)
5
+ export const VIEW_MESSAGE_TAG = "mawaru:view";
@@ -0,0 +1,2 @@
1
+ import type { HumanView } from "./human.js";
2
+ export declare const runHumanView: (View: HumanView) => void;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Component } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { ViewBridgeProvider } from "./_bridge.js";
5
+ import { humanPropsOf } from "./_humanProps.js";
6
+ import { VIEW_MESSAGE_TAG, } from "./protocol.js";
7
+ const post = (message) =>
8
+ // 受け手は親(ホスト)の window だけ。不透明オリジンの iframe からは親のオリジンを指定できない
9
+ window.parent.postMessage({ tag: VIEW_MESSAGE_TAG, ...message }, "*");
10
+ const messageOf = (err) => err instanceof Error ? err.message : String(err);
11
+ let nextRequestId = 1;
12
+ const pending = new Map();
13
+ const dataListeners = new Set();
14
+ const onHostMessage = (event) => {
15
+ if (event.source !== window.parent)
16
+ return;
17
+ const message = event.data;
18
+ if (message?.tag !== VIEW_MESSAGE_TAG)
19
+ return;
20
+ if (message.type === "response") {
21
+ const waiting = pending.get(message.id);
22
+ if (!waiting)
23
+ return;
24
+ pending.delete(message.id);
25
+ if (message.ok)
26
+ waiting.resolve(message.value);
27
+ else
28
+ waiting.reject(new Error(message.error));
29
+ return;
30
+ }
31
+ if (message.type === "data") {
32
+ document.documentElement.dataset.mode = message.mode;
33
+ for (const listener of dataListeners)
34
+ listener(message.data);
35
+ }
36
+ };
37
+ // ホストに頼む(返事が来るまで待つ)
38
+ const requestHost = (method, args) => new Promise((resolve, reject) => {
39
+ const id = nextRequestId++;
40
+ pending.set(id, {
41
+ resolve: resolve,
42
+ reject,
43
+ });
44
+ post({ type: "request", id, method, args });
45
+ });
46
+ const ops = {
47
+ out: (output) => requestHost("out", { output }),
48
+ fetchFileUrl: (file) => requestHost("fetchFileUrl", { file }),
49
+ uploadFile: (file) => requestHost("uploadFile", { file }),
50
+ writeText: (text) => requestHost("writeText", { text }),
51
+ };
52
+ // 部品(FilePreview・SchemaForm のファイル欄)が裏で使う口も、同じ頼みごとにつなぐ
53
+ const bridge = {
54
+ fileUrl: ops.fetchFileUrl,
55
+ uploadFile: ops.uploadFile,
56
+ copyText: ops.writeText,
57
+ };
58
+ // 描画の例外をホストへ知らせる(ホストはエラーと理由を出す)
59
+ class ReportErrors extends Component {
60
+ state = { failed: false };
61
+ static getDerivedStateFromError() {
62
+ return { failed: true };
63
+ }
64
+ componentDidCatch(error) {
65
+ post({ type: "error", message: messageOf(error) });
66
+ }
67
+ render() {
68
+ return this.state.failed ? null : this.props.children;
69
+ }
70
+ }
71
+ // 実装はコンポーネント1つ。状態(直した値など)は実装が useState で持ち、材料が届き直しても
72
+ // 同じコンポーネントとして描き直すので保たれる
73
+ export const runHumanView = (View) => {
74
+ window.addEventListener("message", onHostMessage);
75
+ window.addEventListener("error", (event) => post({ type: "error", message: event.message }));
76
+ window.addEventListener("unhandledrejection", (event) => post({ type: "error", message: messageOf(event.reason) }));
77
+ const container = document.getElementById("root");
78
+ if (!container)
79
+ throw new Error("#root がありません");
80
+ const root = createRoot(container);
81
+ dataListeners.add((data) => {
82
+ const props = humanPropsOf(data, ops);
83
+ root.render(_jsx(ViewBridgeProvider, { bridge: bridge, children: _jsx(ReportErrors, { children: _jsx(View, { ...props }) }) }));
84
+ });
85
+ post({ type: "ready" });
86
+ };