@godxjp/ui 30.5.2 → 30.6.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.
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 30.5.2.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 30.6.0.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -56,10 +56,10 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
56
56
  its `importPath`, and its examples. Fetch only the handful you picked in step 1.
57
57
  3. `rules.json` — 50 cardinal rules. The ones about raw HTML and hardcoded colour are not
58
58
  style advice.
59
- 4. `tokens.json` — 2073 design tokens, each tagged with its `tier`. **If you were handed a
59
+ 4. `tokens.json` — 2074 design tokens, each tagged with its `tier`. **If you were handed a
60
60
  brand, read the 211 `foundation` entries first** — `--primary`, `--background`,
61
61
  `--radius`, `--font-size-base` are the handful everything else derives from. The
62
- 1759 `component` entries are per-part knobs; reach for one only when a role is
62
+ 1760 `component` entries are per-part knobs; reach for one only when a role is
63
63
  right everywhere except one component.
64
64
  5. `anti-ai-tells.json` — 26 shapes that make generated UI look generated, each with the
65
65
  fix. Read before you reach for a gradient hero or a wall of coloured chips.
@@ -145,7 +145,7 @@ has stopped following the brand.
145
145
  |---|---|---|---|
146
146
  | `foundation` | 211 | the seeds — `--primary`, `--background`, `--foreground`, `--radius`, `--font-size-base`, `--shadow-color`. Everything below is derived from these | **yes — this is the main road.** Handed a brand colour, this is where it goes: `:root { --primary: <H> <S>% <L>%; }` (HSL components, no `hsl()` wrapper) |
147
147
  | `semantic` | 103 | named roles that follow the seeds — `--ring`, `--text-link`, `--primary-hover`, `--overlay-background` | only when the seed is right and ONE role must differ. That role then stops following a later brand change |
148
- | `component` | 1759 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
148
+ | `component` | 1760 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
149
149
 
150
150
  A token whose `value` is `initial` is not empty and not broken: `initial` is the guaranteed-invalid
151
151
  value, so the real default is computed where the element paints it. Set it and yours wins.
@@ -10,6 +10,12 @@
10
10
  "name": "mode",
11
11
  "type": "\"single\" | \"multiple\""
12
12
  },
13
+ {
14
+ "defaultValue": "\"auto\"",
15
+ "description": "TWO ENTRY POINTS AT ONCE, instead of \"dropdown OR dialog\" (gh#944). `auto` keeps the original behaviour: `threshold` picks one. `inline` opens both — a typeable field with suggestions REGARDLESS of `count`, and a 「検索」 button beside it that always shows and opens the dialog with filters + pagination; the half-typed text carries into the dialog as its initial query. Reach for it when `count > threshold` is almost always true: a consumer measured customer projects at hundreds-to-thousands of records, so the dialog-only branch was the one users met most, and there they lost the ability to type a key they already knew. The threshold answers \"is the set big\"; the real question is \"does this person already know what they want\" — `inline` stops forcing a choice. NOTE: the forwarded ref lands on the <input> under `inline` and on the trigger <button> otherwise.",
16
+ "name": "shape",
17
+ "type": "\"auto\" | \"inline\""
18
+ },
13
19
  {
14
20
  "defaultValue": "10",
15
21
  "description": "The dropdown/dialog switch. A dropdown is the right control for eight people and the wrong one for eight hundred; this is where that line is drawn, once, by a service rather than per screen.",
@@ -27,7 +33,7 @@
27
33
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
28
34
  },
29
35
  {
30
- "description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
36
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
31
37
  "name": "loadOptions",
32
38
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
33
39
  },
@@ -66,6 +72,11 @@
66
72
  "name": "placeholder",
67
73
  "type": "string"
68
74
  },
75
+ {
76
+ "description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
77
+ "name": "data-field",
78
+ "type": "string"
79
+ },
69
80
  {
70
81
  "description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
71
82
  "name": "dialogTitle",
@@ -5996,6 +5996,12 @@
5996
5996
  "name": "mode",
5997
5997
  "type": "\"single\" | \"multiple\""
5998
5998
  },
5999
+ {
6000
+ "defaultValue": "\"auto\"",
6001
+ "description": "TWO ENTRY POINTS AT ONCE, instead of \"dropdown OR dialog\" (gh#944). `auto` keeps the original behaviour: `threshold` picks one. `inline` opens both — a typeable field with suggestions REGARDLESS of `count`, and a 「検索」 button beside it that always shows and opens the dialog with filters + pagination; the half-typed text carries into the dialog as its initial query. Reach for it when `count > threshold` is almost always true: a consumer measured customer projects at hundreds-to-thousands of records, so the dialog-only branch was the one users met most, and there they lost the ability to type a key they already knew. The threshold answers \"is the set big\"; the real question is \"does this person already know what they want\" — `inline` stops forcing a choice. NOTE: the forwarded ref lands on the <input> under `inline` and on the trigger <button> otherwise.",
6002
+ "name": "shape",
6003
+ "type": "\"auto\" | \"inline\""
6004
+ },
5999
6005
  {
6000
6006
  "defaultValue": "10",
6001
6007
  "description": "The dropdown/dialog switch. A dropdown is the right control for eight people and the wrong one for eight hundred; this is where that line is drawn, once, by a service rather than per screen.",
@@ -6013,7 +6019,7 @@
6013
6019
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
6014
6020
  },
6015
6021
  {
6016
- "description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
6022
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
6017
6023
  "name": "loadOptions",
6018
6024
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
6019
6025
  },
