@godxjp/ui 26.1.0 → 26.3.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 (34) hide show
  1. package/dist/components/data-entry/chat-composer.d.ts +1 -0
  2. package/dist/components/data-entry/chat-composer.js +12 -5
  3. package/dist/components/query/mutation-feedback.d.ts +3 -1
  4. package/dist/components/query/mutation-feedback.js +10 -0
  5. package/dist/contracts/measurement.json +1 -1
  6. package/dist/i18n/messages/en.json +2 -1
  7. package/dist/i18n/messages/ja.json +2 -1
  8. package/dist/i18n/messages/vi.json +2 -1
  9. package/dist/lib/platform.d.ts +10 -0
  10. package/dist/lib/platform.js +9 -0
  11. package/dist/lib/utils.d.ts +1 -0
  12. package/dist/lib/utils.js +3 -1
  13. package/dist/props/components/data-entry.prop.d.ts +30 -11
  14. package/dist/props/components/query.prop.d.ts +7 -0
  15. package/dist/props/registry.d.ts +4 -0
  16. package/dist/props/registry.js +5 -0
  17. package/dist/styles/control.css +8 -8
  18. package/dist/styles/float-button-layout.css +1 -1
  19. package/dist/styles/focus-ring.css +1 -1
  20. package/dist/styles/form-layout.css +31 -0
  21. package/dist/styles/layout.css +8 -1
  22. package/dist/styles/shell-layout.css +5 -5
  23. package/dist/tokens/components/control.css +7 -6
  24. package/dist/tokens/components/float-button.css +1 -1
  25. package/dist/tokens/components/segmented.css +1 -1
  26. package/dist/tokens/components/shell.css +4 -4
  27. package/dist/tokens/foundation.css +4 -7
  28. package/docs/CUSTOMER-THEMING.md +4 -0
  29. package/docs/DESIGN-AUTHORITY.md +9 -0
  30. package/docs/data-entry/chat-composer.tsx +101 -1
  31. package/docs/query/mutation-feedback.tsx +59 -1
  32. package/package.json +2 -2
  33. package/scripts/_agent-setup.mjs +55 -10
  34. package/scripts/cli.mjs +63 -2
@@ -31,6 +31,7 @@ export declare const ChatComposer: React.ForwardRefExoticComponent<Omit<React.HT
31
31
  onCancel?: () => void;
32
32
  loading?: import("../../props/index.js").PendingProp;
33
33
  submitType?: import("./chat-composer.js").ChatComposerSubmitTypeProp;
34
+ allowEmptySubmit?: boolean;
34
35
  placeholder?: import("../../props/index.js").PlaceholderProp;
35
36
  disabled?: import("../../props/index.js").DisabledProp;
36
37
  readOnly?: boolean;
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { SendHorizontal, Square } from "lucide-react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { isApplePlatform } from "../../lib/platform.js";
7
8
  import { omitFieldA11y, pickFieldA11y, useFieldIdentity } from "../../lib/field-a11y.js";
8
9
  import { Button } from "../general/button.js";
9
10
  import { Textarea } from "./textarea.js";
