wp-typia 0.16.0 → 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.0",
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
@@ -15,3 +15,29 @@ Constraints:
15
15
  shell automation.
16
16
  - `@wp-typia/project-tools` remains the runtime library; these screens should only
17
17
  collect input and hand the resolved command state back to `wp-typia`.
18
+
19
+ Alternate-buffer lifecycle contract:
20
+
21
+ - `LazyFlow` owns pre-mount lifecycle safety for lazy loader failures and loading-time quit.
22
+ - Mounted flows must use the shared alternate-buffer lifecycle helper instead of ad hoc exit logic.
23
+ - `create`, `add`, and `migrate` must always call `runtime.exit()` on submit success, cancel, and quit.
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,34 +1,95 @@
1
- import { useState } from "react";
1
+ import { createElement, useMemo } from "react";
2
2
 
3
- import { useRuntime } from "@bunli/runtime/app";
4
- import { Alert, SchemaForm } from "@bunli/tui";
3
+ import {
4
+ Form,
5
+ type SelectOption,
6
+ useFormContext,
7
+ useTerminalDimensions,
8
+ } from "@bunli/tui";
5
9
  import { HOOKED_BLOCK_POSITION_IDS } from "@wp-typia/project-tools";
6
- import { z } from "zod";
7
10
 
8
11
  import { executeAddCommand } from "../runtime-bridge";
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";
9
28
 
10
- const addFlowSchema = z.object({
11
- anchor: z.string().optional(),
12
- block: z.string().optional(),
13
- "data-storage": z.string().optional(),
14
- kind: z.enum(["block", "variation", "pattern", "binding-source", "hooked-block"]).default("block"),
15
- name: z.string().optional(),
16
- "persistence-policy": z.string().optional(),
17
- position: z.string().optional(),
18
- template: z.string().optional(),
19
- });
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
+ ];
20
52
 
21
- 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
+ ];
22
71
 
23
- type AddFlowProps = {
24
- cwd: string;
25
- initialValues: Partial<AddFlowValues>;
26
- workspaceBlockOptions: Array<{
27
- description: string;
28
- name: string;
29
- value: string;
30
- }>;
31
- };
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
+ ];
32
93
 
33
94
  const HOOKED_BLOCK_POSITION_DESCRIPTIONS: Record<
34
95
  (typeof HOOKED_BLOCK_POSITION_IDS)[number],
@@ -40,180 +101,182 @@ const HOOKED_BLOCK_POSITION_DESCRIPTIONS: Record<
40
101
  lastChild: "Insert as the last child of the anchor block",
41
102
  };
42
103
 