@@ -6052,6 +6058,11 @@
6052
6058
  "name": "placeholder",
6053
6059
  "type": "string"
6054
6060
  },
6061
+ {
6062
+ "description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
6063
+ "name": "data-field",
6064
+ "type": "string"
6065
+ },
6055
6066
  {
6056
6067
  "description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
6057
6068
  "name": "dialogTitle",
package/agent/index.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "components": 173,
5
5
  "patterns": 21,
6
6
  "rules": 50,
7
- "tokens": 2073,
7
+ "tokens": 2074,
8
8
  "vocabulary": 14
9
9
  },
10
10
  "files": [
@@ -48,19 +48,19 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.5.2/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.6.0/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
55
55
  "tokenTiers": {
56
56
  "component": "per-part knobs, --{component}-{part}-{property}; usually leave these alone",
57
57
  "counts": {
58
- "component": 1759,
58
+ "component": 1760,
59
59
  "foundation": 211,
60
60
  "semantic": 103
61
61
  },
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "30.5.2"
65
+ "version": "30.6.0"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
- > A Japanese-enterprise React design system: 173 components, 2073 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.5.2.
3
+ > A Japanese-enterprise React design system: 173 components, 2074 design tokens,
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.6.0.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@30.5.2`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@30.6.0`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -18,7 +18,7 @@ that can only fetch URLs.
18
18
  - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 46 KB — all 173 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
19
19
  - [components/&lt;Name&gt;.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components/Select.json): one file per component (1 KB–34 KB, median 6 KB). Read the index, then fetch only the ones you chose — this is the selective route, and the reason you do not need the blob.
20
20
  - [components.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components.json): 1.2 MB — every entry in one file. Most URL fetchers truncate a response this size without saying so; prefer the per-component files.
21
- - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1759 `component` knobs.
21
+ - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1760 `component` knobs.
22
22
  - [vocabulary.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/vocabulary.json): the controlled prop vocabulary — which prop name means what, across every component.
23
23
  - [rules.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/rules.json): 50 cardinal rules.
24
24
  - [anti-ai-tells.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/anti-ai-tells.json): 26 shapes that make generated UI look generated, each with its fix.
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v30.5.2/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v30.6.0/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
package/agent/tokens.json CHANGED
@@ -5891,6 +5891,12 @@
5891
5891
  "tier": "component",
5892
5892
  "value": "22rem"
5893
5893
  },
5894
+ {
5895
+ "description": "Danh sách gợi ý dưới ô inline (gh#944) — ngắn hơn danh sách trong Dialog, vì nó nằm TRONG dòng chảy của form chứ không phải trong một lớp phủ riêng.",
5896
+ "name": "--record-picker-suggest-max-block-size",
5897
+ "tier": "component",
5898
+ "value": "14rem"
5899
+ },
5894
5900
  {
5895
5901
  "description": "Data-entry component tokens — small-by-design text knobs (rule #45/#46).",
5896
5902
  "name": "--password-strength-score-font-size",
@@ -35,6 +35,7 @@ export type { RecordPickerProp, RecordPickerProp as RecordPickerProps };
35
35
  */
36
36
  export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "defaultValue" | "onChange" | "value"> & {
37
37
  mode?: "single" | "multiple";
38
+ shape?: "auto" | "inline";
38
39
  value?: string | string[] | null;
39
40
  defaultValue?: string | string[] | null;
40
41
  onValueChange?: (value: string | string[] | null) => void;
@@ -59,4 +60,5 @@ export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.Bu
59
60
  placeholder?: string;
60
61
  dialogTitle?: string;
61
62
  size?: "xs" | "sm" | "md" | "lg";
62
- } & React.RefAttributes<HTMLButtonElement>>;
63
+ "data-field"?: string;
64
+ } & React.RefAttributes<HTMLButtonElement | HTMLInputElement>>;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { Check, ChevronDown, X } from "lucide-react";
3
+ import { Check, ChevronDown, Search, X } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
6
  import { cn } from "../../lib/utils.js";
@@ -24,6 +24,7 @@ import { Select } from "./select.js";
24
24
  const RecordPicker = React.forwardRef(
25
25
  function RecordPicker2({
26
26
  mode = "single",
27
+ shape = "auto",
27
28
  value,
28
29
  defaultValue,
29
30
  onValueChange,
@@ -39,6 +40,7 @@ const RecordPicker = React.forwardRef(
39
40
  disabled = false,
40
41
  size,
41
42
  className,
43
+ "data-field": fieldName,
42
44
  ...props
43
45
  }, ref) {
44
46
  const { t } = useTranslation();
@@ -55,10 +57,44 @@ const RecordPicker = React.forwardRef(
55
57
  },
56
58
  [controlled, isMultiple, onValueChange]
57
59
  );
60
+ const selectLoadOptions = React.useMemo(
61
+ () => loadOptions ? async ({ query, page }) => {
62
+ const r = await loadOptions({
63
+ query,
64
+ filters: {},
65
+ cursor: page > 1 ? String(page) : void 0
66
+ });
67
+ return { options: r.options, hasMore: r.nextCursor !== void 0 };
68
+ } : void 0,
69
+ [loadOptions]
70
+ );
58
71
  const staticOptions = React.useMemo(
59
72
  () => options ? normalizeSelectOptions(options) : [],
60
73
  [options]
61
74
  );
75
+ if (shape === "inline") {
76
+ return /* @__PURE__ */ jsx(
77
+ InlinePicker,
78
+ {
79
+ ref,
80
+ isMultiple,
81
+ selected,
82
+ commit,
83
+ staticOptions,
84
+ loadOptions,
85
+ filters,
86
+ selectedOptions,
87
+ emptyOption,
88
+ placeholder,
89
+ dialogTitle,
90
+ disabled,
91
+ size,
92
+ className,
93
+ "data-field": fieldName,
94
+ ...props
95
+ }
96
+ );
97
+ }
62
98
  const total = count ?? (options ? staticOptions.length : void 0);
63
99
  const asDialog = total === void 0 ? Boolean(loadOptions) : total > threshold;
64
100
  if (!asDialog) {
@@ -66,6 +102,10 @@ const RecordPicker = React.forwardRef(
66
102
  Select,
67
103
  {
68
104
  mode: isMultiple ? "multiple" : void 0,
105
+ loadOptions: selectLoadOptions,
106
+ id: props.id,
107
+ "data-field": fieldName,
108
+ "aria-describedby": props["aria-describedby"],
69
109
  labelInValue: false,
70
110
  showSearch: true,
71
111
  options: withEmptyOption(options, emptyOption),
@@ -82,6 +122,7 @@ const RecordPicker = React.forwardRef(
82
122
  DialogPicker,
83
123
  {
84
124
  ref,
125
+ "data-field": fieldName,
85
126
  isMultiple,
86
127
  selected,
87
128
  commit,
@@ -100,6 +141,7 @@ const RecordPicker = React.forwardRef(
100
141
  );
101
142
  }
102
143
  );
144
+ const INLINE_SUGGESTION_LIMIT = 8;
103
145
  function toArray(v) {
104
146
  if (v === null || v === void 0) return [];
105
147
  return Array.isArray(v) ? v : [v];
@@ -108,6 +150,155 @@ function withEmptyOption(options, emptyOption) {
108
150
  if (!emptyOption) return options;
109
151
  return [{ value: emptyOption.value, label: emptyOption.label }, ...options ?? []];
110
152
  }
153
+ const InlinePicker = React.forwardRef(function InlinePicker2({
154
+ isMultiple,
155
+ selected,
156
+ commit,
157
+ staticOptions,
158
+ loadOptions,
159
+ filters,
160
+ selectedOptions,
161
+ emptyOption,
162
+ placeholder,
163
+ dialogTitle,
164
+ disabled,
165
+ size,
166
+ className,
167
+ id,
168
+ "data-field": fieldName,
169
+ "aria-describedby": describedBy
170
+ }, ref) {
171
+ const { t } = useTranslation();
172
+ const [query, setQuery] = React.useState("");
173
+ const [rows, setRows] = React.useState(staticOptions);
174
+ const [openSuggest, setOpenSuggest] = React.useState(false);
175
+ const [dialogQuery, setDialogQuery] = React.useState(null);
176
+ const labelCache = React.useRef(/* @__PURE__ */ new Map());
177
+ for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) {
178
+ labelCache.current.set(o.value, o);
179
+ }
180
+ React.useEffect(() => {
181
+ if (!loadOptions || query.trim() === "") return;
182
+ const id2 = setTimeout(() => {
183
+ void loadOptions({ query, filters: {} }).then((r) => setRows(r.options)).catch(() => void 0);
184
+ }, 250);
185
+ return () => clearTimeout(id2);
186
+ }, [query, loadOptions]);
187
+ const suggestions = React.useMemo(() => {
188
+ const q = query.trim().toLowerCase();
189
+ const base = loadOptions ? rows : staticOptions;
190
+ return (q ? base.filter((o) => (o.label + (o.sublabel ?? "")).toLowerCase().includes(q)) : base).slice(0, INLINE_SUGGESTION_LIMIT);
191
+ }, [query, rows, staticOptions, loadOptions]);
192
+ const chosen = selected.map((v) => labelCache.current.get(v) ?? { value: v, label: v });
193
+ const pick = (v) => {
194
+ commit(isMultiple ? [...selected, v] : [v]);
195
+ setQuery("");
196
+ setOpenSuggest(false);
197
+ };
198
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
199
+ /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: 1, className: cn("ui-record-picker-inline", className), children: [
200
+ /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", children: [
201
+ /* @__PURE__ */ jsx(
202
+ Input,
203
+ {
204
+ ref,
205
+ type: "search",
206
+ value: query,
207
+ disabled,
208
+ placeholder: placeholder ?? t("dataEntry.recordPicker.placeholder"),
209
+ onChange: (e) => {
210
+ setQuery(e.target.value);
211
+ setOpenSuggest(true);
212
+ },
213
+ onFocus: () => setOpenSuggest(true),
214
+ className: "ui-record-picker-inline-input",
215
+ id,
216
+ "data-field": fieldName,
217
+ "aria-describedby": describedBy
218
+ }
219
+ ),
220
+ /* @__PURE__ */ jsxs(
221
+ Button,
222
+ {
223
+ type: "button",
224
+ variant: "outline",
225
+ size: size === "lg" ? "lg" : "default",
226
+ disabled,
227
+ "aria-haspopup": "dialog",
228
+ onClick: () => setDialogQuery(query),
229
+ children: [
230
+ /* @__PURE__ */ jsx(Search, { "aria-hidden": "true" }),
231
+ t("dataEntry.recordPicker.openSearch")
232
+ ]
233
+ }
234
+ ),
235
+ chosen.length > 0 && !disabled ? /* @__PURE__ */ jsx(
236
+ Button,
237
+ {
238
+ variant: "ghost",
239
+ size: "icon-sm",
240
+ "aria-label": t("dataEntry.recordPicker.clear"),
241
+ onClick: () => commit([]),
242
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
243
+ }
244
+ ) : null
245
+ ] }),
246
+ chosen.length > 0 ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chosen.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
247
+ c.icon,
248
+ c.label,
249
+ c.sublabel ? ` ${c.sublabel}` : ""
250
+ ] }, c.value)) }) : null,
251
+ openSuggest && suggestions.length > 0 ? /* @__PURE__ */ jsx(
252
+ Command,
253
+ {
254
+ shouldFilter: false,
255
+ className: "ui-record-picker-suggest",
256
+ "aria-label": t("dataEntry.recordPicker.suggestions"),
257
+ children: suggestions.map((o) => /* @__PURE__ */ jsxs(
258
+ "button",
259
+ {
260
+ type: "button",
261
+ role: "option",
262
+ "aria-selected": selected.includes(o.value),
263
+ "data-picked": selected.includes(o.value) ? "" : void 0,
264
+ className: "ui-record-picker-option",
265
+ onClick: () => pick(o.value),
266
+ children: [
267
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: selected.includes(o.value) ? /* @__PURE__ */ jsx(Check, {}) : null }),
268
+ /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
269
+ o.label,
270
+ o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
271
+ ] })
272
+ ]
273
+ },
274
+ o.value
275
+ ))
276
+ }
277
+ ) : null
278
+ ] }),
279
+ dialogQuery !== null ? /* @__PURE__ */ jsx(
280
+ DialogPicker,
281
+ {
282
+ isMultiple,
283
+ selected,
284
+ commit: (next) => {
285
+ commit(next);
286
+ setDialogQuery(null);
287
+ setQuery("");
288
+ },
289
+ staticOptions,
290
+ loadOptions,
291
+ filters,
292
+ selectedOptions,
293
+ emptyOption,
294
+ dialogTitle,
295
+ initialQuery: dialogQuery,
296
+ autoOpen: true,
297
+ onDismiss: () => setDialogQuery(null)
298
+ }
299
+ ) : null
300
+ ] });
301
+ });
111
302
  const DialogPicker = React.forwardRef(function DialogPicker2({
112
303
  isMultiple,
113
304
  selected,
@@ -122,31 +313,41 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
122
313
  disabled,
123
314
  size,
124
315
  className,
316
+ initialQuery,
317
+ autoOpen = false,
318
+ onDismiss,
125
319
  ...props
126
320
  }, ref) {
127
321
  const { t } = useTranslation();
128
- const [open, setOpen] = React.useState(false);
129
- const [query, setQuery] = React.useState("");
322
+ const [open, setOpen] = React.useState(autoOpen);
323
+ const [query, setQuery] = React.useState(initialQuery ?? "");
130
324
  const [filterValues, setFilterValues] = React.useState({});
131
325
  const [rows, setRows] = React.useState(staticOptions);
132
326
  const [status, setStatus] = React.useState("idle");
327
+ const [cursor, setCursor] = React.useState(void 0);
133
328
  const [draft, setDraft] = React.useState(selected);
134
329
  React.useEffect(() => {
135
330
  if (open) setDraft(selected);
136
331
  }, [open, selected]);
137
- const labels = React.useMemo(() => {
138
- const map = /* @__PURE__ */ new Map();
139
- for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) map.set(o.value, o);
140
- if (emptyOption) map.set(emptyOption.value, { value: emptyOption.value, label: emptyOption.label });
141
- return map;
142
- }, [selectedOptions, staticOptions, rows, emptyOption]);
332
+ const labelCache = React.useRef(/* @__PURE__ */ new Map());
333
+ for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) {
334
+ labelCache.current.set(o.value, o);
335
+ }
336
+ if (emptyOption) {
337
+ labelCache.current.set(emptyOption.value, {
338
+ value: emptyOption.value,
339
+ label: emptyOption.label
340
+ });
341
+ }
342
+ const labels = labelCache.current;
143
343
  const load = React.useCallback(
144
- async (q, f) => {
344
+ async (q, f, more) => {
145
345
  if (!loadOptions) return;
146
346
  setStatus("loading");
147
347
  try {
148
- const result = await loadOptions({ query: q, filters: f });
149
- setRows(result.options);
348
+ const result = await loadOptions({ query: q, filters: f, cursor: more });
349
+ setRows((prev) => more ? [...prev, ...result.options] : result.options);
350
+ setCursor(result.nextCursor);
150
351
  setStatus("idle");
151
352
  } catch {
152
353
  setStatus("error");
@@ -183,112 +384,161 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
183
384
  };
184
385
  const chips = selected.map((v) => labels.get(v) ?? { value: v, label: v });
185
386
  return /* @__PURE__ */ jsxs(Fragment, { children: [
186
- /* @__PURE__ */ jsxs(
187
- Button,
387
+ autoOpen ? null : /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", className: "ui-record-picker-row", children: [
388
+ /* @__PURE__ */ jsxs(
389
+ Button,
390
+ {
391
+ ref,
392
+ type: "button",
393
+ variant: "outline",
394
+ size,
395
+ disabled,
396
+ "aria-haspopup": "dialog",
397
+ onClick: () => setOpen(true),
398
+ className: cn("ui-record-picker-trigger", className),
399
+ ...props,
400
+ children: [
401
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
402
+ c.icon,
403
+ c.label
404
+ ] }, c.value)) }) }),
405
+ /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
406
+ ]
407
+ }
408
+ ),
409
+ !isMultiple && selected.length > 0 && !disabled ? /* @__PURE__ */ jsx(
410
+ Button,
411
+ {
412
+ variant: "ghost",
413
+ size: "icon-sm",
414
+ "aria-label": t("dataEntry.recordPicker.clear"),
415
+ onClick: () => commit([]),
416
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
417
+ }
418
+ ) : null
419
+ ] }),
420
+ /* @__PURE__ */ jsx(
421
+ Dialog,
188
422
  {
189
- ref,
190
- type: "button",
191
- variant: "outline",
192
- size,
193
- disabled,
194
- "aria-haspopup": "dialog",
195
- onClick: () => setOpen(true),
196
- className: cn("ui-record-picker-trigger", className),
197
- ...props,
198
- children: [
199
- /* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
200
- c.icon,
201
- c.label
202
- ] }, c.value)) }) }),
203
- /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
204
- ]
205
- }
206
- ),
207
- /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
208
- /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
209
- /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
210
- /* @__PURE__ */ jsx(
211
- Input,
212
- {
213
- autoFocus: true,
214
- type: "search",
215
- value: query,
216
- onChange: (e) => setQuery(e.target.value),
217
- "aria-label": t("dataEntry.recordPicker.search"),
218
- placeholder: t("dataEntry.recordPicker.searchPlaceholder")
219
- }
220
- ),
221
- filters?.length ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "sm", wrap: true, role: "group", "aria-label": t("dataEntry.recordPicker.filters"), children: filters.map((f) => /* @__PURE__ */ jsx(
222
- Select,
223
- {
224
- size: "sm",
225
- "aria-label": f.label,
226
- placeholder: f.label,
227
- value: filterValues[f.name] ?? "",
228
- onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
229
- options: [
230
- { value: "", label: t("dataEntry.recordPicker.allFilter") },
231
- ...f.options
232
- ]
233
- },
234
- f.name
235
- )) }) : null,
236
- /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
237
- EmptyState,
238
- {
239
- variant: "compact",
240
- tone: "destructive",
241
- title: t("dataEntry.recordPicker.error"),
242
- action: /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => void load(query, filterValues), children: t("dataEntry.recordPicker.more") })
243
- }
244
- ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
245
- const rendered = items.map((o) => {
246
- const picked = (isMultiple ? draft : selected).includes(o.value);
247
- return /* @__PURE__ */ jsxs(
248
- "button",
423
+ open,
424
+ onOpenChange: (next) => {
425
+ setOpen(next);
426
+ if (!next) onDismiss?.();
427
+ },
428
+ children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
429
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
430
+ /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
431
+ /* @__PURE__ */ jsx(
432
+ Input,
249
433
  {
250
- type: "button",
251
- role: "option",
252
- "aria-selected": picked,
253
- "data-picked": picked ? "" : void 0,
254
- disabled: o.disabled,
255
- className: "ui-record-picker-option",
256
- onClick: () => toggle(o.value),
257
- children: [
258
- /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: picked ? /* @__PURE__ */ jsx(Check, {}) : null }),
259
- o.icon,
260
- /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
261
- o.label,
262
- o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
263
- ] })
264
- ]
265
- },
266
- o.value
267
- );
268
- });
269
- return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
270
- }) })
271
- ] }) }),
272
- isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
273
- /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
274
- /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setDraft([]), children: [
275
- /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
276
- t("dataEntry.recordPicker.clear")
277
- ] }),
278
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setOpen(false), children: t("dataEntry.recordPicker.cancel") }),
279
- /* @__PURE__ */ jsx(
280
- Button,
281
- {
282
- size: "sm",
283
- onClick: () => {
284
- commit(draft);
285
- setOpen(false);
286
- },
287
- children: t("dataEntry.recordPicker.confirm")
288
- }
289
- )
290
- ] }) : null
291
- ] }) })
434
+ autoFocus: true,
435
+ type: "search",
436
+ value: query,
437
+ onChange: (e) => setQuery(e.target.value),
438
+ "aria-label": t("dataEntry.recordPicker.search"),
439
+ placeholder: t("dataEntry.recordPicker.searchPlaceholder")
440
+ }
441
+ ),
442
+ filters?.length ? /* @__PURE__ */ jsx(
443
+ Flex,
444
+ {
445
+ direction: "row",
446
+ gap: "sm",
447
+ wrap: true,
448
+ role: "group",
449
+ "aria-label": t("dataEntry.recordPicker.filters"),
450
+ children: filters.map((f) => /* @__PURE__ */ jsx(
451
+ Select,
452
+ {
453
+ size: "sm",
454
+ "aria-label": f.label,
455
+ placeholder: f.label,
456
+ value: filterValues[f.name] ?? "",
457
+ onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
458
+ options: [
459
+ { value: "", label: t("dataEntry.recordPicker.allFilter") },
460
+ ...f.options
461
+ ]
462
+ },
463
+ f.name
464
+ ))
465
+ }
466
+ ) : null,
467
+ /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
468
+ EmptyState,
469
+ {
470
+ variant: "compact",
471
+ tone: "destructive",
472
+ title: t("dataEntry.recordPicker.error"),
473
+ action: /* @__PURE__ */ jsx(
474
+ Button,
475
+ {
476
+ variant: "outline",
477
+ size: "sm",
478
+ onClick: () => void load(query, filterValues),
479
+ children: t("dataEntry.recordPicker.more")
480
+ }
481
+ )
482
+ }
483
+ ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
484
+ const rendered = items.map((o) => {
485
+ const picked = (isMultiple ? draft : selected).includes(o.value);
486
+ return /* @__PURE__ */ jsxs(
487
+ "button",
488
+ {
489
+ type: "button",
490
+ role: "option",
491
+ "aria-selected": picked,
492
+ "data-picked": picked ? "" : void 0,
493
+ disabled: o.disabled,
494
+ className: "ui-record-picker-option",
495
+ onClick: () => toggle(o.value),
496
+ children: [
497
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: picked ? /* @__PURE__ */ jsx(Check, {}) : null }),
498
+ o.icon,
499
+ /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
500
+ o.label,
501
+ o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
502
+ ] })
503
+ ]
504
+ },
505
+ o.value
506
+ );
507
+ });
508
+ return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
509
+ }) }),
510
+ cursor !== void 0 && status !== "loading" ? /* @__PURE__ */ jsx(
511
+ Button,
512
+ {
513
+ variant: "outline",
514
+ size: "sm",
515
+ onClick: () => void load(query, filterValues, cursor),
516
+ children: t("dataEntry.recordPicker.more")
517
+ }
518
+ ) : null
519
+ ] }) }),
520
+ isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
521
+ /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
522
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setDraft([]), children: [
523
+ /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
524
+ t("dataEntry.recordPicker.clear")
525
+ ] }),
526
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setOpen(false), children: t("dataEntry.recordPicker.cancel") }),
527
+ /* @__PURE__ */ jsx(
528
+ Button,
529
+ {
530
+ size: "sm",
531
+ onClick: () => {
532
+ commit(draft);
533
+ setOpen(false);
534
+ },
535
+ children: t("dataEntry.recordPicker.confirm")
536
+ }
537
+ )
538
+ ] }) : null
539
+ ] })
540
+ }
541
+ )
292
542
  ] });
