wp-typia 0.16.1 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wp-typia",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Canonical CLI package for wp-typia scaffolding and project workflows",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  "@bunli/tui": "0.6.0",
66
66
  "@bunli/utils": "0.6.0",
67
67
  "@wp-typia/api-client": "^0.4.2",
68
- "@wp-typia/project-tools": "0.16.0",
68
+ "@wp-typia/project-tools": "0.16.1",
69
69
  "better-result": "^2.7.0",
70
70
  "react": "19.2.0",
71
71
  "react-dom": "19.2.0",
package/src/ui/README.md CHANGED
@@ -22,3 +22,22 @@ Alternate-buffer lifecycle contract:
22
22
  - Mounted flows must use the shared alternate-buffer lifecycle helper instead of ad hoc exit logic.
23
23
  - `create`, `add`, and `migrate` must always call `runtime.exit()` on submit success, cancel, and quit.
24
24
  - Runtime execution failures use exit-on-failure: report the error, then exit immediately.
25
+
26
+ First-party form interaction contract:
27
+
28
+ - `create`, `add`, and `migrate` use a shared first-party form layer instead of relying on `SchemaForm` field traversal.
29
+ - Small viewport safety is part of the `wp-typia` contract: active fields must stay inside a scrollable viewport and footer help must not overlap the form body.
30
+ - Keyboard traversal is owned locally by `wp-typia`.
31
+ - `Tab` / `Shift+Tab` move across the visible field order.
32
+ - Select fields handle arrow-key movement and preserve traversal when moving back out to the next field.
33
+ - Checkbox fields use `Space` / `Enter` to toggle without trapping focus.
34
+ - Hidden conditional fields must never keep focus or leak stale values into submit payloads.
35
+ - `Ctrl+S` submit must stay reachable through the shared first-party field layer, not only through Bunli's form-level hotkeys.
36
+
37
+ Regression and triage contract:
38
+
39
+ - The committed regression contract is the first-party model and layout test suite under `packages/wp-typia/tests`.
40
+ - `create-flow-layout.test.ts` keeps the small-viewport scroll model and checkbox-cluster ordering pinned.
41
+ - `tui-interaction-models.test.ts` keeps visible field ordering and submit sanitization pinned for `add` and `migrate`.
42
+ - The committed tests deliberately stop short of a repo-level PTY smoke because the alternate-buffer harness cost and flake surface outweighed the regression value for this round.
43
+ - If a future regression needs terminal-level confirmation, reproduce it from the real `wp-typia` command first before classifying it as a Bunli-level issue.
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+
3
+ import {
4
+ FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
5
+ FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
6
+ getFirstPartyScrollTop,
7
+ getFirstPartyViewportHeight,
8
+ } from "./first-party-form-model";
9
+
10
+ export const addFlowSchema = z.object({
11
+ anchor: z.string().optional(),
12
+ block: z.string().optional(),
13
+ "data-storage": z.string().optional(),
14
+ kind: z
15
+ .enum(["block", "variation", "pattern", "binding-source", "hooked-block"])
16
+ .default("block"),
17
+ name: z.string().optional(),
18
+ "persistence-policy": z.string().optional(),
19
+ position: z.string().optional(),
20
+ template: z.string().optional(),
21
+ });
22
+
23
+ export type AddFlowValues = z.infer<typeof addFlowSchema>;
24
+
25
+ export type AddFieldName =
26
+ | "kind"
27
+ | "name"
28
+ | "template"
29
+ | "block"
30
+ | "anchor"
31
+ | "position"
32
+ | "data-storage"
33
+ | "persistence-policy";
34
+
35
+ const ADD_FIELD_ORDER = [
36
+ "kind",
37
+ "name",
38
+ "template",
39
+ "block",
40
+ "anchor",
41
+ "position",
42
+ "data-storage",
43
+ "persistence-policy",
44
+ ] as const satisfies ReadonlyArray<AddFieldName>;
45
+
46
+ const ADD_FIELD_HEIGHTS: Record<AddFieldName, number> = {
47
+ anchor: FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
48
+ block: FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
49
+ "data-storage": FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
50
+ kind: FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
51
+ name: FIRST_PARTY_TEXT_FIELD_BODY_HEIGHT,
52
+ "persistence-policy": FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
53
+ position: FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
54
+ template: FIRST_PARTY_SELECT_FIELD_BODY_HEIGHT,
55
+ };
56
+
57
+ export function isAddPersistenceTemplate(template?: string): boolean {
58
+ return template === "persistence" || template === "compound";
59
+ }
60
+
61
+ export function getVisibleAddFieldNames(values: Partial<AddFlowValues>): Array<AddFieldName> {
62
+ switch (values.kind ?? "block") {
63
+ case "variation":
64
+ return ["kind", "name", "block"];
65
+ case "pattern":
66
+ return ["kind", "name"];
67
+ case "binding-source":
68
+ return ["kind", "name"];
69
+ case "hooked-block":
70
+ return ["kind", "name", "anchor", "position"];
71
+ case "block":
72
+ default:
73
+ return ADD_FIELD_ORDER.filter((name) => {
74
+ if (name === "data-storage" || name === "persistence-policy") {
75
+ return isAddPersistenceTemplate(values.template);
76
+ }
77
+ return name === "kind" || name === "name" || name === "template";
78
+ });
79
+ }
80
+ }
81
+
82
+ export function getAddViewportHeight(terminalHeight = 24): number {
83
+ return getFirstPartyViewportHeight(terminalHeight);
84
+ }
85
+
86
+ export function getAddScrollTop(options: {
87
+ activeFieldName: string | null;
88
+ values: Partial<AddFlowValues>;
89
+ viewportHeight: number;
90
+ }): number {
91
+ const { activeFieldName, values, viewportHeight } = options;
92
+ return getFirstPartyScrollTop({
93
+ activeFieldName,
94
+ fieldHeights: ADD_FIELD_HEIGHTS,
95
+ visibleFieldNames: getVisibleAddFieldNames(values),
96
+ viewportHeight,
97
+ });
98
+ }
99
+
100
+ export function sanitizeAddSubmitValues(values: AddFlowValues): Record<string, unknown> {
101
+ const visibleFields = new Set(getVisibleAddFieldNames(values));
102
+ const sanitized: Record<string, unknown> = {};
103
+
104
+ for (const fieldName of visibleFields) {
105
+ const value = values[fieldName];
106
+ if (typeof value === "string") {
107
+ const trimmed = value.trim();
108
+ if (trimmed.length > 0) {
109
+ sanitized[fieldName] = trimmed;
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (value !== undefined && value !== null) {
115
+ sanitized[fieldName] = value;
116
+ }
117
+ }
118
+
119
+ return sanitized;
120
+ }
@@ -1,32 +1,95 @@
1
- import { SchemaForm } from "@bunli/tui";
1
+ import { createElement, useMemo } from "react";
2
+
3
+ import {
4
+ Form,
5
+ type SelectOption,
6
+ useFormContext,
7
+ useTerminalDimensions,
8
+ } from "@bunli/tui";
2
9
  import { HOOKED_BLOCK_POSITION_IDS } from "@wp-typia/project-tools";
3
- import { z } from "zod";
4
10
 
5
11
  import { executeAddCommand } from "../runtime-bridge";
6
12
  import { useAlternateBufferLifecycle } from "./alternate-buffer-lifecycle";
13
+ import {
14
+ type AddFlowValues,
15
+ addFlowSchema,
16
+ getAddScrollTop,
17
+ getAddViewportHeight,
18
+ getVisibleAddFieldNames,
19
+ isAddPersistenceTemplate,
20
+ sanitizeAddSubmitValues,
21
+ } from "./add-flow-model";
22
+ import {
23
+ FirstPartyScrollBox,
24
+ FirstPartySelectField,
25
+ FirstPartyTextField,
26
+ } from "./first-party-form";
27
+ import { getWrappedFieldNeighbors } from "./first-party-form-model";
7
28
 
8
- const addFlowSchema = z.object({
9
- anchor: z.string().optional(),
10
- block: z.string().optional(),
11
- "data-storage": z.string().optional(),
12
- kind: z.enum(["block", "variation", "pattern", "binding-source", "hooked-block"]).default("block"),
13
- name: z.string().optional(),
14
- "persistence-policy": z.string().optional(),
15
- position: z.string().optional(),
16
- template: z.string().optional(),
17
- });
29
+ const kindOptions: SelectOption[] = [
30
+ { name: "block", description: "Add a real block slice", value: "block" },
31
+ {
32
+ name: "variation",
33
+ description: "Add a variation to an existing block",
34
+ value: "variation",
35
+ },
36
+ {
37
+ name: "pattern",
38
+ description: "Add a PHP block pattern shell",
39
+ value: "pattern",
40
+ },
41
+ {
42
+ name: "binding-source",
43
+ description: "Add a shared block bindings source",
44
+ value: "binding-source",
45
+ },
46
+ {
47
+ name: "hooked-block",
48
+ description: "Add block.json hook metadata to an existing block",
49
+ value: "hooked-block",
50
+ },
51
+ ];
18
52
 
19
- type AddFlowValues = z.infer<typeof addFlowSchema>;
53
+ const templateOptions: SelectOption[] = [
54
+ { name: "basic", description: "Basic block scaffold", value: "basic" },
55
+ {
56
+ name: "interactivity",
57
+ description: "Interactivity API block scaffold",
58
+ value: "interactivity",
59
+ },
60
+ {
61
+ name: "persistence",
62
+ description: "Persistence-enabled block scaffold",
63
+ value: "persistence",
64
+ },
65
+ {
66
+ name: "compound",
67
+ description: "Compound parent + child scaffold",
68
+ value: "compound",
69
+ },
70
+ ];
20
71
 
21
- type AddFlowProps = {
22
- cwd: string;
23
- initialValues: Partial<AddFlowValues>;
24
- workspaceBlockOptions: Array<{
25
- description: string;
26
- name: string;
27
- value: string;
28
- }>;
29
- };
72
+ const dataStorageOptions: SelectOption[] = [
73
+ {
74
+ name: "custom-table",
75
+ description: "Dedicated custom table storage",
76
+ value: "custom-table",
77
+ },
78
+ {
79
+ name: "post-meta",
80
+ description: "Persist through post meta",
81
+ value: "post-meta",
82
+ },
83
+ ];
84
+
85
+ const persistencePolicyOptions: SelectOption[] = [
86
+ {
87
+ name: "authenticated",
88
+ description: "Authenticated write policy",
89
+ value: "authenticated",
90
+ },
91
+ { name: "public", description: "Public token policy", value: "public" },
92
+ ];
30
93
 
31
94
  const HOOKED_BLOCK_POSITION_DESCRIPTIONS: Record<
32
95
  (typeof HOOKED_BLOCK_POSITION_IDS)[number],
@@ -38,170 +101,182 @@ const HOOKED_BLOCK_POSITION_DESCRIPTIONS: Record<
38
101
  lastChild: "Insert as the last child of the anchor block",
39
102
  };
40
103
 
41
- export function AddFlow({ cwd, initialValues, workspaceBlockOptions }: AddFlowProps) {
42
- const { handleCancel, handleSubmit } = useAlternateBufferLifecycle("wp-typia add failed");
104
+ type AddFlowProps = {
105
+ cwd: string;
106
+ initialValues: Partial<AddFlowValues>;
107
+ workspaceBlockOptions: Array<{
108
+ description: string;
109
+ name: string;
110
+ value: string;
111
+ }>;
112
+ };
43
113
 
44
- return (
45
- <SchemaForm
46
- fields={[
47
- {
48
- kind: "select",
49
- label: "Kind",
50
- name: "kind",
51
- options: [
52
- { name: "block", description: "Add a real block slice", value: "block" },
53
- {
54
- name: "variation",
55
- description: "Add a variation to an existing block",
56
- value: "variation",
57
- },
58
- {
59
- name: "pattern",
60
- description: "Add a PHP block pattern shell",
61
- value: "pattern",
62
- },
63
- {
64
- name: "binding-source",
65
- description: "Add a shared block bindings source",
66
- value: "binding-source",
67
- },
68
- {
69
- name: "hooked-block",
70
- description: "Add block.json hook metadata to an existing block",
71
- value: "hooked-block",
72
- },
73
- ],
74
- },
75
- {
76
- kind: "text",
77
- label: "Block name",
114
+ type AddSelectFieldName = {
115
+ [K in keyof AddFlowValues]-?: AddFlowValues[K] extends string | undefined ? K : never;
116
+ }[keyof AddFlowValues];
117
+
118
+ function getAddNameLabel(kind?: string): string {
119
+ switch (kind) {
120
+ case "variation":
121
+ return "Variation name";
122
+ case "pattern":
123
+ return "Pattern name";
124
+ case "binding-source":
125
+ return "Binding source name";
126
+ case "hooked-block":
127
+ return "Target block";
128
+ case "block":
129
+ default:
130
+ return "Block name";
131
+ }
132
+ }
133
+
134
+ function AddFlowFields({
135
+ workspaceBlockOptions,
136
+ }: {
137
+ workspaceBlockOptions: AddFlowProps["workspaceBlockOptions"];
138
+ }) {
139
+ const { activeFieldName, values } = useFormContext();
140
+ const { height: terminalHeight = 24 } = useTerminalDimensions();
141
+ const addValues = values as Partial<AddFlowValues>;
142
+ const kind = addValues.kind ?? "block";
143
+ const template = addValues.template;
144
+ const viewportHeight = getAddViewportHeight(terminalHeight);
145
+ const scrollValues = useMemo(() => ({ kind, template }), [kind, template]);
146
+ const scrollTop = useMemo(
147
+ () =>
148
+ getAddScrollTop({
149
+ activeFieldName,
150
+ values: scrollValues,
151
+ viewportHeight,
152
+ }),
153
+ [activeFieldName, scrollValues, viewportHeight],
154
+ );
155
+
156
+ const visibleFields = new Set(getVisibleAddFieldNames(addValues));
157
+ const orderedVisibleFields = useMemo(() => getVisibleAddFieldNames(addValues), [addValues]);
158
+ const hookedBlockNameUsesSelect = kind === "hooked-block" && workspaceBlockOptions.length > 0;
159
+ const variationBlockUsesSelect = kind === "variation" && workspaceBlockOptions.length > 0;
160
+
161
+ return createElement(
162
+ FirstPartyScrollBox,
163
+ { scrollTop, viewportHeight },
164
+ [
165
+ createElement(FirstPartySelectField, {
166
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "kind"),
167
+ key: "kind",
168
+ label: "Kind",
169
+ name: "kind" satisfies AddSelectFieldName,
170
+ options: kindOptions,
171
+ }),
172
+ visibleFields.has("name") && !hookedBlockNameUsesSelect
173
+ ? createElement(FirstPartyTextField, {
174
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "name"),
175
+ key: `name-text:${kind}`,
176
+ label: getAddNameLabel(kind),
78
177
  name: "name",
79
- visibleWhen: (values) => values.kind === "block",
80
- },
81
- {
82
- kind: "select",
178
+ })
179
+ : null,
180
+ hookedBlockNameUsesSelect
181
+ ? createElement(FirstPartySelectField, {
182
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "name"),
183
+ key: "name-select:hooked-block",
184
+ label: getAddNameLabel(kind),
185
+ name: "name" satisfies AddSelectFieldName,
186
+ options: workspaceBlockOptions,
187
+ })
188
+ : null,
189
+ visibleFields.has("template")
190
+ ? createElement(FirstPartySelectField, {
191
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "template"),
192
+ key: "template",
83
193
  label: "Template family",
84
- name: "template",
85
- options: [
86
- { name: "basic", description: "Basic block scaffold", value: "basic" },
87
- {
88
- name: "interactivity",
89
- description: "Interactivity API block scaffold",
90
- value: "interactivity",
91
- },
92
- {
93
- name: "persistence",
94
- description: "Persistence-enabled block scaffold",
95
- value: "persistence",
96
- },
97
- {
98
- name: "compound",
99
- description: "Compound parent + child scaffold",
100
- value: "compound",
101
- },
102
- ],
103
- visibleWhen: (values) => values.kind === "block",
104
- },
105
- {
106
- kind: "text",
107
- label: "Variation name",
108
- name: "name",
109
- visibleWhen: (values) => values.kind === "variation",
110
- },
111
- {
112
- kind: workspaceBlockOptions.length > 0 ? "select" : "text",
194
+ name: "template" satisfies AddSelectFieldName,
195
+ options: templateOptions,
196
+ })
197
+ : null,
198
+ visibleFields.has("block") && !variationBlockUsesSelect
199
+ ? createElement(FirstPartyTextField, {
200
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "block"),
201
+ key: "block-text",
113
202
  label: "Target block",
114
203
  name: "block",
115
- options: workspaceBlockOptions,
116
- visibleWhen: (values) => values.kind === "variation",
117
- },
118
- {
119
- kind: "text",
120
- label: "Pattern name",
121
- name: "name",
122
- visibleWhen: (values) => values.kind === "pattern",
123
- },
124
- {
125
- kind: "text",
126
- label: "Binding source name",
127
- name: "name",
128
- visibleWhen: (values) => values.kind === "binding-source",
129
- },
130
- {
131
- kind: workspaceBlockOptions.length > 0 ? "select" : "text",
204
+ })
205
+ : null,
206
+ variationBlockUsesSelect
207
+ ? createElement(FirstPartySelectField, {
208
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "block"),
209
+ key: "block-select",
132
210
  label: "Target block",
133
- name: "name",
211
+ name: "block" satisfies AddSelectFieldName,
134
212
  options: workspaceBlockOptions,
135
- visibleWhen: (values) => values.kind === "hooked-block",
136
- },
137
- {
138
- kind: "text",
213
+ })
214
+ : null,
215
+ visibleFields.has("anchor")
216
+ ? createElement(FirstPartyTextField, {
217
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "anchor"),
218
+ key: "anchor",
139
219
  label: "Anchor block name",
140
220
  name: "anchor",
141
- visibleWhen: (values) => values.kind === "hooked-block",
142
- },
143
- {
144
- kind: "select",
221
+ })
222
+ : null,
223
+ visibleFields.has("position")
224
+ ? createElement(FirstPartySelectField, {
225
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "position"),
226
+ key: "position",
145
227
  label: "Hook position",
146
- name: "position",
228
+ name: "position" satisfies AddSelectFieldName,
147
229
  options: HOOKED_BLOCK_POSITION_IDS.map((position) => ({
148
- name: position,
149
230
  description: HOOKED_BLOCK_POSITION_DESCRIPTIONS[position],
231
+ name: position,
150
232
  value: position,
151
233
  })),
152
- visibleWhen: (values) => values.kind === "hooked-block",
153
- },
154
- {
155
- kind: "select",
234
+ })
235
+ : null,
236
+ visibleFields.has("data-storage") && isAddPersistenceTemplate(template)
237
+ ? createElement(FirstPartySelectField, {
238
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "data-storage"),
239
+ key: "data-storage",
156
240
  label: "Data storage",
157
- name: "data-storage",
158
- options: [
159
- {
160
- name: "custom-table",
161
- description: "Dedicated custom table storage",
162
- value: "custom-table",
163
- },
164
- {
165
- name: "post-meta",
166
- description: "Persist through post meta",
167
- value: "post-meta",
168
- },
169
- ],
170
- visibleWhen: (values) =>
171
- values.kind === "block" &&
172
- (values.template === "persistence" || values.template === "compound"),
173
- },
174
- {
175
- kind: "select",
241
+ name: "data-storage" satisfies AddSelectFieldName,
242
+ options: dataStorageOptions,
243
+ })
244
+ : null,
245
+ visibleFields.has("persistence-policy") && isAddPersistenceTemplate(template)
246
+ ? createElement(FirstPartySelectField, {
247
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "persistence-policy"),
248
+ key: "persistence-policy",
176
249
  label: "Persistence policy",
177
- name: "persistence-policy",
178
- options: [
179
- {
180
- name: "authenticated",
181
- description: "Authenticated write policy",
182
- value: "authenticated",
183
- },
184
- { name: "public", description: "Public token policy", value: "public" },
185
- ],
186
- visibleWhen: (values) =>
187
- values.kind === "block" &&
188
- (values.template === "persistence" || values.template === "compound"),
189
- },
190
- ]}
250
+ name: "persistence-policy" satisfies AddSelectFieldName,
251
+ options: persistencePolicyOptions,
252
+ })
253
+ : null,
254
+ ],
255
+ );
256
+ }
257
+
258
+ export function AddFlow({ cwd, initialValues, workspaceBlockOptions }: AddFlowProps) {
259
+ const { handleCancel, handleSubmit } = useAlternateBufferLifecycle("wp-typia add failed");
260
+
261
+ return (
262
+ <Form
191
263
  initialValues={initialValues}
192
264
  onCancel={handleCancel}
193
265
  onSubmit={async (values) =>
194
266
  handleSubmit(async () => {
195
- await executeAddCommand({
196
- cwd,
197
- flags: values,
198
- kind: values.kind,
199
- name: values.name,
200
- });
267
+ const flags = sanitizeAddSubmitValues(values);
268
+ await executeAddCommand({
269
+ cwd,
270
+ flags,
271
+ kind: values.kind,
272
+ name: typeof flags.name === "string" ? flags.name : undefined,
273
+ });
201
274
  })
202
275
  }
203
276
  schema={addFlowSchema}
204
277
  title="Extend a wp-typia workspace"
205
- />
278
+ >
279
+ <AddFlowFields workspaceBlockOptions={workspaceBlockOptions} />
280
+ </Form>
206
281
  );
207
282
  }