@godxjp/ui 30.6.0 → 30.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,9 +160,22 @@ The framework ships colors, the type scale, the wa-iro palette, and (opt-in) bun
160
160
  @source "../views";
161
161
  ```
162
162
 
163
- ### Slim build — ship only the CSS you use
164
-
165
- `@godxjp/ui/styles` is the zero-config all-in-one (every component's CSS + bundled fonts). Managing fonts yourself (next/font, a system stack, an extension that must not ship font files)? Load the same layers without the faces:
163
+ ### Where the CSS weight actually is — the font faces, not the components
164
+
165
+ `@godxjp/ui/styles` is the zero-config all-in-one: every component's layers **plus 729 `@font-face`
166
+ declarations** for the bundled CJK faces. Those declarations are render-blocking CSS on every page,
167
+ separate from the font FILES (which load lazily, per glyph range). Measured on a fresh Vite 8 +
168
+ Tailwind v4 app using 15 components (gh#971):
169
+
170
+ | entry | CSS emitted | gzip |
171
+ | ------------------------ | ----------- | ---------- |
172
+ | `@godxjp/ui/styles` | 1,303,803 B | **367 KB** |
173
+ | `@godxjp/ui/styles/core` | 581,210 B | **86 KB** |
174
+
175
+ So if CSS weight matters, **the one change that moves it is the import line** — 77% of it is the face
176
+ declarations. `core` is not a per-component slice: it still carries every component's layers (about
177
+ 64 KB gzip for all ~165), it only drops the faces. Load fonts another way (next/font, a system stack,
178
+ your own `@font-face`) and take the same layers without them:
166
179
 
167
180
  ```css
168
181
  @import "@godxjp/ui/styles/core"; /* every component layer, no @font-face */
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 30.6.0.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 30.7.1.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -47,7 +47,7 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
47
47
  task is a task** — "build a settings page", "confirm a destructive delete", "a list page with
48
48
  filters" — start HERE, not at the components. Then fetch `patterns/<name>.json` for complete,
49
49
  copy-paste-ready code. A component index answers "does X exist"; it cannot answer "build Y".
50
- 1. `components-index.json` — 46 KB, all 173 components as name + group +
50
+ 1. `components-index.json` — 46 KB, all 175 components as name + group +
51
51
  tagline. Read this when you already know the SHAPE you need. Each entry may carry `absorbed`:
52
52
  names that **do not exist** and map to it — `Combobox`, `Autocomplete`, `CountrySelect` and
53
53
  `SearchSelect` are all `Select`. If you are about to hand-roll something, search this field
@@ -33,7 +33,7 @@
33
33
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
34
34
  },
35
35
  {
36
- "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
36
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost. `cursor` is EXACTLY the `nextCursor` your previous response returned for the same `query` (undefined on the first page) — the same in all three shapes, dropdown included; an offset or an opaque token both work. Before gh#964 the dropdown sent the page NUMBER instead, so an offset server read \"2\" as offset 2 and returned duplicate rows; if you worked around that, remove the workaround.",
37
37
  "name": "loadOptions",
38
38
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
39
39
  },
@@ -38,10 +38,6 @@
38
38
  31
39
39
  ],
40
40
  "storyPath": "feedback/Skeleton.stories.tsx",
