@godxjp/ui 23.2.1 → 23.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.
@@ -1,4 +1,5 @@
1
1
  import { translate } from "../i18n/translate.js";
2
+ import { dateTimeFormat } from "../lib/intl-cache.js";
2
3
  const APP_TIMEZONE_PRESET = [
3
4
  "UTC",
4
5
  "Asia/Ho_Chi_Minh",
@@ -113,7 +114,7 @@ function getTimezoneCityName(timezone) {
113
114
  function getTimezoneOffsetLabel(timezone, locale = "en") {
114
115
  if (timezone === "UTC") return "UTC";
115
116
  try {
116
- const parts = new Intl.DateTimeFormat(locale, {
117
+ const parts = dateTimeFormat(locale, {
117
118
  timeZone: resolveTimezoneForIntl(timezone),
118
119
  timeZoneName: "shortOffset"
119
120
  }).formatToParts(/* @__PURE__ */ new Date());
@@ -22,6 +22,7 @@ import { useTranslation } from "../../i18n/use-translation.js";
22
22
  import { ChartFrame, chartColor, chartHeight, useChartNumberFormat } from "./chart-frame.js";
23
23
  import { buildCartesianSummary } from "./chart-summary.js";
24
24
  import { useCategoryAxisMetrics } from "./chart-category-axis.js";
25
+ import { listFormat } from "../../lib/intl-cache.js";
25
26
  function CartesianChart({
26
27
  kind,
27
28
  data,
@@ -45,10 +46,7 @@ function CartesianChart({
45
46
  assertRechartsPeer();
46
47
  const { t, locale } = useTranslation();
47
48
  const fmt = useChartNumberFormat(numberFormat);
48
- const list = React.useMemo(
49
- () => new Intl.ListFormat(locale, { style: "narrow", type: "unit" }),
50
- [locale]
51
- );
49
+ const list = React.useMemo(() => listFormat(locale, { style: "narrow", type: "unit" }), [locale]);
52
50
  const hasData = data.length > 0 && series.length > 0;
53
51
  const summary = buildCartesianSummary(
54
52
  data,
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../../lib/utils.js";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
+ import { numberFormat } from "../../lib/intl-cache.js";
6
7
  const CHART_COLORS = [
7
8
  "var(--chart-1)",
8
9
  "var(--chart-2)",
@@ -25,7 +26,7 @@ function chartHeight(size = "md", height) {
25
26
  }
26
27
  function useChartNumberFormat(options) {
27
28
  const { locale } = useTranslation();
28
- return React.useMemo(() => new Intl.NumberFormat(locale, options), [locale, options]);
29
+ return React.useMemo(() => numberFormat(locale, options), [locale, options]);
29
30
  }
30
31
  function ChartFrame({
31
32
  label,
@@ -22,6 +22,7 @@ import { CONTROL_STATUS_CHROME_CLASS, CONTROL_VARIANT_CHROME_CLASS } from "./con
22
22
  import { Input } from "./input.js";
23
23
  import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../data-display/popover.js";
24
24
  import { Calendar } from "./calendar.js";
25
+ import { dateTimeFormat, numberFormat } from "../../lib/intl-cache.js";
25
26
  const ISO_HINT = "yyyy-mm-dd";
26
27
  const PERIOD_PICKERS = /* @__PURE__ */ new Set(["month", "quarter", "year"]);
27
28
  const PERIOD_COLUMNS = 3;
@@ -49,6 +50,7 @@ function DatePicker(props) {
49
50
  disabledDate,
50
51
  cellRender,
51
52
  allowClear,
53
+ triggerLabel,
52
54
  format: formatProp,
53
55
  parseFormat,
54
56
  minDate,
@@ -296,13 +298,9 @@ function DatePicker(props) {
296
298
  (index) => index < periodCells
297
299
  );
298
300
  const pageStep = picker === "year" ? periodCells : 1;
299
- const prevDisabled = !pickerDateAllowed(
300
- periodPage(viewYear - pageStep)[periodCells - 1],
301
- minimum,
302
- void 0
303
- );
304
- const nextDisabled = !pickerDateAllowed(periodPage(viewYear + pageStep)[0], void 0, maximum);
305
- const periodLabel = (date, index) => picker === "year" ? new Intl.DateTimeFormat(locale, { year: "numeric" }).format(date) : picker === "quarter" ? t("dataEntry.datePicker.quarter", { quarter: index + 1 }) ?? `Q${index + 1}` : new Intl.DateTimeFormat(locale, { month: "short" }).format(date);
301
+ const prevDisabled = () => !pickerDateAllowed(periodPage(viewYear - pageStep)[periodCells - 1], minimum, void 0);
302
+ const nextDisabled = () => !pickerDateAllowed(periodPage(viewYear + pageStep)[0], void 0, maximum);
303
+ const periodLabel = (date, index) => picker === "year" ? dateTimeFormat(locale, { year: "numeric" }).format(date) : picker === "quarter" ? t("dataEntry.datePicker.quarter", { quarter: index + 1 }) ?? `Q${index + 1}` : dateTimeFormat(locale, { month: "short" }).format(date);
306
304
  const periodPick = (date) => {
307
305
  if (!range) {
308
306
  choose(date);
@@ -318,7 +316,7 @@ function DatePicker(props) {
318
316
  choose({ from: pendingFrom, to: date });
319
317
  if (!needConfirm) setOpen(false);
320
318
  };
321
- const periodPanel = /* @__PURE__ */ jsxs("div", { className: "ui-month-picker-panel", children: [
319
+ const renderPeriodPanel = () => /* @__PURE__ */ jsxs("div", { className: "ui-month-picker-panel", children: [
322
320
  /* @__PURE__ */ jsxs("div", { className: "ui-month-picker-nav", children: [
323
321
  /* @__PURE__ */ jsx(
324
322
  Button,
@@ -326,21 +324,21 @@ function DatePicker(props) {
326
324
  type: "button",
327
325
  variant: "outline",
328
326
  size: "icon-sm",
329
- disabled: prevDisabled,
327
+ disabled: prevDisabled(),
330
328
  "aria-label": t("dataEntry.monthPicker.previousYear") ?? "Previous year",
331
329
  className: "ui-month-picker-nav-button",
332
330
  onClick: () => setViewYear(viewYear - pageStep),
333
331
  children: /* @__PURE__ */ jsx(ChevronLeft, { className: "ui-month-picker-icon", "aria-hidden": "true" })
334
332
  }
335
333
  ),
336
- /* @__PURE__ */ jsx("span", { className: "ui-month-picker-nav-label", "aria-live": "polite", children: new Intl.NumberFormat(locale, { useGrouping: false }).format(viewYear) }),
334
+ /* @__PURE__ */ jsx("span", { className: "ui-month-picker-nav-label", "aria-live": "polite", children: numberFormat(locale, { useGrouping: false }).format(viewYear) }),
337
335
  /* @__PURE__ */ jsx(
338
336
  Button,
339
337
  {
340
338
  type: "button",
341
339
  variant: "outline",
342
340
  size: "icon-sm",
343
- disabled: nextDisabled,
341
+ disabled: nextDisabled(),
344
342
  "aria-label": t("dataEntry.monthPicker.nextYear") ?? "Next year",
345
343
  className: "ui-month-picker-nav-button",
346
344
  onClick: () => setViewYear(viewYear + pageStep),
@@ -390,7 +388,7 @@ function DatePicker(props) {
390
388
  showClose,
391
389
  onClose: () => setOpen(false)
392
390
  };
393
- const dayPanel = range ? /* @__PURE__ */ jsx(
391
+ const renderDayPanel = () => range ? /* @__PURE__ */ jsx(
394
392
  Calendar,
395
393
  {
396
394
  mode: "range",
@@ -458,7 +456,7 @@ function DatePicker(props) {
458
456
  type: "button",
459
457
  disabled: allDisabled,
460
458
  tabIndex: -1,
461
- "aria-label": (isPeriod ? t("dataEntry.monthPicker.openGrid") : range ? t("dataEntry.dateRangePicker.openCalendar") : t("dataEntry.datePicker.openCalendar")) ?? "Open calendar",
459
+ "aria-label": triggerLabel ?? (isPeriod ? t("dataEntry.monthPicker.openGrid") : range ? t("dataEntry.dateRangePicker.openCalendar") : t("dataEntry.datePicker.openCalendar")) ?? "Open calendar",
462
460
  className: range ? "text-muted-foreground hover:text-foreground shrink-0" : "ui-control-inline-affix-action",
463
461
  children: /* @__PURE__ */ jsx(
464
462
  CalendarIcon,
@@ -502,7 +500,7 @@ function DatePicker(props) {
502
500
  },
503
501
  index
504
502
  )) }) : null,
505
- isPeriod ? periodPanel : dayPanel,
503
+ isPeriod ? renderPeriodPanel() : renderDayPanel(),
506
504
  showTime ? /* @__PURE__ */ jsx(Flex, { pad: "sm", children: /* @__PURE__ */ jsx(
507
505
  TimePicker,
508
506
  {
@@ -7,6 +7,7 @@ import { cn } from "../../lib/utils.js";
7
7
  import { pickFieldA11y } from "../../lib/field-a11y.js";
8
8
  import { Button } from "../general/button.js";
9
9
  import { Input } from "./input.js";
10
+ import { numberFormat } from "../../lib/intl-cache.js";
10
11
  function decimalsOf(n) {
11
12
  if (!Number.isFinite(n)) return 0;
12
13
  const s = String(n);
@@ -77,7 +78,7 @@ const NumberInput = React.forwardRef(
77
78
  const numericValue = isControlled ? controlledValue ?? null : internal;
78
79
  const effectivePrecision = precision ?? decimalsOf(step);
79
80
  const intlFormatter = React.useMemo(
80
- () => new Intl.NumberFormat(locale, {
81
+ () => numberFormat(locale, {
81
82
  minimumFractionDigits: 0,
82
83
  maximumFractionDigits: Math.max(effectivePrecision, 0),
83
84
  useGrouping: false
@@ -6,8 +6,8 @@ export { TwoFactorSetup } from "./two-factor-setup.js";
6
6
  export type { TwoFactorSetupLabels, TwoFactorSetupProps } from "./two-factor-setup.js";
7
7
  export { Toaster } from "./sonner.js";
8
8
  export { toast } from "./use-toast.js";
9
- export { Skeleton, SkeletonRows, SkeletonTable, SkeletonDetail, SkeletonStat, SkeletonArticle, SkeletonAvatar, SkeletonButton, SkeletonInput, SkeletonNode, SkeletonImage, } from "./skeleton.js";
10
- export type { SkeletonProp, SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProps, SkeletonInputProp, SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProps, SkeletonImageProp, SkeletonImageProps, } from "./skeleton.js";
9
+ export { Skeleton, SkeletonForm, SkeletonRows, SkeletonTable, SkeletonDetail, SkeletonStat, SkeletonArticle, SkeletonAvatar, SkeletonButton, SkeletonInput, SkeletonNode, SkeletonImage, } from "./skeleton.js";
10
+ export type { SkeletonProp, SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProps, SkeletonFormProp, SkeletonFormProps, SkeletonInputProp, SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProps, SkeletonImageProp, SkeletonImageProps, } from "./skeleton.js";
11
11
  export { Banner } from "./banner.js";
12
12
  export type { BannerProp, BannerProps } from "./banner.js";
13
13
  export { Alert, AlertTitle, AlertContent, AlertDescription, AlertActions, AlertQueryError, } from "./alert.js";
@@ -44,6 +44,7 @@ import { Toaster } from "./sonner.js";
44
44
  import { toast } from "./use-toast.js";
45
45
  import {
46
46
  Skeleton,
47
+ SkeletonForm,
47
48
  SkeletonRows,
48
49
  SkeletonTable,
49
50
  SkeletonDetail,
@@ -114,6 +115,7 @@ export {
114
115
  SkeletonAvatar,
115
116
  SkeletonButton,
116
117
  SkeletonDetail,
118
+ SkeletonForm,
117
119
  SkeletonImage,
118
120
  SkeletonInput,
119
121
  SkeletonNode,
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
- import type { SkeletonArticleProp, SkeletonAvatarProp, SkeletonButtonProp, SkeletonImageProp, SkeletonInputProp, SkeletonNodeProp, SkeletonProp } from "../../props/components/feedback.prop.js";
3
- export type { SkeletonProp, SkeletonProp as SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProp as SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProp as SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProp as SkeletonButtonProps, SkeletonInputProp, SkeletonInputProp as SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProp as SkeletonNodeProps, SkeletonImageProp, SkeletonImageProp as SkeletonImageProps, } from "../../props/components/feedback.prop.js";
2
+ import type { SkeletonArticleProp, SkeletonAvatarProp, SkeletonButtonProp, SkeletonFormProp, SkeletonImageProp, SkeletonInputProp, SkeletonNodeProp, SkeletonProp } from "../../props/components/feedback.prop.js";
3
+ export type { SkeletonProp, SkeletonProp as SkeletonProps, SkeletonFormProp, SkeletonFormProp as SkeletonFormProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProp as SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProp as SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProp as SkeletonButtonProps, SkeletonInputProp, SkeletonInputProp as SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProp as SkeletonNodeProps, SkeletonImageProp, SkeletonImageProp as SkeletonImageProps, } from "../../props/components/feedback.prop.js";
4
4
  declare function SkeletonBlock({ active, loading, className, children, ...props }: SkeletonProp): React.JSX.Element;
5
5
  /** Stands in for an `Avatar` — circle for a person, square for an entity mark. */
6
6
  export declare function SkeletonAvatar({ size, shape, active, className }: SkeletonAvatarProp): React.JSX.Element;
@@ -39,6 +39,15 @@ interface SkeletonRowsProps {
39
39
  }
40
40
  /** Skeleton for a flat list of rows (use inside a Card or section). */
41
41
  export declare function SkeletonRows({ rows, columns, className }: SkeletonRowsProps): React.JSX.Element;
42
+ /**
43
+ * Skeleton of a `Form columns={N}` — label + control PAIRS on the form's own grid (gh#552).
44
+ *
45
+ * It renders through `ResponsiveGrid` with the SAME `columns` value the form takes, so the two
46
+ * share one breakpoint ladder rather than two that agree today. `SkeletonRows` could get the column
47
+ * count right and never the inside of a cell: a form field is a short label stacked on a full-width
48
+ * control, and a flat line list is neither.
49
+ */
50
+ export declare function SkeletonForm({ columns, fields, active, className }: SkeletonFormProp): React.JSX.Element;
42
51
  /** Skeleton matching the DataTable layout — header row + N body rows. */
43
52
  export declare function SkeletonTable({ rows, columns }: SkeletonRowsProps): React.JSX.Element;
44
53
  /** Skeleton matching a Card detail layout — title + 6 metadata rows. */
@@ -1,5 +1,6 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { ImageIcon } from "lucide-react";
3
+ import { ResponsiveGrid } from "../layout/responsive-grid.js";
3
4
  import { cn } from "../../lib/utils.js";
4
5
  import { tableCellPaddingClass, tableRowHeightClass } from "../../lib/control-styles.js";
5
6
  function measureStyle(width) {
@@ -169,6 +170,12 @@ function SkeletonRows({ rows = 6, columns = 4, className }) {
169
170
  j
170
171
  )) }, i)) });
171
172
  }
173
+ function SkeletonForm({ columns = 1, fields = 6, active, className }) {
174
+ return /* @__PURE__ */ jsx("div", { className: cn("ui-skeleton-form", className), "aria-busy": "true", children: /* @__PURE__ */ jsx(ResponsiveGrid, { columns, children: Array.from({ length: fields }).map((_, i) => /* @__PURE__ */ jsxs("div", { className: "ui-skeleton-form-field", children: [
175
+ /* @__PURE__ */ jsx(Skeleton, { active, className: "ui-skeleton-form-label" }),
176
+ /* @__PURE__ */ jsx(Skeleton, { active, className: "ui-skeleton-form-control" })
177
+ ] }, i)) }) });
178
+ }
172
179
  function SkeletonTable({ rows = 8, columns = 5 }) {
173
180
  return /* @__PURE__ */ jsxs("div", { className: "ui-skeleton-table", "aria-busy": "true", children: [
174
181
  /* @__PURE__ */ jsx("div", { className: cn("ui-skeleton-table-head", tableCellPaddingClass, tableRowHeightClass), children: Array.from({ length: columns }).map((_, j) => /* @__PURE__ */ jsx(Skeleton, { className: cn("ui-skeleton-caption", j === 0 ? "w-1/5" : "flex-1") }, j)) }),
@@ -205,6 +212,7 @@ export {
205
212
  SkeletonAvatar,
206
213
  SkeletonButton,
207
214
  SkeletonDetail,
215
+ SkeletonForm,
208
216
  SkeletonImage,
209
217
  SkeletonInput,
210
218
  SkeletonNode,
@@ -6,6 +6,7 @@ import { cva } from "class-variance-authority";
6
6
  import { Loader2 } from "lucide-react";
7
7
  import { cn } from "../../lib/utils.js";
8
8
  import { useTranslation } from "../../i18n/use-translation.js";
9
+ import { numberFormat } from "../../lib/intl-cache.js";
9
10
  const buttonVariants = cva("ui-button", {
10
11
  variants: {
11
12
  variant: {
@@ -78,7 +79,7 @@ const Button = React.forwardRef(
78
79
  loadingText ?? children
79
80
  ] }) : children;
80
81
  const showCount = !asChild && count != null && (count !== 0 || showZero);
81
- const countLabel = showCount && count != null && count > overflowCount ? `${new Intl.NumberFormat(locale).format(overflowCount)}+` : count != null ? new Intl.NumberFormat(locale).format(count) : "";
82
+ const countLabel = showCount && count != null && count > overflowCount ? `${numberFormat(locale).format(overflowCount)}+` : count != null ? numberFormat(locale).format(count) : "";
82
83
  const countNode = showCount ? /* @__PURE__ */ jsx("span", { "data-slot": "button-count", className: "ui-button-count", children: countLabel }) : null;
83
84
  return /* @__PURE__ */ jsx(
84
85
  Comp,
@@ -8,6 +8,7 @@ import { EmptyState } from "../data-display/empty-state.js";
8
8
  import { Progress } from "../data-display/progress.js";
9
9
  import { Text } from "../general/typography.js";
10
10
  import { CenteredShell } from "./centered-shell.js";
11
+ import { dateTimeFormat, numberFormat } from "../../lib/intl-cache.js";
11
12
  const STATUS_META = {
12
13
  400: { icon: TriangleAlert, tone: "warning" },
13
14
  403: { icon: ShieldAlert, tone: "warning" },
@@ -37,7 +38,7 @@ function formatMaintenanceWindow(maintenance, locale) {
37
38
  };
38
39
  const start = new Date(maintenance.start);
39
40
  if (Number.isNaN(start.getTime())) return maintenance.start;
40
- const formatter = new Intl.DateTimeFormat(locale, options);
41
+ const formatter = dateTimeFormat(locale, options);
41
42
  if (maintenance.end === void 0) return formatter.format(start);
42
43
  const end = new Date(maintenance.end);
43
44
  if (Number.isNaN(end.getTime())) return formatter.format(start);
@@ -72,7 +73,7 @@ const ErrorSurface = React.forwardRef(
72
73
  const maintenanceProgress = maintenance?.progress;
73
74
  const hasProgress = typeof maintenanceProgress === "number" && Number.isFinite(maintenanceProgress);
74
75
  const progressLabel = hasProgress ? t("layout.errorSurface.maintenanceProgress", {
75
- percent: new Intl.NumberFormat(locale, {
76
+ percent: numberFormat(locale, {
76
77
  style: "percent",
77
78
  maximumFractionDigits: 0
78
79
  }).format(Math.max(0, Math.min(100, maintenanceProgress)) / 100)
@@ -5,6 +5,7 @@ import { ToggleButton } from "react-aria-components";
5
5
  import { cva } from "class-variance-authority";
6
6
  import { cn } from "../../lib/utils.js";
7
7
  import { useTranslation } from "../../i18n/use-translation.js";
8
+ import { numberFormat } from "../../lib/intl-cache.js";
8
9
  const toggleVariants = cva("ui-toggle", {
9
10
  variants: {
10
11
  variant: {
@@ -33,7 +34,7 @@ function useCounterPill({
33
34
  const visible = count != null && (count !== 0 || showZero);
34
35
  const formatted = React.useMemo(() => {
35
36
  if (count == null) return "";
36
- const format = new Intl.NumberFormat(locale);
37
+ const format = numberFormat(locale);
37
38
  return count > overflowCount ? `${format.format(overflowCount)}+` : format.format(count);
38
39
  }, [count, locale, overflowCount]);
39
40
  if (!visible) {
@@ -1,6 +1,7 @@
1
1
  import en from "./messages/en.json" with { type: "json" };
2
2
  import ja from "./messages/ja.json" with { type: "json" };
3
3
  import vi from "./messages/vi.json" with { type: "json" };
4
+ import { numberFormat, pluralRules } from "../lib/intl-cache.js";
4
5
  const MESSAGE_CATALOG = {
5
6
  vi,
6
7
  en,
@@ -56,7 +57,7 @@ function selectPlural(value, locale, params) {
56
57
  if (count === void 0) {
57
58
  return value.other ?? Object.values(value)[0] ?? "";
58
59
  }
59
- const category = new Intl.PluralRules(locale).select(count);
60
+ const category = pluralRules(locale).select(count);
60
61
  return value[category] ?? value.other ?? Object.values(value)[0] ?? "";
61
62
  }
62
63
  function interpolate(template, locale, params) {
@@ -64,7 +65,7 @@ function interpolate(template, locale, params) {
64
65
  return Object.entries(params).reduce(
65
66
  (text, [key, value]) => text.replaceAll(
66
67
  `{${key}}`,
67
- typeof value === "number" ? new Intl.NumberFormat(locale).format(value) : String(value)
68
+ typeof value === "number" ? numberFormat(locale).format(value) : String(value)
68
69
  ),
69
70
  template
70
71
  );
@@ -9,10 +9,11 @@ import {
9
9
  startOfYear
10
10
  } from "date-fns";
11
11
  import { parseDateInput, toIsoDate } from "./parse.js";
12
+ import { dateTimeFormat } from "../intl-cache.js";
12
13
  function formatPickerDate(date, format, locale, withTime = false) {
13
14
  if (!date || !isValid(date)) return "";
14
15
  if (typeof format === "function") return format(date);
15
- if (typeof format === "object") return new Intl.DateTimeFormat(locale, format).format(date);
16
+ if (typeof format === "object") return dateTimeFormat(locale, format).format(date);
16
17
  if (typeof format === "string") return formatDate(date, format);
17
18
  return withTime ? formatDate(date, "yyyy-MM-dd HH:mm") : toIsoDate(date);
18
19
  }
@@ -1,7 +1,8 @@
1
1
  import { getSyncedLocale, translateCurrent } from "../i18n/translate.js";
2
+ import { numberFormat } from "./intl-cache.js";
2
3
  function formatBytes(n, locale = getSyncedLocale()) {
3
4
  if (n == null) return "\u2014";
4
- const num = (digits, scaled) => new Intl.NumberFormat(locale, {
5
+ const num = (digits, scaled) => numberFormat(locale, {
5
6
  minimumFractionDigits: digits,
6
7
  maximumFractionDigits: digits
7
8
  }).format(scaled);
@@ -12,7 +13,7 @@ function formatBytes(n, locale = getSyncedLocale()) {
12
13
  }
13
14
  function formatCurrency(amountMinor, currency, locale = getSyncedLocale()) {
14
15
  if (amountMinor == null || !currency) return "\u2014";
15
- const formatter = new Intl.NumberFormat(locale, { style: "currency", currency });
16
+ const formatter = numberFormat(locale, { style: "currency", currency });
16
17
  const minorUnitDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
17
18
  const major = amountMinor / Math.pow(10, minorUnitDigits);
18
19
  return formatter.format(major);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Memoised `Intl` formatters, keyed by locale + options.
3
+ *
4
+ * WHY THIS EXISTS, AS A MEASUREMENT (gh#557). A consumer ported a legacy CakePHP grid that keeps
5
+ * every row in the DOM — 8,262 rows, no virtualisation available — and measured 27-30s against
6
+ * 2.3s for the same markup built from native `<input>` / `<select>`. They had already ruled out
7
+ * the obvious suspect: deleting `Popover` / `PopoverContent` from `DatePicker` entirely changed
8
+ * nothing.
9
+ *
10
+ * Rendered here through `react-dom/server`, 2,000 rows at a time, one CLOSED `DatePicker` costs
11
+ * **412.6us** against **4.2us** for `<input type="date">` — 98x. Counting constructor calls during
12
+ * that render, each closed instance built:
13
+ *
14
+ * 12 x new Intl.DateTimeFormat
15
+ * 1 x new Intl.NumberFormat
16
+ *
17
+ * Constructing an `Intl` formatter is one of the most expensive things a JS engine does: it
18
+ * resolves locale data and compiles a pattern. Routing those same constructions through this
19
+ * cache, changing nothing else, took the instance from **412.6us to 178us — a 57% cut**. For the
20
+ * reporter's grid that is roughly 99,000 formatter constructions that no longer happen.
21
+ *
22
+ * WHY A SHARED MODULE AND NOT A `useMemo` AT EACH CALL SITE. `useMemo` memoises per INSTANCE, and
23
+ * the instance is exactly what there are 8,262 of. Two rows formatting dates in the same locale
24
+ * want the same formatter object, and nothing below module scope can give them one.
25
+ *
26
+ * SAFETY. `Intl` formatters are immutable and stateless — `format()` mutates nothing — so a single
27
+ * instance is safely shared across every caller and every React tree, concurrent SSR included.
28
+ */
29
+ export declare function dateTimeFormat(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat;
30
+ export declare function numberFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat;
31
+ export declare function pluralRules(locale: string, options?: Intl.PluralRulesOptions): Intl.PluralRules;
32
+ export declare function listFormat(locale: string, options?: Intl.ListFormatOptions): Intl.ListFormat;
@@ -0,0 +1,29 @@
1
+ const MAX_ENTRIES = 256;
2
+ const cache = /* @__PURE__ */ new Map();
3
+ function memoise(kind, locale, options, make) {
4
+ const key = `${kind} ${locale} ${options === void 0 ? "" : JSON.stringify(options)}`;
5
+ const hit = cache.get(key);
6
+ if (hit !== void 0) return hit;
7
+ const made = make();
8
+ if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
9
+ cache.set(key, made);
10
+ return made;
11
+ }
12
+ function dateTimeFormat(locale, options) {
13
+ return memoise("dtf", locale, options, () => new Intl.DateTimeFormat(locale, options));
14
+ }
15
+ function numberFormat(locale, options) {
16
+ return memoise("nf", locale, options, () => new Intl.NumberFormat(locale, options));
17
+ }
18
+ function pluralRules(locale, options) {
19
+ return memoise("pr", locale, options, () => new Intl.PluralRules(locale, options));
20
+ }
21
+ function listFormat(locale, options) {
22
+ return memoise("lf", locale, options, () => new Intl.ListFormat(locale, options));
23
+ }
24
+ export {
25
+ dateTimeFormat,
26
+ listFormat,
27
+ numberFormat,
28
+ pluralRules
29
+ };
@@ -778,6 +778,17 @@ export type PickerDateFormatProp = string | Intl.DateTimeFormatOptions | ((date:
778
778
  * `value`/`defaultValue`/`onValueChange` rather than antd's `onChange`.
779
779
  */
780
780
  export type DatePickerBaseProp = FieldA11yProps & PickerChromeProp & {
781
+ /**
782
+ * The accessible NAME of the button that opens the calendar, for a screen where more than one
783
+ * date field exists. Default 「カレンダーを開く」 / "Open calendar" — correct for one picker on a
784
+ * page, and useless for three: a reader hears the same sentence three times with nothing to
785
+ * say which field each button belongs to (WCAG 2.2 SC 2.4.6, gh#551).
786
+ *
787
+ * Same axis, same shape, same reason as Select's `clearLabel`: name the control after the
788
+ * FIELD it serves — `開始日のカレンダーを開く`. The default is unchanged, so nothing moves until
789
+ * a caller says something.
790
+ */
791
+ triggerLabel?: string;
781
792
  /** Display format; native submission remains ISO. */
782
793
  format?: PickerDateFormatProp;
783
794
  /** Parser for a custom display function or Intl era display; ISO always remains accepted. */
@@ -83,6 +83,25 @@ export type SkeletonRowsProp = {
83
83
  rows?: number;
84
84
  columns?: number;
85
85
  };
86
+ /**
87
+ * The skeleton of a `Form columns={N}` — label + control PAIRS on the same grid the form uses,
88
+ * not the flat line list `SkeletonRows` draws (gh#552).
89
+ *
90
+ * Why it is its own component rather than `SkeletonRows` with better defaults: `SkeletonRows` has
91
+ * no idea a form field is two stacked things, so a consumer approximating one got the column count
92
+ * right and the inside of every cell wrong. Worse, "close enough" is a thing that DRIFTS — change
93
+ * the form's `columns` and forget the skeleton and the layout jumps again on load, with nothing
94
+ * red to say so. Sharing `ResponsiveGrid` is what stops that: one `columns` value, one ladder.
95
+ */
96
+ export type SkeletonFormProp = {
97
+ /** Columns of the form this stands in for — passed straight to `ResponsiveGrid`. */
98
+ columns?: number;
99
+ /** How many label+control pairs to draw. */
100
+ fields?: number;
101
+ /** Shimmer, matching the rest of the family. */
102
+ active?: boolean;
103
+ className?: string;
104
+ };
86
105
  /**
87
106
  * A skeleton line's MEASURE. `number` is read as pixels, matching antd's
88
107
  * `SkeletonParagraphProps["width"]` (components/skeleton/Paragraph.tsx); a string is any CSS length
@@ -1955,6 +1955,11 @@ export declare const COMPONENT_PROP_REGISTRY: {
1955
1955
  readonly file: "components/feedback.prop.ts";
1956
1956
  readonly vocabulary: readonly ["SizeProp", "ShapeProp", "ClassNameProp"];
1957
1957
  };
1958
+ readonly SkeletonFormProp: {
1959
+ readonly group: "feedback";
1960
+ readonly file: "components/feedback.prop.ts";
1961
+ readonly vocabulary: readonly ["ClassNameProp"];
1962
+ };
1958
1963
  readonly SkeletonInputProp: {
1959
1964
  readonly group: "feedback";
1960
1965
  readonly file: "components/feedback.prop.ts";
@@ -2254,6 +2254,11 @@ const COMPONENT_PROP_REGISTRY = {
2254
2254
  file: "components/feedback.prop.ts",
2255
2255
  vocabulary: ["SizeProp", "ShapeProp", "ClassNameProp"]
2256
2256
  },
2257
+ SkeletonFormProp: {
2258
+ group: "feedback",
2259
+ file: "components/feedback.prop.ts",
2260
+ vocabulary: ["ClassNameProp"]
2261
+ },
2257
2262
  SkeletonInputProp: {
2258
2263
  group: "feedback",
2259
2264
  file: "components/feedback.prop.ts",
@@ -216,6 +216,28 @@
216
216
  gap: var(--skeleton-cell-gap);
217
217
  }
218
218
 
219
+ .ui-skeleton-form-field {
220
+ display: grid;
221
+ grid-template-columns: minmax(0, 1fr);
222
+ gap: var(--space-stack-xs);
223
+ min-inline-size: 0;
224
+ }
225
+
226
+ .ui-skeleton-form-label {
227
+ block-size: var(--skeleton-caption-height);
228
+ inline-size: var(--skeleton-label-width);
229
+ max-inline-size: 100%;
230
+ border-radius: var(--skeleton-radius);
231
+ background: var(--skeleton-background, hsl(var(--muted)));
232
+ }
233
+
234
+ .ui-skeleton-form-control {
235
+ block-size: var(--control-height);
236
+ inline-size: 100%;
237
+ border-radius: var(--skeleton-element-radius);
238
+ background: var(--skeleton-background, hsl(var(--muted)));
239
+ }
240
+
219
241
  .ui-skeleton-table {
220
242
  overflow: hidden;
221
243
  border: 1px solid hsl(var(--border));
@@ -329,6 +329,10 @@
329
329
  padding: var(--card-space-body-y) var(--card-space-inset);
330
330
  }
331
331
 
332
+ [data-slot="card-content"][data-flush] [data-slot="tabs-panel"] {
333
+ padding-inline: 0;
334
+ }
335
+
332
336
  [data-slot="card-footer"] {
333
337
  display: flex;
334
338
  flex-wrap: wrap;
@@ -39,7 +39,17 @@
39
39
 
40
40
  @container responsive-grid (min-width: 40rem) {
41
41
  .ui-responsive-grid > .ui-form-field {
42
- grid-column: span var(--form-field-col-span, 1);
42
+ grid-column: span min(var(--responsive-grid-sm, 1), var(--form-field-col-span, 1));
43
+ }
44
+ }
45
+ @container responsive-grid (min-width: 48rem) {
46
+ .ui-responsive-grid > .ui-form-field {
47
+ grid-column: span min(var(--responsive-grid-md, 1), var(--form-field-col-span, 1));
48
+ }
49
+ }
50
+ @container responsive-grid (min-width: 64rem) {
51
+ .ui-responsive-grid > .ui-form-field {
52
+ grid-column: span min(var(--responsive-grid-lg, 1), var(--form-field-col-span, 1));
43
53
  }
44
54
  }
45
55
 
@@ -66,3 +66,8 @@
66
66
  --focus-field-shadow: 0 0 0 var(--focus-ring-glow-width)
67
67
  hsl(var(--focus-ring-glow-color) / var(--focus-ring-glow-alpha));
68
68
  }
69
+
70
+ :root[data-focus-outline="off"] {
71
+ --focus-outline: 0;
72
+ --focus-field-shadow: none;
73
+ }
@@ -108,9 +108,10 @@
108
108
  transparent 70%
109
109
  );
110
110
 
111
- --focus-outline: 0;
111
+ --focus-outline: 1;
112
112
  --focus-outline-weight: var(--stroke-hairline);
113
113
  --focus-outline-color: var(--focus-ring-color, var(--ring));
114
+
114
115
  --control-outline-width: calc(var(--stroke-md) * var(--focus-outline));
115
116
  --focus-outline-offset: 0px;
116
117
 
@@ -125,7 +126,8 @@
125
126
  --focus-ring-glow-color: var(--control-outline);
126
127
  --focus-ring-glow-alpha: var(--control-outline-alpha);
127
128
 
128
- --focus-field-shadow: none;
129
+ --focus-field-shadow: 0 0 0 var(--focus-ring-glow-width)
130
+ hsl(var(--focus-ring-glow-color) / var(--focus-ring-glow-alpha));
129
131
 
130
132
  --focus-ring-offset: var(--focus-outline-offset);
131
133
 
@@ -255,7 +257,6 @@
255
257
  --activity-interval: var(--duration-loop);
256
258
 
257
259
  --activity-stagger-step: 160ms;
258
-
259
260
  }
260
261
 
261
262
  .ui-scale-fixed {
@@ -306,7 +307,6 @@
306
307
  --space-chrome-y: var(--space-4);
307
308
  --space-chrome-gap: var(--space-2);
308
309
  --field-label-gap: var(--space-2);
309
-
310
310
  }
311
311
 
312
312
  .dark,
@@ -12,17 +12,17 @@ It changes no code by itself. It is the tie-breaker a reviewer points at.
12
12
 
13
13
  ## The layers, and who owns each
14
14
 
15
- | Layer | Authority | Status in this repo |
16
- | ------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
17
- | Interaction semantics, keyboard, ARIA | **WAI-ARIA APG** | already followed — 33 references in `src/` |
18
- | Behaviour primitives | **Radix** | already the implementation — 193 references |
19
- | Component composition shape | **shadcn** | already the structural convention — 23 references |
20
- | Component taxonomy / grouping | **Ant Design** groups | already the catalog shape: `data-entry`, `data-display`, `layout`, `feedback`, `navigation`, `general` — a naming precedent, nothing is installed |
21
- | Colour foundation | **SmartHR** | already the palette source — `--primary` = SmartHR MAIN `#0071bd`, `--foreground` = TEXT_BLACK, `--border` = BORDER |
22
- | **Derived colour — the interaction states hanging off each seed** | **Measured contrast (WCAG 2.2 / JIS X 8341-3)** | Authored in `src/tokens/derived.css`; no algorithm derives them. Four contrast suites read that file and hold every value to a threshold — see below |
23
- | **Japanese UI convention — density, JP typography, form patterns** | **SmartHR** | **NEW — this decision.** Extends SmartHR from "where the colours came from" to the authority for how a JP business screen behaves |
24
- | **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency) | **NEW — this decision.** The reference when a JP customer asks which standard a screen meets (JIS X 8341-3) |
25
- | **Spacing, density, type scale, information architecture** | **IBM Carbon** | **NEW — this decision** |
15
+ | Layer | Authority | Status in this repo |
16
+ | ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | Interaction semantics, keyboard, ARIA | **WAI-ARIA APG** | already followed — 33 references in `src/` |
18
+ | Behaviour primitives | **Radix** | already the implementation — 193 references |
19
+ | Component composition shape | **shadcn** | already the structural convention — 23 references |
20
+ | Component taxonomy / grouping | **Ant Design** groups | already the catalog shape: `data-entry`, `data-display`, `layout`, `feedback`, `navigation`, `general` — a naming precedent, nothing is installed |
21
+ | Colour foundation | **SmartHR** | already the palette source — `--primary` = SmartHR MAIN `#0071bd`, `--foreground` = TEXT_BLACK, `--border` = BORDER |
22
+ | **Derived colour — the interaction states hanging off each seed** | **Measured contrast (WCAG 2.2 / JIS X 8341-3)** | Authored in `src/tokens/derived.css`; no algorithm derives them. Four contrast suites read that file and hold every value to a threshold — see below |
23
+ | **Japanese UI convention — density, JP typography, form patterns** | **SmartHR** | **NEW — this decision.** Extends SmartHR from "where the colours came from" to the authority for how a JP business screen behaves |
24
+ | **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency) | **NEW — this decision.** The reference when a JP customer asks which standard a screen meets (JIS X 8341-3) |
25
+ | **Spacing, density, type scale, information architecture** | **IBM Carbon** | **NEW — this decision** |
26
26
 
27
27
  The first five were already true and merely unwritten. The last three are the choices being made
28
28
  here. Carbon fills the one layer that had no outside answer at all: page rhythm, table density, form layout,
@@ -197,10 +197,34 @@ rows or a totals row — which is how a consumer ends up hand-rolling a `<tfoot>
197
197
  in page CSS.
198
198
 
199
199
  **The rule: where antd names a capability, this library takes antd's name and antd's semantics.**
200
- The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
201
- `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) never
202
- from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
203
- of `start` inside rc-table itself).
200
+
201
+ > **Restated and widened by the repo owner on 2026-09-12, because the rule above was being read as
202
+ > advice rather than as the standard it is: Ant Design IS the standard. A missing capability is
203
+ > ported from antd 100% FIRST — its names, its props, its semantics — and improved afterwards.
204
+ > Not redesigned first, and not half-ported.**
205
+ >
206
+ > Two things forced the restatement, both measured rather than felt:
207
+ >
208
+ > - `Dialog` + `AlertDialog` ship **26** exports with **12 name-for-name pairs and 0 parts unique
209
+ > to `AlertDialog`**, whose entire difference is `role="alertdialog"` plus `isDismissable={false}`
210
+ > — two props the shell already takes. antd has exactly one `Modal`, where danger is `okType`
211
+ > and `Modal.confirm()`. The shape here came from Radix, silently, against this very rule
212
+ > (gh#567). A package-wide sweep over all 272 public exports found this is the ONLY such pair:
213
+ > `Skeleton.Avatar/Button/Input/Image/Node` looks identical in shape but is antd's own naming,
214
+ > so it is compliance, not drift.
215
+ > - Whole families arrived half-ported: Ant Design X without `Conversations`, `Attachments`,
216
+ > `ThoughtChain`, `Welcome` or `Actions` (gh#559); `FloatButton` never at all (gh#558);
217
+ > `Typography` reduced to `Text` + `Heading`, with `Paragraph`, `Link`, `copyable`, `editable`,
218
+ > `mark`, `keyboard` and `italic` simply absent.
219
+ >
220
+ > A deviation from antd is still allowed — the three below are — but it must be WRITTEN DOWN at
221
+ > the point of deviation. An undocumented deviation is a bug, and gh#567 is what that bug costs:
222
+ > a consumer forced to pick between the right ARIA role and a form it needs, and an accessibility
223
+ > decision pushed onto the party least able to make it.
224
+ > The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
225
+ > `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) — never
226
+ > from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
227
+ > of `start` inside rc-table itself).
204
228
 
205
229
  > Đọc sau 20.0.0: bản major ấy đã **gỡ `antd` khỏi devDependencies** cùng máy sinh màu của nó, và
206
230
  > `check:no-antd-runtime` canh cho nó không quay lại. Câu trên mô tả cách bề mặt prop này ĐƯỢC ĐỌC
@@ -240,12 +264,12 @@ to trust them had to.
240
264
  **The authority is now the measurement, not the derivation.** Four suites read `derived.css`
241
265
  directly and hold every value in it to a threshold this repo has already committed to:
242
266
 
243
- | suite | what it holds |
244
- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | suite | what it holds |
268
+ | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
245
269
  | `src/tokens/__tests__/focus-ring-contrast.test.ts` | the focus mark in both switch positions — ≥3:1 (WCAG 2.2 SC 1.4.11) on every surface a control sits on, and the halo proven to be decoration rather than the indicator |
246
- | `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it |
247
- | `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar |
248
- | `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes |
270
+ | `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it |
271
+ | `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar |
272
+ | `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes |
249
273
 
250
274
  The first three also pin each value as a literal, so an edit to `derived.css` alone turns CI red
251
275
  rather than quietly retinting the library. **That is a stronger claim than the generator made, not
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "23.2.1",
4
- "godxUiMcp": "23.2.1",
3
+ "version": "23.3.0",
4
+ "godxUiMcp": "23.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -20,7 +20,7 @@
20
20
  }
21
21
  },
22
22
  "sideEffects": false,
23
- "description": "@godxjp/ui shared React UI framework (shadcn + Radix + Tailwind v4).",
23
+ "description": "@godxjp/ui \u2014 shared React UI framework (shadcn + Radix + Tailwind v4).",
24
24
  "files": [
25
25
  "LICENSE",
26
26
  "dist",
@@ -397,7 +397,8 @@
397
397
  "check:number-input-step-target": "node scripts/check-number-input-step-target.mjs",
398
398
  "check:mcp-token-sync": "node scripts/gen-component-tokens.mjs --check",
399
399
  "check:email-token-sync": "node scripts/gen-email-tokens.mjs --check",
400
- "check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs"
400
+ "check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs",
401
+ "typecheck:mcp": "node node_modules/typescript-7/bin/tsc --noEmit -p mcp/tsconfig.json"
401
402
  },
402
403
  "peerDependencies": {
403
404
  "@hookform/resolvers": "^5.2.0",
@@ -412,11 +413,26 @@
412
413
  "zod": "^4.4.0"
413
414
  },
414
415
  "peerDependenciesMeta": {
415
- "recharts": {
416
+ "@hookform/resolvers": {
417
+ "optional": true
418
+ },
419
+ "@tanstack/react-query": {
416
420
  "optional": true
417
421
  },
418
422
  "playwright": {
419
423
  "optional": true
424
+ },
425
+ "react-hook-form": {
426
+ "optional": true
427
+ },
428
+ "react-router-dom": {
429
+ "optional": true
430
+ },
431
+ "recharts": {
432
+ "optional": true
433
+ },
434
+ "zod": {
435
+ "optional": true
420
436
  }
421
437
  },
422
438
  "dependencies": {
@@ -10,8 +10,17 @@
10
10
  * The guarantee is now structural: a file that exists but cannot be read, parsed, or recognised is
11
11
  * NEVER written to. We leave a `.godxjp-ui-suggested` sidecar next to it and say so.
12
12
  */
13
- import { createHash } from "node:crypto";
14
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import {
15
+ chmodSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ readFileSync,
19
+ renameSync,
20
+ statSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
15
24
  import { basename, dirname, join } from "node:path";
16
25
  import { fileURLToPath } from "node:url";
17
26
 
@@ -205,9 +214,31 @@ function readJson(path) {
205
214
  * write manufactured the precondition for the overwrite.
206
215
  */
207
216
  function writeFileAtomic(path, data) {
208
- const tmp = `${path}.godxjp-ui-tmp`;
209
- writeFileSync(tmp, data);
210
- renameSync(tmp, path);
217
+ // UNIQUE temp name. A fixed `${path}.godxjp-ui-tmp` is a shared mutable file: two installs
218
+ // running at once — a workspace installing packages in parallel is enough — write over each
219
+ // other's temp and `rename` whichever finished last.
220
+ const tmp = `${path}.godxjp-ui-tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
221
+ try {
222
+ writeFileSync(tmp, data);
223
+ // `rename` does NOT carry the target's permissions: the new file is born under the process
224
+ // umask. A `.mcp.json` the consumer had chmod 600 would come back 644 after the first sync,
225
+ // silently, because the CONTENT would be right. This happens on every write, not only under
226
+ // contention, which makes it the worse half of the two.
227
+ try {
228
+ chmodSync(tmp, statSync(path).mode & 0o7777);
229
+ } catch {
230
+ // No existing file (a create), or a filesystem that will not report/set the mode. Either
231
+ // way the default is correct and this must not abort the write.
232
+ }
233
+ renameSync(tmp, path);
234
+ } catch (error) {
235
+ try {
236
+ unlinkSync(tmp);
237
+ } catch {
238
+ // Nothing to clean up.
239
+ }
240
+ throw error;
241
+ }
211
242
  }
212
243
 
213
244
  /**
@@ -131,6 +131,20 @@ những thứ ấy trên PR. **Tới được bước này KHÔNG phải là đ
131
131
  một câu hỏi, không phải một chướng ngại. Nếu bạn tin cổng ấy sai thì nói ra và
132
132
  đưa số đo, đừng lặng lẽ sửa nó.
133
133
 
134
+ **VÀ ĐỪNG NGỒI CHỜ CI.** Đẩy nhánh, mở PR, rồi đi làm việc khác. Không có vòng lặp
135
+ `until … gh pr checks … sleep` nào cả. CI chạy là việc của CI; nếu cần theo dõi thì
136
+ mở một agent nền, đừng chặn người đang điều phối. Đo được ngày 12/09/2026: một lượt
137
+ ngồi poll bốn shard đã ăn hơn một tiếng đồng hồ của chủ dự án để nhìn một thanh tiến
138
+ trình, trong khi có việc khác đang xếp hàng.
139
+
140
+ **Và full suite thì chạy theo LỊCH, không nằm trong vòng lặp sửa code của ai.** Cùng
141
+ ngày, tôi thêm bốn shard vitest vào làn PR của kho DS và đặt chúng thành required
142
+ check, để bịt một khoảng trống có thật (hai commit vào `main` đỏ qua một làn nhanh
143
+ xanh). Khoảng trống là thật và phép đo trung thực — nhưng nó chỉ định giá **máy**.
144
+ Máy chưa bao giờ là phần đắt. Từ lúc ấy mọi PR phải chờ bốn shard mới merge được.
145
+ Đã hoàn nguyên. Một hàng rào làm người điều phối phải chờ không rẻ hơn một `main`
146
+ đỏ; nó chỉ dời chi phí sang con đường duy nhất không song song hoá được.
147
+
134
148
  ### Bước 6 — Khép vòng
135
149
 
136
150
  Phát hành → nâng gói ở consumer → **gỡ vá tạm** → **gỡ mọi chú thích "chờ
@@ -891,16 +891,47 @@ function staleOwnedRules() {
891
891
  const target = join(CWD, ".ai", "rules", "godxjp-ui.md");
892
892
  if (!existsSync(target)) return null;
893
893
 
894
- const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(readFileSync(target, "utf8"))?.[1];
894
+ // FOUR states, not two. `!stamped || !installed || stamped === installed` collapsed three very
895
+ // different situations into one silence, and only ONE of them is genuinely fine:
896
+ //
897
+ // no rule file, consumer never opted into the agent kit -> silent, and it MUST stay silent.
898
+ // Turning this into a finding would make a UI audit into a tool that nags every consumer to
899
+ // install an agent kit they did not ask for. (Handled by the existsSync above.)
900
+ // rule file present but carrying NO stamp -> compatibility UNKNOWN, say so.
901
+ // installed version unreadable -> compatibility UNKNOWN, say so.
902
+ // stamp != installed -> stale, the original finding.
903
+ const contents = readFileSync(target, "utf8");
904
+ const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(contents)?.[1];
905
+
895
906
  let installed;
896
907
  try {
897
908
  installed = JSON.parse(
898
909
  readFileSync(join(CWD, "node_modules", "@godxjp", "ui", "package.json"), "utf8"),
899
910
  ).version;
900
911
  } catch {
901
- return null;
912
+ installed = undefined;
902
913
  }
903
- if (!stamped || !installed || stamped === installed) return null;
914
+
915
+ if (!stamped || !installed) {
916
+ // A file the package OWNS, whose provenance cannot be established. That is not the same as
917
+ // "up to date", and reporting it as such is the whole class of bug this function exists for.
918
+ return {
919
+ file: ".ai/rules/godxjp-ui.md",
920
+ line: 1,
921
+ rule: "owned-rules-unknown",
922
+ severity: "warn",
923
+ message:
924
+ `This file is written by @godxjp/ui, but its version cannot be established ` +
925
+ `(${!stamped ? "the file carries no `<!-- godxjp-ui:version … -->` stamp" : "the installed package version could not be read"}). ` +
926
+ `An agent may be reading guidance from a different major. Refresh it with ` +
927
+ `\`npx @godxjp/ui init-agent\`, or delete the file if this project does not use the agent kit.`,
928
+ replacement: null,
929
+ standard: null,
930
+ snippet: (stamped ?? "(no stamp)") + " vs " + (installed ?? "(package version unreadable)"),
931
+ };
932
+ }
933
+
934
+ if (stamped === installed) return null;
904
935
 
905
936
  return {
906
937
  file: ".ai/rules/godxjp-ui.md",
@@ -1112,11 +1143,37 @@ const warnings = findings.filter((f) => f.severity === "warn");
1112
1143
  // "✓ No UI-standardization violations found." and exited 0 having read nothing at all.
1113
1144
  // …except under `--changed`, where "this branch touched no .tsx" is a clean run, not a
1114
1145
  // misconfigured path. Failing there would make the gate unusable on every backend-only commit.
1115
- if (filesScanned === 0 && CHANGED) {
1116
- if (!quiet && !asJson) console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1146
+ // "This branch touched no UI file" is a clean run — but ONLY if there is nothing else to say.
1147
+ //
1148
+ // `staleOwnedRules()` runs before the scan loop and pushes its finding into `findings`, and this
1149
+ // branch used to exit 0 regardless: a consumer whose package-owned rules were four majors out of
1150
+ // date got "✓ no .tsx/.jsx changed" and a clean exit on any commit that happened not to touch a
1151
+ // component. Under `--format json` it was worse — the exit came BEFORE anything was written, so
1152
+ // stdout was EMPTY and a CI step reading it could not tell that from a pass.
1153
+ //
1154
+ // Reproduced: rules stamped 19.6.0 against an installed 23.3.0, one README.md changed →
1155
+ // `✓ no .tsx/.jsx changed`, exit 0, empty JSON, while `ui-audit src --format json` on the same
1156
+ // tree at the same moment reported `owned-rules-stale`.
1157
+ //
1158
+ // Same defect as the `.jsx` one this file just fixed, one screen further down: a gate declaring
1159
+ // itself clean while holding a finding.
1160
+ /** `--changed` legitimately opened no file. NOT the same thing as a misconfigured scan path. */
1161
+ const changedNoFiles = CHANGED && filesScanned === 0;
1162
+
1163
+ if (changedNoFiles && findings.length === 0) {
1164
+ if (asJson) {
1165
+ // ALWAYS emit a valid document on the JSON path. This branch used to exit before writing
1166
+ // anything, so a CI step parsing stdout got an empty string and a zero exit.
1167
+ process.stdout.write(
1168
+ JSON.stringify({ summary: { errors: 0, warnings: 0 }, findings: [] }, null, 2) + "\n",
1169
+ );
1170
+ } else if (!quiet) {
1171
+ console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1172
+ }
1117
1173
  process.exit(0);
1118
1174
  }
1119
- if (filesScanned === 0) {
1175
+
1176
+ if (filesScanned === 0 && !CHANGED) {
1120
1177
  const message =
1121
1178
  `ui-audit scanned 0 files — none of [${SCAN_DIRS.join(", ")}] exists (or all were filtered). ` +
1122
1179
  `Pass the directories to scan, e.g. \`node scripts/ui-audit.mjs src docs\`. ` +
@@ -1131,7 +1188,7 @@ if (filesScanned === 0) {
1131
1188
  process.exitCode = 2;
1132
1189
  }
1133
1190
 
1134
- if (filesScanned === 0) {
1191
+ if (filesScanned === 0 && !changedNoFiles) {
1135
1192
  // already reported above
1136
1193
  } else if (asJson) {
1137
1194
  process.stdout.write(
@@ -1161,7 +1218,10 @@ if (filesScanned === 0) {
1161
1218
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
1162
1219
  }
1163
1220
  console.log(
1164
- `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset} across ${scannedFiles.join(", ")}.`,
1221
+ `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}` +
1222
+ (scannedFiles.length > 0
1223
+ ? ` across ${scannedFiles.join(", ")}.`
1224
+ : " — no UI file changed on this branch, but the findings above are not about a file."),
1165
1225
  );
1166
1226
  if (errors.length === 0 && warnings.length === 0) {
1167
1227
  console.log("✓ No UI-standardization violations found.");
@@ -1169,4 +1229,4 @@ if (filesScanned === 0) {
1169
1229
  }
1170
1230
 
1171
1231
  // See the note above --rules: exitCode, so a large JSON report drains fully.
1172
- if (filesScanned > 0) process.exitCode = errors.length > 0 ? 1 : 0;
1232
+ if (filesScanned > 0 || changedNoFiles) process.exitCode = errors.length > 0 ? 1 : 0;