43
- export function AddFlow({ cwd, initialValues, workspaceBlockOptions }: AddFlowProps) {
44
- const runtime = useRuntime();
45
- const [errorMessage, setErrorMessage] = useState<string | null>(null);
104
+ type AddFlowProps = {
105
+ cwd: string;
106
+ initialValues: Partial<AddFlowValues>;
107
+ workspaceBlockOptions: Array<{
108
+ description: string;
109
+ name: string;
110
+ value: string;
111
+ }>;
112
+ };
46
113
 
47
- return (
48
- <>
49
- {errorMessage ? (
50
- <Alert message={errorMessage} title="Add failed" tone="danger" />
51
- ) : null}
52
- <SchemaForm
53
- fields={[
54
- {
55
- kind: "select",
56
- label: "Kind",
57
- name: "kind",
58
- options: [
59
- { name: "block", description: "Add a real block slice", value: "block" },
60
- {
61
- name: "variation",
62
- description: "Add a variation to an existing block",
63
- value: "variation",
64
- },
65
- {
66
- name: "pattern",
67
- description: "Add a PHP block pattern shell",
68
- value: "pattern",
69
- },
70
- {
71
- name: "binding-source",
72
- description: "Add a shared block bindings source",
73
- value: "binding-source",
74
- },
75
- {
76
- name: "hooked-block",
77
- description: "Add block.json hook metadata to an existing block",
78
- value: "hooked-block",
79
- },
80
- ],
81
- },
82
- {
83
- kind: "text",
84
- 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),
85
177
  name: "name",
86
- visibleWhen: (values) => values.kind === "block",
87
- },
88
- {
89
- 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",
90
193
  label: "Template family",
91
- name: "template",
92
- options: [
93
- { name: "basic", description: "Basic block scaffold", value: "basic" },
94
- {
95
- name: "interactivity",
96
- description: "Interactivity API block scaffold",
97
- value: "interactivity",
98
- },
99
- {
100
- name: "persistence",
101
- description: "Persistence-enabled block scaffold",
102
- value: "persistence",
103
- },
104
- {
105
- name: "compound",
106
- description: "Compound parent + child scaffold",
107
- value: "compound",
108
- },
109
- ],
110
- visibleWhen: (values) => values.kind === "block",
111
- },
112
- {
113
- kind: "text",
114
- label: "Variation name",
115
- name: "name",
116
- visibleWhen: (values) => values.kind === "variation",
117
- },
118
- {
119
- 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",
120
202
  label: "Target block",
121
203
  name: "block",
122
- options: workspaceBlockOptions,
123
- visibleWhen: (values) => values.kind === "variation",
124
- },
125
- {
126
- kind: "text",
127
- label: "Pattern name",
128
- name: "name",
129
- visibleWhen: (values) => values.kind === "pattern",
130
- },
131
- {
132
- kind: "text",
133
- label: "Binding source name",
134
- name: "name",
135
- visibleWhen: (values) => values.kind === "binding-source",
136
- },
137
- {
138
- kind: workspaceBlockOptions.length > 0 ? "select" : "text",
204
+ })
205
+ : null,
206
+ variationBlockUsesSelect
207
+ ? createElement(FirstPartySelectField, {
208
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "block"),
209
+ key: "block-select",
139
210
  label: "Target block",
140
- name: "name",
211
+ name: "block" satisfies AddSelectFieldName,
141
212
  options: workspaceBlockOptions,
142
- visibleWhen: (values) => values.kind === "hooked-block",
143
- },
144
- {
145
- kind: "text",
213
+ })
214
+ : null,
215
+ visibleFields.has("anchor")
216
+ ? createElement(FirstPartyTextField, {
217
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "anchor"),
218
+ key: "anchor",
146
219
  label: "Anchor block name",
147
220
  name: "anchor",
148
- visibleWhen: (values) => values.kind === "hooked-block",
149
- },
150
- {
151
- kind: "select",
221
+ })
222
+ : null,
223
+ visibleFields.has("position")
224
+ ? createElement(FirstPartySelectField, {
225
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "position"),
226
+ key: "position",
152
227
  label: "Hook position",
153
- name: "position",
228
+ name: "position" satisfies AddSelectFieldName,
154
229
  options: HOOKED_BLOCK_POSITION_IDS.map((position) => ({
155
- name: position,
156
230
  description: HOOKED_BLOCK_POSITION_DESCRIPTIONS[position],
231
+ name: position,
157
232
  value: position,
158
233
  })),
159
- visibleWhen: (values) => values.kind === "hooked-block",
160
- },
161
- {
162
- kind: "select",
234
+ })
235
+ : null,
236
+ visibleFields.has("data-storage") && isAddPersistenceTemplate(template)
237
+ ? createElement(FirstPartySelectField, {
238
+ ...getWrappedFieldNeighbors(orderedVisibleFields, "data-storage"),
239
+ key: "data-storage",
163
240
  label: "Data storage",
164
- name: "data-storage",
165
- options: [
166
- {
167
- name: "custom-table",
168
- description: "Dedicated custom table storage",
169
- value: "custom-table",
170
- },
171
- {
172
- name: "post-meta",
173
- description: "Persist through post meta",
174
- value: "post-meta",
175
- },
176
- ],
177
- visibleWhen: (values) =>
178
- values.kind === "block" &&
179
- (values.template === "persistence" || values.template === "compound"),
180
- },
181
- {
182
- 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",
183
249
  label: "Persistence policy",
184
- name: "persistence-policy",
185
- options: [
186
- {
187
- name: "authenticated",
188
- description: "Authenticated write policy",
189
- value: "authenticated",
190
- },
191
- { name: "public", description: "Public token policy", value: "public" },
192
- ],
193
- visibleWhen: (values) =>
194
- values.kind === "block" &&
195
- (values.template === "persistence" || values.template === "compound"),
196
- },
197
- ]}
198
- initialValues={initialValues}
199
- onCancel={() => runtime.exit()}
200
- onSubmit={async (values) => {
201
- try {
202
- setErrorMessage(null);
203
- await executeAddCommand({
204
- cwd,
205
- flags: values,
206
- kind: values.kind,
207
- name: values.name,
208
- });
209
- runtime.exit();
210
- } catch (error) {
211
- setErrorMessage(error instanceof Error ? error.message : String(error));
212
- }
213
- }}
214
- schema={addFlowSchema}
215
- title="Extend a wp-typia workspace"
216
- />
217
- </>
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
263
+ initialValues={initialValues}
264
+ onCancel={handleCancel}
265
+ onSubmit={async (values) =>
266
+ handleSubmit(async () => {
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
+ });
274
+ })
275
+ }
276
+ schema={addFlowSchema}
277
+ title="Extend a wp-typia workspace"
278
+ >
279
+ <AddFlowFields workspaceBlockOptions={workspaceBlockOptions} />
280
+ </Form>
218
281
  );
219
282
  }