293
543
  });
294
544
  export {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "30.5.2",
3
+ "version": "30.6.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -206,7 +206,9 @@
206
206
  "cancel": "Cancel",
207
207
  "clear": "Clear selection",
208
208
  "more": "Load more",
209
- "openDialog": "Choose from list"
209
+ "openDialog": "Choose from list",
210
+ "openSearch": "Search",
211
+ "suggestions": "Suggestions"
210
212
  }
211
213
  },
212
214
  "feedback": {
@@ -202,7 +202,9 @@
202
202
  "cancel": "キャンセル",
203
203
  "clear": "選択を解除",
204
204
  "more": "さらに読み込む",
205
- "openDialog": "一覧から選択"
205
+ "openDialog": "一覧から選択",
206
+ "openSearch": "検索",
207
+ "suggestions": "候補"
206
208
  }
207
209
  },
208
210
  "feedback": {
@@ -203,7 +203,9 @@
203
203
  "cancel": "Huỷ",
204
204
  "clear": "Bỏ chọn",
205
205
  "more": "Tải thêm",
206
- "openDialog": "Chọn từ danh sách"
206
+ "openDialog": "Chọn từ danh sách",
207
+ "openSearch": "Tìm kiếm",
208
+ "suggestions": "Gợi ý"
207
209
  }