41
- "subParts": [
42
- "SkeletonDetail",
43
- "SkeletonStat"
44
- ],
45
41
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article).",
46
42
  "usage": [
47
43
  "DO use Skeleton for a custom block when SkeletonRows/Table/Stat/Article do not match the final layout.",
@@ -0,0 +1,31 @@
1
+ {
2
+ "example": "import { SkeletonDetail } from \"@godxjp/ui/feedback\";\n\n<SkeletonDetail />",
3
+ "group": "feedback",
4
+ "importPath": "@godxjp/ui/feedback",
5
+ "name": "SkeletonDetail",
6
+ "props": [],
7
+ "related": [
8
+ "SkeletonStat — the same propless treatment for a KPI tile.",
9
+ "SkeletonRows — flat list lines, not label/value pairs.",
10
+ "SkeletonTable — tabular placeholder with a header band.",
11
+ "Skeleton — the bare block this preset is built from; reach for it when you need a shape this one does not draw.",
12
+ "Descriptions — the component this placeholder is standing in for."
13
+ ],
14
+ "rules": [
15
+ 3,
16
+ 31
17
+ ],
18
+ "storyPath": "feedback/Skeleton.stories.tsx",
19
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically.",
20
+ "usage": [
21
+ "DO use it while a single record's data resolves — a detail page, a drawer showing one entity, a Descriptions block.",
22
+ "DO leave it propless. It has no `rows` knob on purpose: a record page that loads with a different number of lines each time reads as a layout bug, and `Skeleton` is there for a bespoke shape.",
23
+ "DON'T use it for a list or a table — SkeletonRows draws flat lines, SkeletonTable draws a header band.",
24
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
25
+ ],
26
+ "useCases": [
27
+ "Detail page body before the record arrives",
28
+ "Drawer or sheet opened on a row while its full entity loads",
29
+ "Descriptions block placeholder"
30
+ ]
31
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "example": "import { SkeletonStat } from \"@godxjp/ui/feedback\";\nimport { ResponsiveGrid } from \"@godxjp/ui/layout\";\n\n<ResponsiveGrid columns={{ base: 1, sm: 2, lg: 4 }} gap=\"md\">\n {Array.from({ length: 4 }).map((_, i) => (\n <SkeletonStat key={i} />\n ))}\n</ResponsiveGrid>",
3
+ "group": "feedback",
4
+ "importPath": "@godxjp/ui/feedback",
5
+ "name": "SkeletonStat",
6
+ "props": [],
7
+ "related": [
8
+ "StatCard — the loaded component this stands in for; match the grid, not just the size.",
9
+ "SkeletonDetail — the same propless treatment for a record page.",
10
+ "SkeletonRows — flat list lines.",
11
+ "Skeleton — the bare block this preset is built from."
12
+ ],
13
+ "rules": [
14
+ 3,
15
+ 31
16
+ ],
17
+ "storyPath": "feedback/Skeleton.stories.tsx",
18
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive.",
19
+ "usage": [
20
+ "DO render one per StatCard while the figures load, inside the same grid the loaded tiles use.",
21
+ "DO leave it propless — it mirrors StatCard's three-line rhythm exactly, which is what stops the row jumping on hydration.",
22
+ "DON'T wrap it in a Card yourself if the loaded state uses StatCard; put it where the StatCard goes so the surface is identical.",
23
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
24
+ ],
25
+ "useCases": [
26
+ "Dashboard KPI row before the metrics query resolves",
27
+ "A single figure on a detail page loading separately from the record",
28
+ "Placeholder inside a ResponsiveGrid of StatCards"
29
+ ]
30
+ }
@@ -607,6 +607,16 @@
607
607
  "name": "Skeleton",
608
608
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article)."
609
609
  },
610
+ {
611
+ "group": "feedback",
612
+ "name": "SkeletonDetail",
613
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically."
614
+ },
615
+ {
616
+ "group": "feedback",
617
+ "name": "SkeletonStat",
618
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive."
619
+ },
610
620
  {
611
621
  "group": "feedback",
612
622
  "name": "SkeletonArticle",
@@ -6019,7 +6019,7 @@
6019
6019
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
6020
6020
  },
6021
6021
  {
6022
- "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
6022
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost. `cursor` is EXACTLY the `nextCursor` your previous response returned for the same `query` (undefined on the first page) — the same in all three shapes, dropdown included; an offset or an opaque token both work. Before gh#964 the dropdown sent the page NUMBER instead, so an offset server read \"2\" as offset 2 and returned duplicate rows; if you worked around that, remove the workaround.",
6023
6023
  "name": "loadOptions",
6024
6024
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
6025
6025
  },
@@ -11480,10 +11480,6 @@
11480
11480
  31
11481
11481
  ],
11482
11482
  "storyPath": "feedback/Skeleton.stories.tsx",
11483
- "subParts": [
11484
- "SkeletonDetail",
11485
- "SkeletonStat"
11486
- ],
11487
11483
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article).",
11488
11484
  "usage": [
11489
11485
  "DO use Skeleton for a custom block when SkeletonRows/Table/Stat/Article do not match the final layout.",
@@ -11497,6 +11493,67 @@
11497
11493
  "Inline metadata placeholder"
11498
11494
  ]
11499
11495
  },