@@ -23,6 +24,7 @@ const ChatComposer = React.forwardRef(
23
24
  onCancel,
24
25
  loading = false,
25
26
  submitType = "enter",
27
+ allowEmptySubmit = false,
26
28
  placeholder,
27
29
  disabled = false,
28
30
  readOnly = false,
@@ -57,12 +59,13 @@ const ChatComposer = React.forwardRef(
57
59
  const rest = omitFieldA11y(props);
58
60
  const identity = useFieldIdentity({ id, name, "data-field": props["data-field"] });
59
61
  const surface = controlSurfaceAttrs({ status, size });
60
- const canSubmit = isSendable(draft) && !disabled && !readOnly && !loading;
62
+ const canSubmit = (allowEmptySubmit || isSendable(draft)) && !disabled && !readOnly && !loading;
61
63
  const submit = React.useCallback(() => {
64
+ if (disabled || readOnly || loading) return;
62
65
  const text = innerRef.current?.value ?? draft;
63
- if (!isSendable(text) || disabled || readOnly || loading) return;
64
- onSubmit?.(text);
65
- }, [draft, disabled, readOnly, loading, onSubmit]);
66
+ if (isSendable(text)) onSubmit?.(text);
67
+ else if (allowEmptySubmit) onSubmit?.("");
68
+ }, [draft, disabled, readOnly, loading, allowEmptySubmit, onSubmit]);
66
69
  const handleKeyDown = (event) => {
67
70
  onKeyDown?.(event);
68
71
  if (event.defaultPrevented) return;
@@ -70,7 +73,11 @@ const ChatComposer = React.forwardRef(
70
73
  if (composing.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) {
71
74
  return;
72
75
  }
73
- const wantsSend = submitType === "enter" ? !event.shiftKey : event.shiftKey;
76
+ const wantsSend = submitType === "modEnter" ? (
77
+ // ⌘ on Apple platforms, Ctrl everywhere else — the other one is left alone, because
78
+ // Ctrl+Enter on a Mac is not the convention a Mac user reaches for.
79
+ isApplePlatform() ? event.metaKey : event.ctrlKey
80
+ ) : submitType === "enter" ? !event.shiftKey : event.shiftKey;
74
81
  if (!wantsSend) return;
75
82
  event.preventDefault();
76
83
  submit();
@@ -3,5 +3,7 @@ export type { AlertMutationFeedbackProp, AlertMutationFeedbackProp as AlertMutat
3
3
  /**
4
4
  * Inline mutation error — renders nothing when idle/success.
5
5
  * Prefer toast for transient saves; use this for blocking form sections (SimulatorPage).
6
+ * Inside a form that received a server error bag (`FormRoot errors` / `Form errors`), a validation
7
+ * error is the fields' to show, so it is skipped by default (gh#690).
6
8
  */
7
- export declare function AlertMutationFeedback({ mutation, onRetry, showRetry, pending, className, }: AlertMutationFeedbackProp): import("react").JSX.Element | null;
9
+ export declare function AlertMutationFeedback({ mutation, onRetry, showRetry, pending, ignoreValidationErrors, className, }: AlertMutationFeedbackProp): import("react").JSX.Element | null;
@@ -1,15 +1,25 @@
1
1
  "use client";
2
2
  import { Fragment, jsx } from "react/jsx-runtime";
3
3
  import { AlertQueryError } from "../feedback/alert.js";
4
+ import { useFormErrorsRegistry } from "../data-entry/form-errors.js";
5
+ import { classifyQueryError } from "../../lib/query-error.js";
4
6
  function AlertMutationFeedback({
5
7
  mutation,
6
8
  onRetry,
7
9
  showRetry = true,
8
10
  pending,
11
+ ignoreValidationErrors,
9
12
  className
10
13
  }) {
14
+ const registry = useFormErrorsRegistry();
15
+ const bagShowsErrors = registry !== null && Object.values(registry.errors).some(
16
+ (entry) => Array.isArray(entry) ? entry.some(Boolean) : Boolean(entry)
17
+ );
11
18
  if (mutation.isPending && pending) return /* @__PURE__ */ jsx(Fragment, { children: pending });
12
19
  if (!mutation.isError || mutation.error == null) return null;
20
+ if ((ignoreValidationErrors ?? bagShowsErrors) && classifyQueryError(mutation.error).category === "validation") {
21
+ return null;
22
+ }
13
23
  return /* @__PURE__ */ jsx(
14
24
  AlertQueryError,
15
25
  {
@@ -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": "26.1.0",
3
+ "version": "26.3.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,
@@ -169,7 +169,8 @@
169
169
  "send": "Send message",
170
170
  "cancel": "Stop generating",
171
171
  "hintEnter": "Enter to send · Shift + Enter for a new line",
172
- "hintShiftEnter": "Shift + Enter to send · Enter for a new line"
172
+ "hintShiftEnter": "Shift + Enter to send · Enter for a new line",
173
+ "hintModEnter": "{modifier} + Enter to send · Enter for a new line"
173
174
  },
174
175
  "chatSuggestion": {
175
176
  "label": "Suggestions",
@@ -166,7 +166,8 @@
166
166
  "send": "メッセージを送信",
167
167
  "cancel": "生成を停止",
168
168
  "hintEnter": "Enter で送信 · Shift + Enter で改行",
169
- "hintShiftEnter": "Shift + Enter で送信 · Enter で改行"
169
+ "hintShiftEnter": "Shift + Enter で送信 · Enter で改行",
170
+ "hintModEnter": "{modifier} + Enter で送信 · Enter で改行"
170
171
  },
171
172
  "chatSuggestion": {
172
173
  "label": "候補",
@@ -166,7 +166,8 @@
166
166
  "send": "Gửi tin nhắn",
167
167
  "cancel": "Dừng tạo câu trả lời",
168
168
  "hintEnter": "Enter để gửi · Shift + Enter để xuống dòng",
169
- "hintShiftEnter": "Shift + Enter để gửi · Enter để xuống dòng"
169
+ "hintShiftEnter": "Shift + Enter để gửi · Enter để xuống dòng",
170
+ "hintModEnter": "{modifier} + Enter để gửi · Enter để xuống dòng"
170
171
  },
171
172
  "chatSuggestion": {
172
173
  "label": "Gợi ý",
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the user agent runs on an Apple platform (macOS, iOS, iPadOS), where the primary
3
+ * shortcut modifier is ⌘ (`metaKey`) rather than Ctrl (`ctrlKey`).
4
+ *
5
+ * `navigator.userAgentData.platform` is the standards answer (Chromium); `navigator.platform` is
6
+ * the deprecated but universally present fallback (Safari and Firefox still report `MacIntel` /
7
+ * `iPhone` / `iPad` there). Outside a browser there is no platform, so the answer is `false` — a
8
+ * server render therefore describes the Ctrl shortcut.
9
+ */
10
+ export declare function isApplePlatform(): boolean;
@@ -0,0 +1,9 @@
1
+ function isApplePlatform() {
2
+ if (typeof navigator === "undefined") return false;
3
+ const data = navigator.userAgentData;
4
+ const platform = data?.platform || navigator.platform || "";
5
+ return /mac|iphone|ipad|ipod/i.test(platform);
6
+ }
7
+ export {
8
+ isApplePlatform
9
+ };
@@ -1,2 +1,3 @@
1
1
  import { type ClassValue } from "clsx";
2
2
  export declare function cn(...inputs: ClassValue[]): string;
3
+ export { isApplePlatform } from "./platform.js";
package/dist/lib/utils.js CHANGED
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
3
3
  function cn(...inputs) {
4
4
  return twMerge(clsx(inputs));
5
5
  }
6
+ import { isApplePlatform } from "./platform.js";
6
7
  export {
7
- cn
8
+ cn,
9
+ isApplePlatform
8
10
  };
@@ -294,9 +294,9 @@ export type FormFieldProp = {
294
294
  *
295
295
  * `before` is for a helper the reader needs BEFORE they answer rather than after: the
296
296
  * secondary language of a bilingual form, a unit or format note, a pick-one-of-these
297
- * preamble. `labelAddon` cannot carry that — it is an inline row beside the label with no
298
- * wrap, sized for a chip or a help button, so a full sentence squeezes the label instead of
299
- * taking its own line. Putting the second line inside `label` does work, but costs the
297
+ * preamble. `labelAddon` cannot carry that — it belongs to the label row, sized for a chip, a
298
+ * help button or a short text action; in a horizontal/inline field it wraps under the label
299
+ * inside the label column, so a full sentence stacks there instead of above the input. Putting the second line inside `label` does work, but costs the
300
300
  * string-label fallbacks (`aria-label`, `FieldNameContext`), which fire only when `label` is
301
301
  * a plain string.
302
302
  *
@@ -308,7 +308,11 @@ export type FormFieldProp = {
308
308
  validateStatus?: "success" | "warning" | "error" | "validating";
309
309
  hasFeedback?: boolean;
310
310
  feedback?: React.ReactNode;
311
- /** Optional control rendered inline after the label (e.g. a help button). */
311
+ /**
312
+ * Optional control rendered after the label (e.g. a help button, a short text action). In a
313
+ * horizontal/inline field the label row wraps: an addon that does not fit beside the label
314
+ * drops to its own line under it, capped to the label column, never into the control column.
315
+ */
312
316
  labelAddon?: React.ReactNode;
313
317
  /** Override the Form's layout for this field only. */
314
318
  layout?: FormLayoutProp;
@@ -341,9 +345,9 @@ export type FormFieldProp = {
341
345
  *
342
346
  * `before` is for a helper the reader needs BEFORE they answer rather than after: the
343
347
  * secondary language of a bilingual form, a unit or format note, a pick-one-of-these
344
- * preamble. `labelAddon` cannot carry that — it is an inline row beside the label with no
345
- * wrap, sized for a chip or a help button, so a full sentence squeezes the label instead of
346
- * taking its own line. Putting the second line inside `label` does work, but costs the
348
+ * preamble. `labelAddon` cannot carry that — it belongs to the label row, sized for a chip, a
349
+ * help button or a short text action; in a horizontal/inline field it wraps under the label
350
+ * inside the label column, so a full sentence stacks there instead of above the input. Putting the second line inside `label` does work, but costs the
347
351
  * string-label fallbacks (`aria-label`, `FieldNameContext`), which fire only when `label` is
348
352
  * a plain string.
349
353
  *
@@ -355,7 +359,11 @@ export type FormFieldProp = {
355
359
  validateStatus?: "success" | "warning" | "error" | "validating";
356
360
  hasFeedback?: boolean;
357
361
  feedback?: React.ReactNode;
358
- /** Optional control rendered inline after the label (e.g. a help button). */
362
+ /**
363
+ * Optional control rendered after the label (e.g. a help button, a short text action). In a
364
+ * horizontal/inline field the label row wraps: an addon that does not fit beside the label
365
+ * drops to its own line under it, capped to the label column, never into the control column.
366
+ */
359
367
  labelAddon?: React.ReactNode;
360
368
  /** Override the Form's layout for this field only. */
361
369
  layout?: FormLayoutProp;
@@ -1774,10 +1782,13 @@ export type BranchScopePickerProp = FieldA11yProps & {
1774
1782
  *
1775
1783
  * `enter` (default) is the chat convention: `Enter` sends, `Shift+Enter` inserts a newline.
1776
1784
  * `shiftEnter` is the inverse, for composers that hold long, deliberately multi-line drafts.
1777
- * Neither ever fires while an IME conversion is in flight.
1785
+ * `modEnter` is this library's extension (antd X has only the first two; see
1786
+ * docs/DESIGN-AUTHORITY.md): `⌘+Enter` on Apple platforms, `Ctrl+Enter` elsewhere sends, while
1787
+ * `Enter` and `Shift+Enter` both insert a newline — the record-comment convention.
1788
+ * None of them ever fires while an IME conversion is in flight.
1778
1789
  * @see ChatComposer
1779
1790
  */
1780
- export type ChatComposerSubmitTypeProp = "enter" | "shiftEnter";
1791
+ export type ChatComposerSubmitTypeProp = "enter" | "shiftEnter" | "modEnter";
1781
1792
  /**
1782
1793
  * @see ChatComposer — the message input of a conversation (Ant Design X `Sender`; the industry
1783
1794
  * calls the control a *composer*, so that is what it is named).
@@ -1797,7 +1808,8 @@ export type ChatComposerProp = Omit<React.HTMLAttributes<HTMLDivElement>, "onSub
1797
1808
  onValueChange?: OnValueChangeProp<string>;
1798
1809
  /**
1799
1810
  * Send the draft. Receives the text as typed; never fires for an empty or whitespace-only
1800
- * draft, and never while `loading`, `disabled` or `readOnly`.
1811
+ * draft (unless `allowEmptySubmit`, which then passes `""`), and never while `loading`,
1812
+ * `disabled` or `readOnly`.
1801
1813
  */
1802
1814
  onSubmit?: (value: string) => void;
1803
1815
  /** Stop the in-flight response. Only reachable while `loading`. */
@@ -1809,6 +1821,13 @@ export type ChatComposerProp = Omit<React.HTMLAttributes<HTMLDivElement>, "onSub
1809
1821
  loading?: PendingProp;
1810
1822
  /** Which keystroke sends and which breaks the line. Default `enter`. */
1811
1823
  submitType?: ChatComposerSubmitTypeProp;
1824
+ /**
1825
+ * Let an empty or whitespace-only draft be sent — for a composer whose `header`/`footer` carry
1826
+ * payload of their own (a status change on a record). The send button stays enabled and both
1827
+ * the button and the keyboard submit call `onSubmit("")`. Still blocked while `loading`,
1828
+ * `disabled` or `readOnly`. Default `false`.
1829
+ */
1830
+ allowEmptySubmit?: boolean;
1812
1831
  /** Empty-state text of the draft box; pass it through `t()` at the call site. */
1813
1832
  placeholder?: PlaceholderProp;
1814
1833
  /** Disable the whole composer (draft box and every action). */
@@ -31,6 +31,13 @@ export type AlertMutationFeedbackProp = {
31
31
  showRetry?: boolean;
32
32
  /** Optional inline pending slot while `mutation.isPending`. */
33
33
  pending?: React.ReactNode;
34
+ /**
35
+ * Skip rendering when the error classifies as a validation error (`classifyQueryError` category
36
+ * `"validation"`: 400/422). `true` skips every such error. When omitted, the alert is skipped only
37
+ * inside a `FormRoot`/`Form` whose `errors` bag holds at least one message (the fields show it;
38
+ * `FormErrors` shows unclaimed keys); an empty/absent bag still renders the alert.
39
+ */
40
+ ignoreValidationErrors?: boolean;
34
41
  className?: ClassNameProp;
35
42
  };
36
43
  type QueryRefetchLike = Pick<UseQueryResult<unknown>, "isFetching" | "refetch">;
@@ -2048,6 +2048,10 @@ export declare const COMPONENT_PROP_REGISTRY: {
2048
2048
  readonly field: "pending";
2049
2049
  readonly local: true;
2050
2050
  readonly reason: "Inline pending ReactNode slot, not boolean PendingProp state.";
2051
+ }, {
2052
+ readonly field: "ignoreValidationErrors";
2053
+ readonly local: true;
2054
+ readonly reason: "Query-error category filter; defaults on inside a Form whose error bag holds messages.";
2051
2055
  }, "ClassNameProp"];
2052
2056
  };
2053
2057
  readonly ButtonRefetchProp: {
@@ -2401,6 +2401,11 @@ const COMPONENT_PROP_REGISTRY = {
2401
2401
  local: true,
2402
2402
  reason: "Inline pending ReactNode slot, not boolean PendingProp state."
2403
2403
  },
2404
+ {
2405
+ field: "ignoreValidationErrors",
2406
+ local: true,
2407
+ reason: "Query-error category filter; defaults on inside a Form whose error bag holds messages."
2408
+ },
2404
2409
  "ClassNameProp"
2405
2410
  ]
2406
2411
  },
@@ -1119,7 +1119,7 @@
1119
1119
  }
1120
1120
 
1121
1121
  .ui-segmented-item:not([data-state="checked"]):not([data-disabled]):hover {
1122
- background: hsl(var(--segmented-item-hover-background));
1122
+ background: hsl(var(--segmented-item-hover-background, var(--accent)));
1123
1123
  color: hsl(var(--segmented-item-hover-color));
1124
1124
  }
1125
1125
  .ui-segmented-item:not([data-state="checked"]):not([data-disabled]):active {
@@ -2722,7 +2722,7 @@
2722
2722
  }
2723
2723
 
2724
2724
  .ui-control--filled:hover:not(:disabled):not([readonly]):not([data-disabled]) {
2725
- background-color: hsl(var(--control-variant-filled-hover-background));
2725
+ background-color: hsl(var(--control-variant-filled-hover-background, var(--accent)));
2726
2726
  }
2727
2727
 
2728
2728
  .ui-control--borderless {
@@ -2939,7 +2939,7 @@
2939
2939
  }
2940
2940
 
2941
2941
  .ui-slider-dot[data-active="true"] {
2942
- border-color: hsl(var(--slider-dot-active-border-color));
2942
+ border-color: hsl(var(--slider-dot-active-border-color, var(--primary)));
2943
2943
  }
2944
2944
 
2945
2945
  .ui-slider-tooltip {
@@ -3053,14 +3053,14 @@
3053
3053
  }
3054
3054
 
3055
3055
  .ui-radio-button[data-state="checked"] {
3056
- color: hsl(var(--choice-button-selected-color));
3057
- border-color: hsl(var(--choice-button-selected-border-color));
3056
+ color: hsl(var(--choice-button-selected-color, var(--primary)));
3057
+ border-color: hsl(var(--choice-button-selected-border-color, var(--primary)));
3058
3058
  }
3059
3059
 
3060
3060
  .ui-radio-button-bar[data-button-style="solid"] .ui-radio-button[data-state="checked"] {
3061
- color: hsl(var(--choice-button-solid-color));
3062
- background: hsl(var(--choice-button-solid-background));
3063
- border-color: hsl(var(--choice-button-solid-background));
3061
+ color: hsl(var(--choice-button-solid-color, var(--primary-foreground)));
3062
+ background: hsl(var(--choice-button-solid-background, var(--primary)));
3063
+ border-color: hsl(var(--choice-button-solid-background, var(--primary)));
3064
3064
  }
3065
3065
 
3066
3066
  .ui-radio-button[data-disabled] {
@@ -143,7 +143,7 @@
143
143
  content: "";
144
144
  inset: 0;
145
145
  background: conic-gradient(
146
- var(--float-button-progress-color) var(--float-button-progress-offset),
146
+ var(--float-button-progress-color, hsl(var(--primary))) var(--float-button-progress-offset),
147
147
  var(--float-button-progress-track-color) var(--float-button-progress-offset)
148
148
  );
149
149
  mask: radial-gradient(
@@ -53,7 +53,7 @@
53
53
  .ui-otp-slot[data-active="true"],
54
54
  .ui-otp-group[data-appearance="grouped"]:has(.ui-otp-slot[data-active="true"]) {
55
55
  outline: var(--focus-ring-width) solid
56
- hsl(var(--focus-outline-color) / var(--focus-ring-opacity, 1));
56
+ hsl(var(--focus-outline-color, var(--focus-ring-color, var(--ring))) / var(--focus-ring-opacity, 1));
57
57
  outline-offset: var(--focus-ring-offset);
58
58
 
59
59
  transition:
@@ -99,9 +99,16 @@
99
99
  column-gap: var(--form-label-gap);
100
100
  row-gap: var(--space-stack-xs);
101
101
  }
102
+
102
103
  .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
103
104
  > .ui-form-field-label {
104
105
  min-block-size: var(--control-height);
106
+ flex-wrap: wrap;
107
+ }
108
+ .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
109
+ > .ui-form-field-label
110
+ > * {
111
+ max-inline-size: 100%;
105
112
  }
106
113
  .ui-form-field[data-collapse-below="false"][data-label-align="end"]:is(
107
114
  [data-layout="horizontal"],
@@ -125,6 +132,12 @@
125
132
  .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
126
133
  > .ui-form-field-label {
127
134
  min-block-size: var(--control-height);
135
+ flex-wrap: wrap;
136
+ }
137
+ .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
138
+ > .ui-form-field-label
139
+ > * {
140
+ max-inline-size: 100%;
128
141
  }
129
142
  .ui-form-field[data-collapse-below="sm"][data-label-align="end"]:is(
130
143
  [data-layout="horizontal"],
@@ -149,6 +162,12 @@
149
162
  .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
150
163
  > .ui-form-field-label {
151
164
  min-block-size: var(--control-height);
165
+ flex-wrap: wrap;
166
+ }
167
+ .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
168
+ > .ui-form-field-label
169
+ > * {
170
+ max-inline-size: 100%;
152
171
  }
153
172
  .ui-form-field[data-collapse-below="md"][data-label-align="end"]:is(
154
173
  [data-layout="horizontal"],
@@ -173,6 +192,12 @@
173
192
  .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
174
193
  > .ui-form-field-label {
175
194
  min-block-size: var(--control-height);
195
+ flex-wrap: wrap;
196
+ }
197
+ .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
198
+ > .ui-form-field-label
199
+ > * {
200
+ max-inline-size: 100%;
176
201
  }
177
202
  .ui-form-field[data-collapse-below="lg"][data-label-align="end"]:is(
178
203
  [data-layout="horizontal"],
@@ -197,6 +222,12 @@
197
222
  .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
198
223
  > .ui-form-field-label {
199
224
  min-block-size: var(--control-height);
225
+ flex-wrap: wrap;
226
+ }
227
+ .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
228
+ > .ui-form-field-label
229
+ > * {
230
+ max-inline-size: 100%;
200
231
  }
201
232
  .ui-form-field[data-collapse-below="xl"][data-label-align="end"]:is(
202
233
  [data-layout="horizontal"],
@@ -96,7 +96,14 @@
96
96
  }
97
97
 
98
98
  .ui-brand-glow {
99
- background-image: var(--brand-glow);
99
+ background-image: var(
100
+ --brand-glow,
101
+ radial-gradient(
102
+ ellipse var(--brand-glow-size) at var(--brand-glow-position),
103
+ hsl(var(--brand-glow-color, var(--primary)) / var(--brand-glow-alpha)),
104
+ transparent 70%
105
+ )
106
+ );
100
107
  pointer-events: none;
101
108
  }
102
109
 
@@ -1434,8 +1434,8 @@
1434
1434
  }
1435
1435
 
1436
1436
  .ui-app-launcher-tile:hover {
1437
- background: hsl(var(--app-launcher-tile-hover-background));
1438
- color: hsl(var(--app-launcher-tile-hover-color));
1437
+ background: hsl(var(--app-launcher-tile-hover-background, var(--accent)));
1438
+ color: hsl(var(--app-launcher-tile-hover-color, var(--accent-foreground)));
1439
1439
  }
1440
1440
 
1441
1441
  .ui-app-launcher-tile[aria-current="page"] {
@@ -2040,14 +2040,14 @@
2040
2040
  }
2041
2041
 
2042
2042
  .ui-topbar-item:hover {
2043
- background: hsl(var(--topbar-item-hover-background));
2044
- color: hsl(var(--topbar-item-hover-color));
2043
+ background: hsl(var(--topbar-item-hover-background, var(--accent)));
2044
+ color: hsl(var(--topbar-item-hover-color, var(--accent-foreground)));
2045
2045
  }
2046
2046
 
2047
2047
  .ui-topbar-item:active,
2048
2048
  .ui-topbar-item[data-state="open"] {
2049
2049
  background: hsl(var(--topbar-item-active-background));
2050
- color: hsl(var(--topbar-item-hover-color));
2050
+ color: hsl(var(--topbar-item-hover-color, var(--accent-foreground)));
2051
2051
  }
2052
2052
 
2053
2053
  .ui-topbar-item:disabled,
@@ -35,7 +35,7 @@
35
35
  --number-input-touch-height: var(--band-height-2xl);
36
36
 
37
37
  --control-variant-filled-background: var(--muted);
38
- --control-variant-filled-hover-background: var(--accent);
38
+ --control-variant-filled-hover-background: initial;
39
39
 
40
40
  --control-count-font-size: var(--font-size-sm);
41
41
  --control-count-color: var(--muted-foreground);
@@ -58,7 +58,7 @@
58
58
  --slider-dot-size: var(--slider-track-height);
59
59
  --slider-dot-background: var(--background);
60
60
  --slider-dot-border-color: var(--border);
61
- --slider-dot-active-border-color: var(--primary);
61
+ --slider-dot-active-border-color: initial;
62
62
  --slider-tooltip-background: var(--popover);
63
63
  --slider-tooltip-color: var(--popover-foreground);
64
64
  --slider-tooltip-font-size: var(--font-size-xs);
@@ -69,10 +69,11 @@
69
69
  --choice-button-background: var(--background);
70
70
  --choice-button-color: var(--foreground);
71
71
  --choice-button-border-color: var(--input);
72
- --choice-button-selected-color: var(--primary);
73
- --choice-button-selected-border-color: var(--primary);
74
- --choice-button-solid-background: var(--primary);
75
- --choice-button-solid-color: var(--primary-foreground);
72
+
73
+ --choice-button-selected-color: initial;
74
+ --choice-button-selected-border-color: initial;
75
+ --choice-button-solid-background: initial;
76
+ --choice-button-solid-color: initial;
76
77
 
77
78
  --toggle-focus-ring-width: var(--stroke-lg);
78
79
  --toggle-focus-ring-alpha: 1;
@@ -32,7 +32,7 @@
32
32
 
33
33
  --float-button-progress-offset: 0turn;
34
34
  --float-button-progress-width: var(--space-1);
35
- --float-button-progress-color: hsl(var(--primary));
35
+ --float-button-progress-color: initial;
36
36
  --float-button-progress-track-color: hsl(var(--border));
37
37
 
38
38
  --float-button-motion-translate: var(--float-button-size);
@@ -17,7 +17,7 @@
17
17
  --segmented-item-hover-color: var(--foreground);
18
18
  --segmented-item-selected-color: var(--foreground);
19
19
 
20
- --segmented-item-hover-background: var(--accent);
20
+ --segmented-item-hover-background: initial;
21
21
  --segmented-item-active-background: var(--secondary);
22
22
 
23
23
  --segmented-item-selected-background: var(--background);
@@ -110,8 +110,8 @@
110
110
  --topbar-item-radius: var(--radius-sharp);
111
111
  --topbar-item-color: var(--muted-foreground);
112
112
 
113
- --topbar-item-hover-background: var(--accent);
114
- --topbar-item-hover-color: var(--accent-foreground);
113
+ --topbar-item-hover-background: initial;
114
+ --topbar-item-hover-color: initial;
115
115
  --topbar-item-active-background: var(--secondary);
116
116
 
117
117
  --topbar-item-badge-offset-block: var(--space-1);
@@ -158,8 +158,8 @@
158
158
  --app-launcher-tile-min-height: var(--control-height-comfortable);
159
159
  --app-launcher-tile-radius: var(--radius);
160
160
  --app-launcher-tile-color: var(--foreground);
161
- --app-launcher-tile-hover-background: var(--accent);
162
- --app-launcher-tile-hover-color: var(--accent-foreground);
161
+ --app-launcher-tile-hover-background: initial;
162
+ --app-launcher-tile-hover-color: initial;
163
163
 
164
164
  --app-launcher-tile-current-background: initial;
165
165
  --app-launcher-mark-size: 2.5rem;
@@ -100,19 +100,16 @@
100
100
 
101
101
  --shadow-glow: 0 0 0 0 transparent;
102
102
 
103
- --brand-glow-color: var(--primary);
103
+ --brand-glow-color: initial;
104
104
  --brand-glow-alpha: 0.18;
105
105
  --brand-glow-size: 60% 50%;
106
106
  --brand-glow-position: 50% 0%;
107
- --brand-glow: radial-gradient(
108
- ellipse var(--brand-glow-size) at var(--brand-glow-position),
109
- hsl(var(--brand-glow-color) / var(--brand-glow-alpha)),
110
- transparent 70%
111
- );
107
+ --brand-glow: initial;
112
108
 
113
109
  --focus-outline: 1;
114
110
  --focus-outline-weight: var(--stroke-hairline);
115
- --focus-outline-color: var(--focus-ring-color, var(--ring));
111
+
112
+ --focus-outline-color: initial;
116
113
 
117
114
  --control-outline-width: calc(var(--stroke-md) * var(--focus-outline));
118
115
  --focus-outline-offset: 0px;
@@ -243,6 +243,9 @@ The interaction states are **derived from the `--primary` in scope, at the eleme
243
243
  | `--control-outline` | **yes** — the focused field's halo (`--control-outline-alpha` stays per theme) | light `h s calc(l - 5.3)`, dark `h calc(s * 0.99) calc(l - 25.1)` |
244
244
  | `--primary-border` | **yes** (painted by no package surface; kept for the measurement in DESIGN-AUTHORITY) | light `h calc(s * 0.68) calc(l + 16)`, dark `h calc(s * 0.467) calc(l * 0.27043)` |
245
245
  | `--sidebar-item-active-foreground` | **yes** — defaults to the live `--primary-active` | — |
246
+ | `--focus-outline-color` | **yes, through `--ring`** — every keyboard-focus outline (gh#687) | `var(--focus-ring-color, var(--ring))`, read at the focused element — so it follows the `--ring` you set in the scope |
247
+ | Radio button bar selected (`--choice-button-*`), Slider active dot, BackTop progress, `.ui-brand-glow` | **yes** (gh#687) | `var(--primary)` / `var(--primary-foreground)` at the call site |
248
+ | Topbar item / AppLauncher tile / Segmented / filled-control hover | **follow `--accent`** (gh#687) | `var(--accent)` / `var(--accent-foreground)` at the call site |
246
249
  | `--primary-foreground` | **no — set it** | the label on a filled primary; you choose it for your seed |
247
250
  | `--ring` | **only on the element that declares `--primary`** — set it in a nested scope | `var(--primary)` on `:root` / `.dark`. `--ring` is a public role read as `hsl(var(--ring))` in consumer CSS, so it cannot become a live default; a scope below `<html>` inherits the root's |
248
251
  | `--destructive-*`, `--control-outline-error` | no — not brand | literals |
@@ -251,6 +254,7 @@ On the package seed these produce exactly the identity kit values the tier used
251
254
 
252
255
  **Rules that come with it:**
253
256
 
257
+ - **No token tier binds to a scoped role at `:root`.** Every knob whose default is `--primary`, `--primary-foreground`, `--ring`, `--accent` or `--accent-foreground` is `initial`, with the role as the call-site fallback — `--ring` itself is the one exception (above). `tenant-scope-freeze-687.test.ts` holds it.
254
258
  - **Override a state only by setting its knob** (`--primary-hover: 221 90% 72%`). A set knob wins over the derived default, in its own scope and below.
255
259
  - **Read a state through its fallback, not bare.** The four knobs are `initial` so that the default can resolve at the painting element (docs/TOKENS.md, the freeze rule). `hsl(var(--primary-hover))` on its own therefore paints nothing — use the utility (`bg-primary-hover`) or `hsl(var(--primary-hover, from hsl(var(--primary)) var(--primary-hover-channels)))`.
256
260
  - **Label polarity.** The theme's pair steps AWAY from that theme's default label: `darken` under a light label, `lighten` under a dark one, which keeps a label that clears 4.5:1 at rest at 4.5:1 in hover and pressed for every seed (measured, `derived-seed-sweep.test.ts`). If your seed needs the OTHER polarity of label (a pale yellow with dark text in the light theme), point the pair at it too — `applyPrimaryColor()` does this automatically from the label it picks:
@@ -257,6 +257,15 @@ in page CSS.
257
257
  `onClose`, because `onClose` already means overlay dismiss across Dialog/Drawer and would read
258
258
  as closing a surface, not removing one applied filter chip. Implemented on `Badge` — the DS chip
259
259
  primitive — rather than adding a separate `Tag` export beside `Badge`.
260
+ - **`ChatComposer.submitType="modEnter"` and `ChatComposer.allowEmptySubmit`.** Ant Design X
261
+ `Sender.submitType` is only `"enter" | "shiftEnter"`, and `Sender` never submits an empty draft.
262
+ A comment composer on a record needs the convention GitHub, Jira, Linear and GitLab share —
263
+ `Enter` breaks the line, `⌘+Enter` (Apple platforms, `metaKey`) / `Ctrl+Enter` (elsewhere,
264
+ `ctrlKey`) sends — and needs to post a status change with no text (gh#693). `"modEnter"` extends
265
+ antd's union rather than renaming it, so both antd values keep their meaning; its hint is
266
+ `dataEntry.chatComposer.hintModEnter` with `{modifier}` from `isApplePlatform()`
267
+ (`@godxjp/ui/lib/utils`). `allowEmptySubmit` (default `false`, antd's behaviour) sends `""`,
268
+ still never while `loading` / `disabled` / `readOnly`.
260
269
 
261
270
  **A knob that only a fork could reach is not parity either.** antd's `components`,
262
271
  `filterDropdown`, `classNames`/`styles` semantic maps and `prefixCls` all exist to let a consumer
@@ -14,9 +14,12 @@ import {
14
14
  ChatComposer,
15
15
  ChatSuggestion,
16
16
  FormField,
17
+ Select,
17
18
  type ChatSuggestionItemProp,
18
19
  } from "@godxjp/ui/data-entry";
19
20
  import { Button, Text } from "@godxjp/ui/general";
21
+ import { useTranslation } from "@godxjp/ui/i18n";
22
+ import { isApplePlatform } from "@godxjp/ui/lib/utils";
20
23
  import {
21
24
  AppShell,
22
25
  Flex,
@@ -34,7 +37,8 @@ import { Bot, MessageSquare, Paperclip, Settings, Smile, Sparkles, Users } from
34
37
  *
35
38
  * The first card is the real screen: a live assistant transcript whose composer sends, streams and
36
39
  * cancels, with `/` slash commands and `@` mentions wired to the same draft. Every card after it
37
- * exists so the whole API is visible AT REST — both `submitType` values, all four `size` steps,
40
+ * exists so the whole API is visible AT REST — every `submitType` value (including the record-
41
+ * comment bar: `modEnter` + `allowEmptySubmit`), all four `size` steps,
38
42
  * every slot, and each non-default state — without having to click anything.
39
43
  *
40
44
  * Composed only from real @godxjp/ui components.
@@ -91,6 +95,20 @@ const OPENING: Message[] = [
91
95
  },
92
96
  ];
93
97
 
98
+ /** Record statuses for the comment bar — a status change is postable without any text. */
99
+ const STATUSES = [
100
+ { value: "open", label: "未対応" },
101
+ { value: "inProgress", label: "処理中" },
102
+ { value: "resolved", label: "処理済み" },
103
+ { value: "closed", label: "完了" },
104
+ ];
105
+
106
+ interface Comment {
107
+ id: number;
108
+ body: string;
109
+ status: string | null;
110
+ }
111
+
94
112
  /** One transcript line. The message FEED is ChatBubbleList's job; here it only sets the scene. */
95
113
  function Line({ message }: { message: Message }) {
96
114
  const mine = message.author === "you";
@@ -110,6 +128,8 @@ function Line({ message }: { message: Message }) {
110
128
  }
111
129
 
112
130
  export default function Demo() {
131
+ const { t } = useTranslation();
132
+
113
133
  // ── Card 1: the live screen ───────────────────────────────────────────────────────────────
114
134
  const [messages, setMessages] = useState<Message[]>(OPENING);
115
135
  const [draft, setDraft] = useState("");
@@ -142,6 +162,24 @@ export default function Demo() {
142
162
  const [shiftDraft, setShiftDraft] = useState("Enter は改行、Shift + Enter で送信");
143
163
  const [lastSent, setLastSent] = useState<string>("—");
144
164
 
165
+ // ── Card 2b: the record-comment bar (modEnter + allowEmptySubmit) ──────────────────────────
166
+ const [commentDraft, setCommentDraft] = useState("");
167
+ const [status, setStatus] = useState("open");
168
+ const [savedStatus, setSavedStatus] = useState("open");
169
+ const [comments, setComments] = useState<Comment[]>([]);
170
+
171
+ function postComment(text: string) {
172
+ const changed = status !== savedStatus;
173
+ // An empty draft with no status change carries nothing — the consumer decides, not the box.
174
+ if (!text && !changed) return;
175
+ setComments((current) => [
176
+ ...current,
177
+ { id: current.length + 1, body: text, status: changed ? status : null },
178
+ ]);
179
+ setSavedStatus(status);
180
+ setCommentDraft("");
181
+ }
182
+
145
183
  // ── Card 3: the four size steps ───────────────────────────────────────────────────────────
146
184
  const [sizeDrafts, setSizeDrafts] = useState<Record<string, string>>({
147
185
  xs: "xs",
@@ -286,6 +324,68 @@ export default function Demo() {
286
324
  </CardContent>
287
325
  </Card>
288
326
 
327
+ {/* ── 2b. Record comment bar ───────────────────────────────────────────────────── */}
328
+ <Card>
329
+ <CardHeader>
330
+ <CardTitle level={2}>
331
+ submitType=&quot;modEnter&quot; · allowEmptySubmit · 課題へのコメント
332
+ </CardTitle>
333
+ <CardDescription>
334
+ Enter と Shift + Enter は改行、⌘ + Enter(Mac)/ Ctrl + Enter(Windows・Linux)で
335
+ 投稿します。allowEmptySubmit により本文が空でも送信でき、ステータスだけを
336
+ 変更できます(onSubmit には空文字が渡ります)。
337
+ </CardDescription>
338
+ </CardHeader>
339
+ <CardContent>
340
+ <Flex direction="col" gap="md">
341
+ {comments.length === 0 ? (
342
+ <Text size="sm" tone="muted">
343
+ まだコメントはありません
344
+ </Text>
345
+ ) : (
346
+ comments.map((comment) => (
347
+ <Flex key={comment.id} direction="col" gap="xs">
348
+ {comment.status ? (
349
+ <Badge tone="info">
350
+ ステータス:
351
+ {STATUSES.find((option) => option.value === comment.status)?.label}
352
+ </Badge>
353
+ ) : null}
354
+ {comment.body ? <Text>{comment.body}</Text> : null}
355
+ </Flex>
356
+ ))
357
+ )}
358
+ <ChatComposer
359
+ aria-label="課題へのコメント"
360
+ submitType="modEnter"
361
+ allowEmptySubmit
362
+ value={commentDraft}
363
+ onValueChange={setCommentDraft}
364
+ onSubmit={postComment}
365
+ placeholder="コメントを入力"
366
+ submitLabel="コメントを投稿"
367
+ footer={
368
+ <Flex direction="row" gap="sm" align="center" justify="between" wrap>
369
+ <Select
370
+ size="sm"
371
+ aria-label="ステータス"
372
+ value={status}
373
+ onValueChange={setStatus}
374
+ clearable={false}
375
+ options={STATUSES}
376
+ />
377
+ <Text size="xs" tone="muted">
378
+ {t("dataEntry.chatComposer.hintModEnter", {
379
+ modifier: isApplePlatform() ? "⌘" : "Ctrl",
380
+ })}
381
+ </Text>
382
+ </Flex>
383
+ }
384
+ />
385
+ </Flex>
386
+ </CardContent>
387
+ </Card>
388
+
289
389
  {/* ── 3. Every size step ───────────────────────────────────────────────────────── */}
290
390
  <Card>
291
391
  <CardHeader>
@@ -4,15 +4,19 @@ import { QueryClient, QueryClientProvider, useMutation } from "@tanstack/react-q
4
4
 
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@godxjp/ui/data-display";
6
6
  import { FormField, Input } from "@godxjp/ui/data-entry";
7
+ import { FormFieldControl, FormRoot, useZodForm } from "@godxjp/ui/form";
7
8
  import { Button } from "@godxjp/ui/general";
8
9
  import { Flex, PageContainer } from "@godxjp/ui/layout";
9
10
  import { AlertMutationFeedback } from "@godxjp/ui/query";
11
+ import { z } from "zod";
10
12
 
11
13
  /**
12
14
  * AlertMutationFeedback · inline mutation error below a form's submit. Renders
13
15
  * NOTHING while idle/successful; surfaces the error + a retry when useMutation
14
16
  * fails. Composed from real @godxjp/ui + @tanstack/react-query. (This demo fires
15
- * the mutation once on mount so the error state is visible.)
17
+ * the mutation once on mount so the error state is visible.) Inside a FormRoot
18
+ * that received `errors`, a 400/422 validation error is skipped by default —
19
+ * the fields already show the bag — while a 5xx still renders the alert.
16
20
  */
17
21
  const queryClient = new QueryClient();
18
22
 
@@ -54,6 +58,59 @@ function Block() {
54
58
  );
55
59
  }
56
60
 
61
+ type SaveError = Error & { status: number; errors?: Record<string, string[]> };
62
+
63
+ function saveError(status: number, message: string, errors?: Record<string, string[]>) {
64
+ return Object.assign(new Error(message), { status, errors }) as SaveError;
65
+ }
66
+
67
+ const schema = z.object({ code: z.string() });
68
+
69
+ function InFormBlock() {
70
+ const form = useZodForm(schema, { defaultValues: { code: "BTY-0012" } });
71
+ const mutation = useMutation<void, SaveError, number>({
72
+ mutationFn: async (status) => {
73
+ throw status === 422
74
+ ? saveError(422, "The given data was invalid.", {
75
+ code: ["取引先コードが重複しています"],
76
+ })
77
+ : saveError(500, "サーバーエラーが発生しました (500)");
78
+ },
79
+ });
80
+
81
+ useEffect(() => {
82
+ mutation.mutate(422);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, []);
85
+
86
+ return (
87
+ <Card>
88
+ <CardHeader>
89
+ <CardTitle level={2}>取引先の登録 · FormRoot errors</CardTitle>
90
+ <CardDescription>
91
+ A 422 shows on the field only; a 500 still renders the alert.
92
+ </CardDescription>
93
+ </CardHeader>
94
+ <CardContent>
95
+ <FormRoot form={form} errors={mutation.error?.errors} onSubmit={() => mutation.mutate(422)}>
96
+ <FormFieldControl name="code" label="取引先コード">
97
+ {(field) => <Input {...field} value={String(field.value ?? "")} />}
98
+ </FormFieldControl>
99
+ <AlertMutationFeedback mutation={mutation} onRetry={() => mutation.mutate(500)} />
100
+ <Flex direction="row" wrap justify="end" gap="sm">
101
+ <Button variant="outline" type="button" onClick={() => mutation.mutate(500)}>
102
+ 500 を再現
103
+ </Button>
104
+ <Button type="submit" loading={mutation.isPending}>
105
+ 保存
106
+ </Button>
107
+ </Flex>
108
+ </FormRoot>
109
+ </CardContent>
110
+ </Card>
111
+ );
112
+ }
113
+
57
114
  export default function Demo() {
58
115
  return (
59
116
  <QueryClientProvider client={queryClient}>
@@ -63,6 +120,7 @@ export default function Demo() {
63
120
  >
64
121
  <Flex direction="col" gap="lg">
65
122
  <Block />
123
+ <InFormBlock />
66
124
  </Flex>
67
125
  </PageContainer>
68
126
  </QueryClientProvider>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "26.1.0",
4
- "godxUiMcp": "26.1.0",
3
+ "version": "26.3.0",
4
+ "godxUiMcp": "26.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -45,12 +45,32 @@ function mcpEntryMatches(a, b) {
45
45
  return keysA.every((k) => envA[k] === envB[k]);
46
46
  }
47
47
 
48
- function mcpConfigMismatchMessage(root, existing, expected) {
48
+ /**
49
+ * True when `entry` is the shape THIS package writes (`npx @godxjp/ui-mcp@<pin>`, env at most
50
+ * GODX_UI_VERSION), whatever pin and version it carries. Such an entry is ours to move forward on
51
+ * upgrade: treating it as custom left consumers on the MCP of the release they first installed
52
+ * (a `@godxjp/ui-mcp@25.4.0` pin survived the upgrade to 26.x).
53
+ */
54
+ function isPackageWrittenMcpEntry(entry) {
55
+ if (entry?.command !== "npx") return false;
56
+ const args = entry.args ?? [];
57
+ if (args.length !== 1 || !/^@godxjp\/ui-mcp@[^\s]+$/.test(args[0])) return false;
58
+ return Object.keys(entry.env ?? {}).every((key) => key === "GODX_UI_VERSION");
59
+ }
60
+
61
+ /** True when an `mcpServers` entry launches the catalog server, under any key or launcher flags. */
62
+ function runsUiMcp(entry) {
63
+ const parts = [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])];
64
+ return parts.some((part) => typeof part === "string" && /(^|\/)@godxjp\/ui-mcp(@|$)/.test(part));
65
+ }
66
+
67
+ function mcpConfigMismatchMessage(root, existing, expected, key = MCP_KEY) {
49
68
  const ui = readConsumerUiMetadata(root);
50
69
  const pin = expected.args?.[0] ?? "@godxjp/ui-mcp";
51
70
  const ver = ui?.version ?? "(unknown)";
52
71
  return (
53
- `present (custom godx-ui MCP entry — not overwritten; expected ${pin} with ` +
72
+ `present (custom godx-ui MCP entry${key === MCP_KEY ? "" : ` under key "${key}"`} — not overwritten, ` +
73
+ `no duplicate added; expected ${pin} with ` +
54
74
  `env.GODX_UI_VERSION=${ver} from node_modules/@godxjp/ui)`
55
75
  );
56
76
  }
@@ -252,7 +272,18 @@ function readJsonFile(path) {
252
272
  // writing the result back is the same data loss by a different door, so it is a refusal too.
253
273
  if (json === null || typeof json !== "object" || Array.isArray(json))
254
274
  return { state: "wrong-shape" };
255
- return { state: "ok", json };
275
+ return { state: "ok", json, indent: indentOf(raw) };
276
+ }
277
+
278
+ /**
279
+ * The indentation the file already uses — a tab, or the width of the first indented line — so a
280
+ * rewrite changes only the keys it touches. Re-serialising at a fixed 2 turned a one-entry edit
281
+ * into a whole-file diff in a repo formatted at 4 (gh#692).
282
+ */
283
+ function indentOf(raw) {
284
+ const first = raw.match(/\n([ \t]+)\S/);
285
+ if (!first) return 2;
286
+ return first[1].startsWith("\t") ? "\t" : first[1].length;
256
287
  }
257
288
 
258
289
  /** The sentence that goes in the refusal, so a consumer knows which of the three it hit. */
@@ -347,7 +378,11 @@ export function refreshBlock(current, next, startMarker, endMarker) {
347
378
  if (!endMarker) return null;
348
379
  const j = current.indexOf(endMarker, first);
349
380
  if (j < 0) return null;
350
- return current.slice(0, first) + next + current.slice(j + endMarker.length);
381
+ // `next` ends in its own newline, and the old block's newline is still the first character after
382
+ // the end marker — keeping both grew the file by one blank line on every refresh.
383
+ let tail = current.slice(j + endMarker.length);
384
+ if (next.endsWith("\n") && tail.startsWith("\n")) tail = tail.slice(1);
385
+ return current.slice(0, first) + next + tail;
351
386
  }
352
387
 
353
388
  export function ensureMcpJson(root) {
@@ -368,15 +403,25 @@ export function ensureMcpJson(root) {
368
403
  return refuseAndSuggest(path, suggested, "`mcpServers` is not an object");
369
404
  }
370
405
  json.mcpServers = json.mcpServers ?? {};
371
- if (json.mcpServers[MCP_KEY]) {
372
- if (!mcpEntryMatches(json.mcpServers[MCP_KEY], expected)) {
373
- return mcpConfigMismatchMessage(root, json.mcpServers[MCP_KEY], expected);
406
+ const indent = read.state === "ok" ? read.indent : 2;
407
+ // The catalog server may already be registered under ANOTHER key — `godxjp-ui`, `ui`, whatever
408
+ // the repo chose. Looking only at MCP_KEY added a second copy of the same server beside it, at
409
+ // a different version (gh#692). Whatever key runs @godxjp/ui-mcp is the entry.
410
+ const key = json.mcpServers[MCP_KEY]
411
+ ? MCP_KEY
412
+ : Object.keys(json.mcpServers).find((name) => runsUiMcp(json.mcpServers[name]));
413
+ if (key) {
414
+ if (mcpEntryMatches(json.mcpServers[key], expected)) return "present";
415
+ if (!isPackageWrittenMcpEntry(json.mcpServers[key])) {
416
+ return mcpConfigMismatchMessage(root, json.mcpServers[key], expected, key);
374
417
  }
375
- return "present";
418
+ json.mcpServers[key] = expected;
419
+ writeFileAtomic(path, JSON.stringify(json, null, indent) + "\n");
420
+ return "refreshed";
376
421
  }
377
422
  const created = read.state === "missing";
378
423
  json.mcpServers[MCP_KEY] = expected;
379
- writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
424
+ writeFileAtomic(path, JSON.stringify(json, null, indent) + "\n");
380
425
  return created ? "created" : "added";
381
426
  }
382
427
 
@@ -430,7 +475,7 @@ export function ensureClaudeHooks(root) {
430
475
  added.push("SessionStart:workflow-primer");
431
476
  }
432
477
 
433
- writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
478
+ writeFileAtomic(path, JSON.stringify(json, null, read.state === "ok" ? read.indent : 2) + "\n");
434
479
  return added;
435
480
  }
436
481
 
package/scripts/cli.mjs CHANGED
@@ -18,13 +18,74 @@ const MAP = {
18
18
  "visual-audit": "visual-audit.mjs",
19
19
  };
20
20
 
21
+ // `<command> --help` is answered HERE: the scripts take free positional arguments, so a `--help`
22
+ // forwarded to ui-audit used to be read as nothing and ran a full audit instead of printing help.
23
+ const HELP = {
24
+ "init-agent": `godxjp-ui init-agent
25
+
26
+ Install the agent forcing-kit into the current consumer app (run it from the app root):
27
+ .mcp.json the godx-ui MCP server
28
+ .claude/settings.json a PostToolUse hook that audits every Write/Edit of a .tsx file
29
+ .claude/godxjp-ui-workflow.md
30
+ CLAUDE.md the godxjp-ui mandate block
31
+ Idempotent. Refuses inside the @godxjp/ui repo itself. Restart the agent afterwards.`,
32
+ "sync-rules": `godxjp-ui sync-rules
33
+
34
+ Refresh the package-owned agent files for the installed @godxjp/ui version: the godx-ui entry in
35
+ .mcp.json, the managed CLAUDE.md block, .claude/godxjp-ui-workflow.md and .ai/rules/godxjp-ui.md.
36
+ It is the postinstall step, run by hand — use it when the app installs with ignore-scripts=true,
37
+ where postinstall never runs and the rules go stale. Silent no-op in CI and when opted out.`,
38
+ audit: `godxjp-ui audit [dir …] [--changed] [--format json] [--quiet] [--rules]
39
+
40
+ Static UI-standardization audit over source (regex, no browser, fast). Exits non-zero on errors.
41
+ dir … directories or files to scan
42
+ --changed scan what this branch changed vs origin/main (committed, staged, untracked);
43
+ fails rather than reporting clean when origin/main cannot be resolved
44
+ --format json machine-readable findings
45
+ --quiet print errors only (warnings hidden)
46
+ --rules print the rule catalog as JSON and exit
47
+ Pre-commit: npx godxjp-ui audit resources/js || exit 1`,
48
+ "visual-audit": `godxjp-ui visual-audit [--format json] [--strict] <baseUrl> [route …]
49
+
50
+ Runtime audit (Playwright + axe-core) against an app you are ALREADY running locally.
51
+ baseUrl the running app, e.g. http://localhost:5173
52
+ route … paths appended to baseUrl (default "/")
53
+ --strict exit non-zero on any finding, not only errors
54
+ --format json machine-readable findings
55
+ --rules print the visual rule catalog as JSON and exit
56
+ Needs the playwright peer installed.`,
57
+ };
58
+
59
+ const USAGE = `usage: godxjp-ui <command> [args]
60
+
61
+ commands:
62
+ init-agent install the agent forcing-kit (MCP + auto-audit hook + CLAUDE.md mandate)
63
+ sync-rules refresh package-owned agent rules (postinstall, by hand)
64
+ audit static UI audit over source
65
+ visual-audit runtime audit (Playwright + axe-core) against a running app
66
+
67
+ godxjp-ui <command> --help details and flags for one command`;
68
+
69
+ const isHelp = (arg) => arg === "--help" || arg === "-h" || arg === "help";
21
70
  const [cmd, ...rest] = process.argv.slice(2);
71
+
72
+ if (cmd === undefined || isHelp(cmd)) {
73
+ const topic = isHelp(cmd) ? rest[0] : undefined;
74
+ console.log(topic && HELP[topic] ? HELP[topic] : USAGE);
75
+ process.exit(cmd === undefined ? 1 : 0);
76
+ }
22
77
  const script = MAP[cmd];
23
78
  if (!script) {
24
- console.error("usage: godxjp-ui <init-agent | sync-rules | audit | visual-audit> [args]");
79
+ console.error(`unknown command: ${cmd}\n\n${USAGE}`);
25
80
  process.exit(1);
26
81
  }
82
+ if (rest.includes("--help") || rest.includes("-h")) {
83
+ console.log(HELP[cmd]);
84
+ process.exit(0);
85
+ }
27
86
  const env =
28
- cmd === "sync-rules" ? { ...process.env, INIT_CWD: process.env.INIT_CWD ?? process.cwd() } : process.env;
87
+ cmd === "sync-rules"
88
+ ? { ...process.env, INIT_CWD: process.env.INIT_CWD ?? process.cwd() }
89
+ : process.env;
29
90
  const r = spawnSync("node", [join(HERE, script), ...rest], { stdio: "inherit", env });
30
91
  process.exit(r.status ?? 0);