208
210
  },
209
211
  "feedback": {
@@ -1148,6 +1148,22 @@ export type RecordPickerFilterProp = {
1148
1148
  export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "defaultValue" | "onChange"> & {
1149
1149
  /** `single` (default) or `multiple`. Decides the value shape AND whether the dialog confirms. */
1150
1150
  mode?: "single" | "multiple";
1151
+ /**
1152
+ * HAI LỐI VÀO CÙNG LÚC, thay vì "dropdown HOẶC Dialog" (gh#944).
1153
+ *
1154
+ * `auto` (mặc định) giữ nguyên hành vi cũ: `threshold` quyết một trong hai.
1155
+ *
1156
+ * `inline` mở cả hai cùng lúc — ô gõ được có gợi ý ngay bên dưới BẤT KỂ `count`, và một nút
1157
+ * 「検索」 luôn hiện bên cạnh mở Dialog có filter + phân trang. Chữ đang gõ dở mang sang Dialog
1158
+ * làm từ khoá ban đầu.
1159
+ *
1160
+ * Vì sao nó tồn tại: consumer đo được dự án khách có vài trăm tới vài nghìn bản ghi, nên
1161
+ * `count > threshold` gần như LUÔN đúng — tức nhánh "chỉ Dialog" là nhánh người dùng gặp
1162
+ * thường xuyên nhất, và ở đó họ mất hẳn khả năng gõ. Ngưỡng trả lời đúng câu hỏi "tập lớn hay
1163
+ * nhỏ" nhưng câu hỏi thật là "người này đã biết mình tìm gì chưa": biết rồi thì gõ nhanh hơn
1164
+ * mở modal, chưa biết thì cần filter. `inline` không bắt chọn.
1165
+ */
1166
+ shape?: "auto" | "inline";
1151
1167
  value?: string | string[] | null;
1152
1168
  defaultValue?: string | string[] | null;
1153
1169
  /** Receives the shape you passed in: an array for `multiple`, a single value (or null) else. */
@@ -1186,6 +1202,12 @@ export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement
1186
1202
  placeholder?: string;
1187
1203
  dialogTitle?: string;
1188
1204
  size?: "xs" | "sm" | "md" | "lg";
1205
+ /**
1206
+ * Tên trường, do `FormField` truyền xuống. Khai tường minh vì CẢ HAI nhánh phải giữ nó: một
1207
+ * lỗi 422 của server bám theo `data-field`, và consumer đo được nhánh dropdown đánh rơi nó
1208
+ * trong khi nhánh Dialog thì giữ (gh#942).
1209
+ */
1210
+ "data-field"?: string;
1189
1211
  };
1190
1212
  export type SearchSelectLoadParamsProp = {
1191
1213
  query: string;
@@ -835,3 +835,32 @@
835
835
  font-size: var(--font-size-sm);
836
836
  }
837
837
  }
838
+
839
+ @layer components {
840
+
841
+ .ui-record-picker-row {
842
+ inline-size: 100%;
843
+ }
844
+ .ui-record-picker-row > .ui-record-picker-trigger {
845
+ flex: 1 1 auto;
846
+ min-inline-size: 0;
847
+ }
848
+ }
849
+
850
+ @layer components {
851
+
852
+ .ui-record-picker-inline {
853
+ inline-size: 100%;
854
+ }
855
+ .ui-record-picker-inline-input {
856
+ flex: 1 1 auto;
857
+ min-inline-size: 0;
858
+ }
859
+
860
+ .ui-record-picker-suggest {
861
+ max-block-size: var(--record-picker-suggest-max-block-size);
862
+ overflow-y: auto;
863
+ border: var(--stroke-hairline) solid hsl(var(--border));
864
+ border-radius: var(--radius-md);
865
+ }
866
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  --record-picker-list-max-block-size: 22rem;
6
6
 
7
+ --record-picker-suggest-max-block-size: 14rem;
8
+
7
9
  --password-strength-score-font-size: var(
8
10
  --font-size-xs,
9
11
  calc(var(--font-size-base) / var(--font-size-ratio))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.5.2",
4
- "godxUiMcp": "30.5.2",
3
+ "version": "30.6.0",
4
+ "godxUiMcp": "30.6.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -40,17 +40,53 @@ function changedFiles() {
40
40
  return r.status === 0 ? (r.stdout ?? "") : null;
41
41
  };
42
42
 
43
- const mergeBase = run(["merge-base", "HEAD", "origin/main"]);
43
+ /*
44
+ * `origin/main` WAS HARD-CODED, AND MOST REPOS THAT RUN THIS DO NOT DEFAULT TO main (gh#948).
45
+ *
46
+ * In a repo whose default branch is `dev`, `origin/main` usually still EXISTS — stale, or
47
+ * forked long ago — so `merge-base` resolves happily to an ancient commit and the "what this
48
+ * branch changed" set quietly becomes "everything since that commit". Measured in
49
+ * godx-corebooks: a fresh branch off `origin/dev` with ZERO edits audited ~200 files and
50
+ * reported 1114 errors. Nothing was wrong with the branch; the question was.
51
+ *
52
+ * That is the worst shape a scope bug can take — not a crash, a plausible number. It blocked
53
+ * corebooks#114 (the ratchet gate) and #86, because a ratchet cannot start from a baseline
54
+ * nobody believes.
55
+ *
56
+ * Order: an explicit `--base=<ref>` wins; otherwise `origin/HEAD`, which is the symbolic ref
57
+ * git keeps for the remote's OWN default branch and is therefore right in every repo without
58
+ * naming one; `origin/main` stays last so nothing that works today stops working.
59
+ *
60
+ * Fail-closed is unchanged (gh#542): if no candidate resolves, this returns an error rather
61
+ * than an empty file list. "Could not work out the base" must never read as "nothing changed".
62
+ */
63
+ const explicitBase = args.find((a) => a.startsWith("--base="))?.slice("--base=".length);
64
+ const candidates = explicitBase ? [explicitBase] : ["origin/HEAD", "origin/main"];
65
+
66
+ let mergeBase = null;
67
+ let baseUsed = null;
68
+ for (const candidate of candidates) {
69
+ const out = run(["merge-base", "HEAD", candidate]);
70
+ if (out !== null) {
71
+ mergeBase = out;
72
+ baseUsed = candidate;
73
+ break;
74
+ }
75
+ }
76
+
44
77
  if (mergeBase === null) {
45
78
  return {
46
79
  error:
47
- "ui-audit --changed could not resolve `git merge-base HEAD origin/main`. " +
80
+ `ui-audit --changed could not resolve a base (tried: ${candidates.join(", ")}). ` +
48
81
  "Without a base there is no such thing as \u201cwhat this branch changed\u201d, and reporting a " +
49
- "clean audit from that is not a result. Fetch origin/main (a shallow clone may need " +
50
- "`git fetch --unshallow`), or pass the directories to scan instead of `--changed`.",
82
+ "clean audit from that is not a result. Fetch the default branch (a shallow clone may need " +
83
+ "`git fetch --unshallow`), pass `--base=<ref>`, or pass the directories to scan instead of " +
84
+ "`--changed`.",
51
85
  };
52
86
  }
53
87
 
88
+
89
+
54
90
  const parts = [
55
91
  run(["diff", "--name-only", "--diff-filter=ACMR", mergeBase.trim(), "--"]),
56
92
  run(["diff", "--name-only", "--diff-filter=ACMR", "--cached"]),
@@ -64,6 +100,10 @@ function changedFiles() {
64
100
  }
65
101
 
66
102
  return {
103
+ // Đi kèm danh sách file, và được in ra: một base SAI không làm chương trình chết, nó cho ra
104
+ // một con số hợp lý (1114 lỗi trên nhánh chưa sửa gì). Cách duy nhất để người đọc phát hiện
105
+ // là thấy nó đã so với cái gì.
106
+ base: baseUsed,
67
107
  files: [
68
108
  ...new Set(
69
109
  parts
@@ -96,13 +136,55 @@ if (changed?.error) {
96
136
  }
97
137
  process.exit(2);
98
138
  }
139
+ /*
140
+ * ONE TERRITORY, READ BY BOTH PATHS (gh#949).
141
+ *
142
+ * The scan roots used to be spelled inline here, so only the DEFAULT path knew them and
143
+ * `--changed` scanned whatever git happened to name. On the same commit, same working tree:
144
+ *
145
+ * pnpm run audit -> 0 error
146
+ * node scripts/ui-audit.mjs --changed -> 3 error
147
+ *
148
+ * All three were in `mcp/src/data/components.ts`, and all three were the catalog's own PROSE —
149
+ * `Text`'s description quotes `<span className="text-[13px]">` as the anti-example the component
150
+ * exists to replace. A guard that reports its own teaching material is a guard people switch off.
151
+ *
152
+ * The fix is NOT to strip backtick spans from the corpus, which was the first idea: in a `.tsx`
153
+ * a backtick is a TEMPLATE LITERAL, i.e. real code — `className={`text-[13px]`}` appears in this
154
+ * repo today, and blinding the audit to it would trade three false positives for real misses.
155
+ *
156
+ * Nor is it a per-directory exclusion. The audit's subject is UI CODE, and in SELF mode this
157
+ * repo's UI code is `src/` and `docs/` — `mcp/src/data` renders nothing and holds no JSX (0 `.tsx`
158
+ * files, measured). So the roots are the territory, and `--changed` now narrows to it rather than
159
+ * having its own opinion. Add a root here and BOTH paths gain it; that is the property the split
160
+ * spelling could not have.
161
+ *
162
+ * CONSUMER mode is deliberately NOT narrowed. There the roots are a GUESS at a Laravel layout,
163
+ * and an app keeping its components somewhere else would get "nothing scanned" from a filter that
164
+ * is only right about this repo. `--changed` seeing the real files is strictly better there.
165
+ */
166
+ const SELF_SCAN_ROOTS = ["src", "docs"];
167
+ const CONSUMER_SCAN_ROOTS = [
168
+ "resources/js/components",
169
+ "resources/js/pages",
170
+ "resources/js/layouts",
171
+ ];
172
+ const withinSelfRoots = (file) =>
173
+ SELF_SCAN_ROOTS.some((root) => file === root || file.startsWith(`${root}/`));
174
+
175
+ /** Named so the summary can say how many files `--changed` handed over and the roots declined. */
176
+ const changedOutsideRoots =
177
+ CHANGED && SELF && !dirArgs.length ? changed.files.filter((f) => !withinSelfRoots(f)) : [];
178
+
99
179
  const SCAN_DIRS = CHANGED
100
- ? changed.files
180
+ ? SELF && !dirArgs.length
181
+ ? changed.files.filter(withinSelfRoots)
182
+ : changed.files
101
183
  : dirArgs.length
102
184
  ? dirArgs
103
185
  : SELF
104
- ? ["src", "docs"]
105
- : ["resources/js/components", "resources/js/pages", "resources/js/layouts"];
186
+ ? SELF_SCAN_ROOTS
187
+ : CONSUMER_SCAN_ROOTS;
106
188
 
107
189
  const PALETTE =
108
190
  "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|gray|grey|slate|zinc|neutral|stone";
@@ -1850,7 +1932,15 @@ if (changedNoFiles && findings.length === 0) {
1850
1932
  JSON.stringify({ summary: { errors: 0, warnings: 0 }, findings: [] }, null, 2) + "\n",
1851
1933
  );
1852
1934
  } else if (!quiet) {
1853
- console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1935
+ // "Nothing changed" and "everything that changed is outside the territory" are two
1936
+ // different facts, and reading the first when the second is true is how someone concludes
1937
+ // their file was audited (gh#949).
1938
+ console.log(
1939
+ changedOutsideRoots.length > 0
1940
+ ? `✓ ui-audit --changed: nhánh này không đổi file .tsx/.jsx nào trong ` +
1941
+ `[${SELF_SCAN_ROOTS.join(", ")}] (${changedOutsideRoots.length} file đổi ở ngoài đó).`
1942
+ : "✓ ui-audit --changed: no .tsx/.jsx changed on this branch.",
1943
+ );
1854
1944
  }
1855
1945
  process.exit(0);
1856
1946
  }
@@ -1899,6 +1989,19 @@ if (filesScanned === 0 && !changedNoFiles) {
1899
1989
  if (f.standard) console.log(` ${C.dim}standard: ${f.standard}${C.reset}`);
1900
1990
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
1901
1991
  }
1992
+ // Dưới `--changed`, NÓI RA base đã so. Một base sai không làm chương trình chết — nó cho ra một
1993
+ // con số hợp lý, và đó là thứ duy nhất người đọc có thể dùng để nghi ngờ (gh#948).
1994
+ if (CHANGED && changed?.base) {
1995
+ console.log(`${C.dim}so với: ${changed.base}${C.reset}`);
1996
+ }
1997
+ // Bỏ file trong im lặng là cách một gate thu hẹp dần mà không ai thấy (cùng lý do với dòng
1998
+ // `so với:` ngay trên). Nói ra số file và lãnh thổ đã dùng.
1999
+ if (changedOutsideRoots.length > 0 && !quiet) {
2000
+ console.log(
2001
+ `${C.dim}ngoài lãnh thổ [${SELF_SCAN_ROOTS.join(", ")}], không quét: ` +
2002
+ `${changedOutsideRoots.length} file${C.reset}`,
2003
+ );
2004
+ }
1902
2005
  console.log(
1903
2006
  `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}` +
1904
2007
  (scannedFiles.length > 0