11496
+ {
11497
+ "example": "import { SkeletonDetail } from \"@godxjp/ui/feedback\";\n\n<SkeletonDetail />",
11498
+ "group": "feedback",
11499
+ "importPath": "@godxjp/ui/feedback",
11500
+ "name": "SkeletonDetail",
11501
+ "props": [],
11502
+ "related": [
11503
+ "SkeletonStat — the same propless treatment for a KPI tile.",
11504
+ "SkeletonRows — flat list lines, not label/value pairs.",
11505
+ "SkeletonTable — tabular placeholder with a header band.",
11506
+ "Skeleton — the bare block this preset is built from; reach for it when you need a shape this one does not draw.",
11507
+ "Descriptions — the component this placeholder is standing in for."
11508
+ ],
11509
+ "rules": [
11510
+ 3,
11511
+ 31
11512
+ ],
11513
+ "storyPath": "feedback/Skeleton.stories.tsx",
11514
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically.",
11515
+ "usage": [
11516
+ "DO use it while a single record's data resolves — a detail page, a drawer showing one entity, a Descriptions block.",
11517
+ "DO leave it propless. It has no `rows` knob on purpose: a record page that loads with a different number of lines each time reads as a layout bug, and `Skeleton` is there for a bespoke shape.",
11518
+ "DON'T use it for a list or a table — SkeletonRows draws flat lines, SkeletonTable draws a header band.",
11519
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
11520
+ ],
11521
+ "useCases": [
11522
+ "Detail page body before the record arrives",
11523
+ "Drawer or sheet opened on a row while its full entity loads",
11524
+ "Descriptions block placeholder"
11525
+ ]
11526
+ },
11527
+ {
11528
+ "example": "import { SkeletonStat } from \"@godxjp/ui/feedback\";\nimport { ResponsiveGrid } from \"@godxjp/ui/layout\";\n\n<ResponsiveGrid columns={{ base: 1, sm: 2, lg: 4 }} gap=\"md\">\n {Array.from({ length: 4 }).map((_, i) => (\n <SkeletonStat key={i} />\n ))}\n</ResponsiveGrid>",
11529
+ "group": "feedback",
11530
+ "importPath": "@godxjp/ui/feedback",
11531
+ "name": "SkeletonStat",
11532
+ "props": [],
11533
+ "related": [
11534
+ "StatCard — the loaded component this stands in for; match the grid, not just the size.",
11535
+ "SkeletonDetail — the same propless treatment for a record page.",
11536
+ "SkeletonRows — flat list lines.",
11537
+ "Skeleton — the bare block this preset is built from."
11538
+ ],
11539
+ "rules": [
11540
+ 3,
11541
+ 31
11542
+ ],
11543
+ "storyPath": "feedback/Skeleton.stories.tsx",
11544
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive.",
11545
+ "usage": [
11546
+ "DO render one per StatCard while the figures load, inside the same grid the loaded tiles use.",
11547
+ "DO leave it propless — it mirrors StatCard's three-line rhythm exactly, which is what stops the row jumping on hydration.",
11548
+ "DON'T wrap it in a Card yourself if the loaded state uses StatCard; put it where the StatCard goes so the surface is identical.",
11549
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
11550
+ ],
11551
+ "useCases": [
11552
+ "Dashboard KPI row before the metrics query resolves",
11553
+ "A single figure on a detail page loading separately from the record",
11554
+ "Placeholder inside a ResponsiveGrid of StatCards"
11555
+ ]
11556
+ },
11500
11557
  {
11501
11558
  "example": "import { SkeletonArticle } from \"@godxjp/ui/feedback\";\n\n<SkeletonArticle avatar active paragraph={{ rows: 3 }} />",
11502
11559
  "group": "feedback",
package/agent/index.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "counts": {
3
3
  "anti-ai-tells": 26,
4
- "components": 173,
4
+ "components": 175,
5
5
  "patterns": 21,
6
6
  "rules": 50,
7
7
  "tokens": 2074,
@@ -10,7 +10,7 @@
10
10
  "files": [
11
11
  {
12
12
  "file": "components-index.json",
13
- "note": "46 KB — name + group + tagline for all 173. FETCH THIS FIRST, then fetch only the components you chose.",
13
+ "note": "46 KB — name + group + tagline for all 175. FETCH THIS FIRST, then fetch only the components you chose.",
14
14
  "url": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json"
15
15
  },
16
16
  {
@@ -48,7 +48,7 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.6.0/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.7.1/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
@@ -62,5 +62,5 @@
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "30.6.0"
65
+ "version": "30.7.1"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
- > A Japanese-enterprise React design system: 173 components, 2074 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.6.0.
3
+ > A Japanese-enterprise React design system: 175 components, 2074 design tokens,
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.7.1.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@30.6.0`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@30.7.1`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -15,7 +15,7 @@ that can only fetch URLs.
15
15
  ## Catalog
16
16
 
17
17
  - [patterns-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/patterns-index.json): 21 whole-task patterns (name, tagline, tags). Start here when the task is a TASK — "build a settings page" — then fetch `patterns/<name>.json` for complete code.
18
- - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 46 KB — all 173 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
18
+ - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 46 KB — all 175 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
19
19
  - [components/&lt;Name&gt;.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components/Select.json): one file per component (1 KB–34 KB, median 6 KB). Read the index, then fetch only the ones you chose — this is the selective route, and the reason you do not need the blob.
20
20
  - [components.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components.json): 1.2 MB — every entry in one file. Most URL fetchers truncate a response this size without saying so; prefer the per-component files.
21
21
  - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1760 `component` knobs.
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v30.6.0/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v30.7.1/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
package/agent/rules.json CHANGED
@@ -195,7 +195,7 @@
195
195
  "title": "Long text columns get an explicit width"
196
196
  },
197
197
  {
198
- "body": "Author and verify every page at 320–390px FIRST. Page sections are spaced by `PageContainer` itself (`--page-body-gap` between its direct children). Inside a section spacing comes only from `Flex` `gap` (vertical rhythm = `Flex direction=\"col\"`, control rows = the default `direction=\"row\"`) + `ResponsiveGrid columns={2|3|4}` (which collapse to a single column on narrow screens) — never raw `p-*` / `gap-*` / `space-*` utilities for page layout (the static audit rejects them). Styles are loaded whole: `@godxjp/ui/styles` or `styles/core`, never a hand-picked set of layers.",
198
+ "body": "Author and verify every page at 320–390px FIRST. Page sections are spaced by `PageContainer` itself (`--page-body-gap` between its direct children). Inside a section spacing comes only from `Flex` `gap` (vertical rhythm = `Flex direction=\"col\"`, control rows = the default `direction=\"row\"`) + `ResponsiveGrid columns={2|3|4}` (which collapse to a single column on narrow screens) — never raw `p-*` / `gap-*` / `space-*` utilities for page layout (the static audit rejects them). Styles are loaded whole: `@godxjp/ui/styles` or `styles/core`, never a hand-picked set of layers. They differ ONLY in the 729 bundled CJK `@font-face` declarations, and that difference is most of the CSS: 367 KB vs 86 KB gzip for a 15-component app (gh#971) — pick `styles/core` whenever the app loads fonts another way.",
199
199
  "number": 40,
200
200
  "title": "Pages are mobile-first"
201
201
  },
@@ -27,7 +27,7 @@ import {
27
27
  PREFERS_DARK_SCHEME_QUERY
28
28
  } from "./theme-axes.js";
29
29
  import { resolveDefaultTimeFormat } from "./time-format-labels.js";
30
- import { resolveDefaultTimezone } from "./timezones.js";
30
+ import { resolveDefaultTimezone, resolveHydrationSafeTimezone } from "./timezones.js";
31
31
  import {
32
32
  APP_REQUEST_HEADER_DATE_FORMAT,
33
33
  APP_REQUEST_HEADER_LOCALE,
@@ -53,10 +53,6 @@ function buildRequestHeaders(locale, timezone, timeFormat, dateFormat) {
53
53
  [APP_REQUEST_HEADER_DATE_FORMAT]: dateFormat
54
54
  };
55
55
  }
56
- function resolveHydrationSafeTimezone(defaultTimezone, systemTimezone) {
57
- if (defaultTimezone === "browser") return systemTimezone ?? "UTC";
58
- return resolveDefaultTimezone(defaultTimezone, systemTimezone);
59
- }
60
56
  function AppProvider({
61
57
  children,
62
58
  defaultLocale = "vi",
@@ -24,5 +24,20 @@ export declare function getTimezoneOffsetLabel(timezone: string, locale?: AppLoc
24
24
  export declare function formatTimezoneDisplayLabel(timezone: string, locale?: AppLocale): string;
25
25
  export declare function getTimezoneLabel(timezone: string, locale: AppLocale, fallbackLocale?: AppLocale): string;
26
26
  export declare function resolveDefaultTimezone(defaultTimezone: "browser" | "system" | (string & {}), systemTimezone?: string): string;
27
+ /**
28
+ * The timezone to use BEFORE the browser's is known — the first render, and anything that formats
29
+ * before AppProvider has synced (gh#968).
30
+ *
31
+ * `"browser"` answers UTC here, not the browser's zone: a server cannot know the browser's zone, so
32
+ * resolving it on the first render would render one time on the server and another on the client.
33
+ * AppProvider settles to the real browser zone in an effect afterwards.
34
+ *
35
+ * It lives here, not inside AppProvider, so the module-level datetime fallback (`lib/datetime/sync`)
36
+ * can call the SAME function. Before gh#968 that fallback hard-coded `Asia/Ho_Chi_Minh` while this
37
+ * answered UTC — two different answers to "nothing is configured yet". The Vietnamese one was the
38
+ * worse of the two because it looks right: a JST attendance timestamp read 2 hours off, and nothing
39
+ * about `01/05/2026 14:30` says so. UTC is at least visibly not the user's local time.
40
+ */
41
+ export declare function resolveHydrationSafeTimezone(defaultTimezone: "browser" | "system" | (string & {}), systemTimezone?: string): string;
27
42
  /** Vitest only — reset cached IANA list between cases. */
28
43
  export declare function resetIanaTimezoneCacheForTests(): void;
@@ -141,6 +141,10 @@ function resolveDefaultTimezone(defaultTimezone, systemTimezone) {
141
141
  if (defaultTimezone === "system") return systemTimezone ?? "UTC";
142
142
  return defaultTimezone;
143
143
  }
144
+ function resolveHydrationSafeTimezone(defaultTimezone, systemTimezone) {
145
+ if (defaultTimezone === "browser") return systemTimezone ?? "UTC";
146
+ return resolveDefaultTimezone(defaultTimezone, systemTimezone);
147
+ }
144
148
  function resetIanaTimezoneCacheForTests() {
145
149
  cachedAllTimezones = null;
146
150
  cachedTimezoneSet = null;
@@ -157,6 +161,7 @@ export {
157
161
  isValidIanaTimezone,
158
162
  resetIanaTimezoneCacheForTests,
159
163
  resolveDefaultTimezone,
164
+ resolveHydrationSafeTimezone,
160
165
  resolveTimezoneForIntl,
161
166
  resolveTimezonePickerOptions
162
167
  };
@@ -33,6 +33,32 @@ export type { RecordPickerProp, RecordPickerProp as RecordPickerProps };
33
33
  * hands over the labels it already has, and it is merged ahead of whatever loads later, so a chip
34
34
  * never renders as a raw id and never disappears when the query changes.
35
35
  */
36
+ /**
37
+ * Adapt RecordPicker's cursor contract to Select's page-numbered loader (gh#964).
38
+ *
39
+ * `loadOptions` is typed to receive the `nextCursor` the server returned last time — an offset, an
40
+ * opaque token, whatever the server minted. The inline and dialog shapes pass it straight back.
41
+ * This adapter used to pass `String(page)` instead, so page 2 reached the server as "2" and a
42
+ * consumer implementing the type as written read it as offset 2: duplicated and missing rows, with
43
+ * no error. The server's `nextCursor` was used only as a has-more flag.
44
+ *
45
+ * Select asks for pages by number, so the adapter remembers, per query, which cursor fetches which
46
+ * page: page 1 has none, and page N+1 is whatever page N's response said. A new query starts its
47
+ * own chain, so one query's cursor can never be sent with another's text — a user typing,
48
+ * deleting and retyping can make Select ask for page 2 of an earlier query after a later one began.
49
+ *
50
+ * The chains live in this closure, so they are discarded whenever `loadOptions` itself changes
51
+ * (the caller memoises on it): a cursor minted by one data source is meaningless to the next.
52
+ *
53
+ * @internal Exported for tests only; the package barrel re-exports RecordPicker by name, not this.
54
+ */
55
+ export declare function selectLoadOptionsFor(loadOptions: RecordPickerProp["loadOptions"]): (({ query, page }: {
56
+ query: string;
57
+ page: number;
58
+ }) => Promise<{
59
+ options: SearchSelectOptionProp[];
60
+ hasMore: boolean;
61
+ }>) | undefined;
36
62
  export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "defaultValue" | "onChange" | "value"> & {
37
63
  mode?: "single" | "multiple";
38
64
  shape?: "auto" | "inline";
@@ -21,6 +21,19 @@ import {
21
21
  import { Command, CommandGroup } from "./command.js";
22
22
  import { Input } from "./input.js";
23
23
  import { Select } from "./select.js";
24
+ function selectLoadOptionsFor(loadOptions) {
25
+ if (!loadOptions) return void 0;
26
+ const chains = /* @__PURE__ */ new Map();
27
+ return async ({ query, page }) => {
28
+ if (page === 1) chains.set(query, /* @__PURE__ */ new Map());
29
+ const chain = chains.get(query);
30
+ const cursor = page === 1 ? void 0 : chain?.get(page);
31
+ if (page > 1 && cursor === void 0) return { options: [], hasMore: false };
32
+ const r = await loadOptions({ query, filters: {}, cursor });
33
+ if (r.nextCursor !== void 0) chains.get(query)?.set(page + 1, r.nextCursor);
34
+ return { options: r.options, hasMore: r.nextCursor !== void 0 };
35
+ };
36
+ }
24
37
  const RecordPicker = React.forwardRef(
25
38
  function RecordPicker2({
26
39
  mode = "single",
@@ -57,17 +70,7 @@ const RecordPicker = React.forwardRef(
57
70
  },
58
71
  [controlled, isMultiple, onValueChange]
59
72
  );
60
- const selectLoadOptions = React.useMemo(
61
- () => loadOptions ? async ({ query, page }) => {
62
- const r = await loadOptions({
63
- query,
64
- filters: {},
65
- cursor: page > 1 ? String(page) : void 0
66
- });
67
- return { options: r.options, hasMore: r.nextCursor !== void 0 };
68
- } : void 0,
69
- [loadOptions]
70
- );
73
+ const selectLoadOptions = React.useMemo(() => selectLoadOptionsFor(loadOptions), [loadOptions]);
71
74
  const staticOptions = React.useMemo(
72
75
  () => options ? normalizeSelectOptions(options) : [],
73
76
  [options]
@@ -542,5 +545,6 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
542
545
  ] });
543
546
  });
544
547
  export {
545
- RecordPicker
548
+ RecordPicker,
549
+ selectLoadOptionsFor
546
550
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "30.6.0",
3
+ "version": "30.7.1",
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,
@@ -8,7 +8,7 @@ export type DatetimeContext = {
8
8
  dateFormat: AppDateFormat;
9
9
  };
10
10
  /** Sync module-level datetime prefs from AppProvider (mirrors syncI18nLocale). */
11
- export declare function syncDatetimeContext(partial: Pick<DatetimeContext, "locale" | "timezone" | "timeFormat" | "dateFormat"> & {
11
+ export declare function syncDatetimeContext(prefs: Pick<DatetimeContext, "locale" | "timezone" | "timeFormat" | "dateFormat"> & {
12
12
  dateFnsLocale?: Locale;
13
13
  }): void;
14
14
  export declare function getDatetimeContext(): Readonly<DatetimeContext>;
@@ -1,22 +1,23 @@
1
1
  import { getDateFnsLocale } from "../../app/locales.js";
2
2
  import { resolveDefaultDateFormat } from "../../app/date-format-labels.js";
3
+ import { resolveHydrationSafeTimezone } from "../../app/timezones.js";
3
4
  const DEFAULT_LOCALE = "vi";
4
5
  const defaultContext = () => ({
5
6
  locale: DEFAULT_LOCALE,
6
7
  dateFnsLocale: getDateFnsLocale(DEFAULT_LOCALE),
7
- timezone: "Asia/Ho_Chi_Minh",
8
+ timezone: resolveHydrationSafeTimezone("browser"),
8
9
  timeFormat: "24h",
9
10
  dateFormat: resolveDefaultDateFormat(DEFAULT_LOCALE)
10
11
  });
11
12
  let syncedContext = defaultContext();
12
13
  let liveRelativeFormattingEnabled = true;
13
- function syncDatetimeContext(partial) {
14
+ function syncDatetimeContext(prefs) {
14
15
  syncedContext = {
15
- locale: partial.locale,
16
- timezone: partial.timezone,
17
- timeFormat: partial.timeFormat,
18
- dateFormat: partial.dateFormat,
19
- dateFnsLocale: partial.dateFnsLocale ?? getDateFnsLocale(partial.locale)
16
+ locale: prefs.locale,
17
+ timezone: prefs.timezone,
18
+ timeFormat: prefs.timeFormat,
19
+ dateFormat: prefs.dateFormat,
20
+ dateFnsLocale: prefs.dateFnsLocale ?? getDateFnsLocale(prefs.locale)
20
21
  };
21
22
  }
22
23
  function getDatetimeContext() {
@@ -1170,7 +1170,15 @@ export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement
1170
1170
  onValueChange?: (value: string | string[] | null) => void;
1171
1171
  /** Static rows, client-filtered. Provide this OR `loadOptions`. */
1172
1172
  options?: (SearchSelectOptionProp | SelectOptionGroupProp)[];
1173
- /** Server fetcher. `filters` carries the consumer's own filter vocabulary. */
1173
+ /**
1174
+ * Server fetcher. `filters` carries the consumer's own filter vocabulary.
1175
+ *
1176
+ * `cursor` is EXACTLY the `nextCursor` your previous response returned for the same `query` —
1177
+ * an offset, an opaque token, whatever your server minted — and `undefined` for the first page.
1178
+ * The same in every shape: under `threshold` (dropdown), `inline`, and the dialog. Return no
1179
+ * `nextCursor` when the list is finished; that is what ends paging. (Before gh#964 the dropdown
1180
+ * sent the page NUMBER here instead, so an offset-based server read "2" as offset 2.)
1181
+ */
1174
1182
  loadOptions?: (params: {
1175
1183
  query: string;
1176
1184
  filters: Record<string, string>;
@@ -52,6 +52,16 @@
52
52
  border-width: var(--stroke-hairline);
53
53
  }
54
54
 
55
+ [data-slot="tabs-list"][data-variant="line"] {
56
+ position: relative;
57
+ z-index: 1;
58
+ }
59
+
60
+ [data-slot="tabs"]:has([data-slot="tabs-list"][data-variant="line"])
61
+ > :is([data-slot="tabs-panel"], [data-slot="tabs-content"]) {
62
+ isolation: isolate;
63
+ }
64
+
55
65
  [data-slot="tabs-list"][data-variant="line"] [data-slot="tabs-trigger"]::after {
56
66
  content: "";
57
67
  position: absolute;
@@ -60,7 +60,8 @@
60
60
  }
61
61
 
62
62
  .ui-table-row[data-tone] {
63
- background-image: linear-gradient(
63
+
64
+ --table-row-tone-layer: linear-gradient(
64
65
  var(
65
66
  --table-row-tone-surface,
66
67
  hsl(var(--table-row-tone-color) / var(--table-row-tone-wash-alpha))
@@ -70,6 +71,7 @@
70
71
  hsl(var(--table-row-tone-color) / var(--table-row-tone-wash-alpha))
71
72
  )
72
73
  );
74
+ background-image: var(--table-row-tone-layer);
73
75
  }
74
76
 
75
77
  :where([data-slot="table"] > tbody > tr:nth-child(even of :not([data-expanded-row]))) {
@@ -440,48 +442,60 @@
440
442
  box-shadow: 6px 0 6px -5px hsl(var(--foreground) / 0.12);
441
443
  }
442
444
 
445
+ :where(.ui-table-row[data-tone]) > :is(.ui-data-table-pin-end, .ui-data-table-pin-start) {
446
+ background-image: var(--table-row-tone-layer);
447
+ }
448
+
443
449
  :where([data-slot="table"] > tbody > tr:nth-child(even of :not([data-expanded-row])))
444
450
  > :is(.ui-data-table-pin-end, .ui-data-table-pin-start) {
445
- background-image: linear-gradient(
446
- color-mix(
447
- in srgb,
448
- var(--table-row-striped-background, hsl(var(--muted) / 0.8)) var(--table-row-striped-alpha),
449
- transparent
450
- ),
451
- color-mix(
452
- in srgb,
453
- var(--table-row-striped-background, hsl(var(--muted) / 0.8)) var(--table-row-striped-alpha),
454
- transparent
455
- )
456
- );
451
+ background-image:
452
+ var(--table-row-tone-layer, none),
453
+ linear-gradient(
454
+ color-mix(
455
+ in srgb,
456
+ var(--table-row-striped-background, hsl(var(--muted) / 0.8))
457
+ var(--table-row-striped-alpha),
458
+ transparent
459
+ ),
460
+ color-mix(
461
+ in srgb,
462
+ var(--table-row-striped-background, hsl(var(--muted) / 0.8))
463
+ var(--table-row-striped-alpha),
464
+ transparent
465
+ )
466
+ );
457
467
  }
458
468
 
459
469
  .ui-data-table-surface tbody tr:hover > .ui-data-table-pin-end,
460
470
  .ui-data-table-surface tbody tr:hover > .ui-data-table-pin-start {
461
- background-image: linear-gradient(
462
- var(
463
- --table-row-hover-background,
464
- hsl(var(--accent) / var(--table-row-hover-background-alpha, 0.7))
465
- ),
466
- var(
467
- --table-row-hover-background,
468
- hsl(var(--accent) / var(--table-row-hover-background-alpha, 0.7))
469
- )
470
- );
471
+ background-image:
472
+ var(--table-row-tone-layer, none),
473
+ linear-gradient(
474
+ var(
475
+ --table-row-hover-background,
476
+ hsl(var(--accent) / var(--table-row-hover-background-alpha, 0.7))
477
+ ),
478
+ var(
479
+ --table-row-hover-background,
480
+ hsl(var(--accent) / var(--table-row-hover-background-alpha, 0.7))
481
+ )
482
+ );
471
483
  }
472
484
 
473
485
  .ui-data-table-surface tbody tr[data-state="selected"] > .ui-data-table-pin-end,
474
486
  .ui-data-table-surface tbody tr[data-state="selected"] > .ui-data-table-pin-start {
475
- background-image: linear-gradient(
476
- var(
477
- --table-row-selected-background,
478
- hsl(var(--muted) / var(--table-row-selected-background-alpha, 0.3))
479
- ),
480
- var(
481
- --table-row-selected-background,
482
- hsl(var(--muted) / var(--table-row-selected-background-alpha, 0.3))
483
- )
484
- );
487
+ background-image:
488
+ var(--table-row-tone-layer, none),
489
+ linear-gradient(
490
+ var(
491
+ --table-row-selected-background,
492
+ hsl(var(--muted) / var(--table-row-selected-background-alpha, 0.3))
493
+ ),
494
+ var(
495
+ --table-row-selected-background,
496
+ hsl(var(--muted) / var(--table-row-selected-background-alpha, 0.3))
497
+ )
498
+ );
485
499
  }
486
500
 
487
501
  .ui-data-table-ellipsis {
@@ -3,8 +3,8 @@
3
3
  > Generated by `scripts/frame-coverage.mjs` (issue #163). Do not edit by hand — run `pnpm check:frame-coverage`.
4
4
  > Standard: [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md). A missing dimension is **UNTESTED**, never a pass.
5
5
 
6
- - Public components: **173**
7
- - With ≥1 frame: **152** · zero-frame: **21**
6
+ - Public components: **175**
7
+ - With ≥1 frame: **152** · zero-frame: **23**
8
8
  - Fully covered (every contract axis declared): **0**
9
9
 
10
10
  ## UNTESTED — no `/frame/**` route
@@ -22,6 +22,8 @@
22
22
  - [ ] `ServiceCatalogCta`
23
23
  - [ ] `SkeletonRows`
24
24
  - [ ] `OverlayPortalProvider`
25
+ - [ ] `SkeletonDetail`
26
+ - [ ] `SkeletonStat`
25
27
  - [ ] `SkeletonArticle`
26
28
  - [ ] `SkeletonAvatar`
27
29
  - [ ] `SkeletonButton`
@@ -154,6 +156,8 @@ Legend: ✓ covered · N/A reasoned skip · · UNTESTED (blank cell = UNTESTED).
154
156
  | Avatar | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
155
157
  | Separator | ✓ | · | N/A | N/A | · | · | · | · | · | · |
156
158
  | Skeleton | ✓ | N/A | · | N/A | · | · | · | · | · | · |
159
+ | SkeletonDetail | **✗** | N/A | N/A | N/A | · | · | · | · | · | · |
160
+ | SkeletonStat | **✗** | N/A | N/A | N/A | · | · | · | · | · | · |
157
161
  | SkeletonArticle | **✗** | N/A | · | N/A | · | · | · | · | · | · |
158
162
  | SkeletonAvatar | **✗** | ✓ | N/A | N/A | · | · | · | · | · | · |
159
163
  | SkeletonButton | **✗** | ✓ | N/A | N/A | · | · | · | · | · | · |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.6.0",
4
- "godxUiMcp": "30.6.0",
3
+ "version": "30.7.1",
4
+ "godxUiMcp": "30.7.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",