@godxjp/ui 31.15.0 → 31.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 31.15.0.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 31.16.0.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -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` — 48 KB, all 178 components as name + group +
50
+ 1. `components-index.json` — 48 KB, all 179 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
@@ -56,10 +56,10 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
56
56
  its `importPath`, and its examples. Fetch only the handful you picked in step 1.
57
57
  3. `rules.json` — 50 cardinal rules. The ones about raw HTML and hardcoded colour are not
58
58
  style advice.
59
- 4. `tokens.json` — 2121 design tokens, each tagged with its `tier`. **If you were handed a
59
+ 4. `tokens.json` — 2126 design tokens, each tagged with its `tier`. **If you were handed a
60
60
  brand, read the 211 `foundation` entries first** — `--primary`, `--background`,
61
61
  `--radius`, `--font-size-base` are the handful everything else derives from. The
62
- 1806 `component` entries are per-part knobs; reach for one only when a role is
62
+ 1811 `component` entries are per-part knobs; reach for one only when a role is
63
63
  right everywhere except one component.
64
64
  5. `anti-ai-tells.json` — 26 shapes that make generated UI look generated, each with the
65
65
  fix. Read before you reach for a gradient hero or a wall of coloured chips.
@@ -145,7 +145,7 @@ has stopped following the brand.
145
145
  |---|---|---|---|
146
146
  | `foundation` | 211 | the seeds — `--primary`, `--background`, `--foreground`, `--radius`, `--font-size-base`, `--shadow-color`. Everything below is derived from these | **yes — this is the main road.** Handed a brand colour, this is where it goes: `:root { --primary: <H> <S>% <L>%; }` (HSL components, no `hsl()` wrapper) |
147
147
  | `semantic` | 104 | named roles that follow the seeds — `--ring`, `--text-link`, `--primary-hover`, `--overlay-background` | only when the seed is right and ONE role must differ. That role then stops following a later brand change |
148
- | `component` | 1806 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
148
+ | `component` | 1811 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
149
149
 
150
150
  A token whose `value` is `initial` is not empty and not broken: `initial` is the guaranteed-invalid
151
151
  value, so the real default is computed where the element paints it. Set it and yours wins.
@@ -0,0 +1,86 @@
1
+ {
2
+ "example": "import { TextDiff } from \"@godxjp/ui/data-display\";\n\n<TextDiff before={row.previous_original} after={original} lang=\"ja\" />\n\n<TextDiff mode=\"split\" granularity=\"line\" before={previousBody} after={body} />",
3
+ "group": "data-display",
4
+ "importPath": "@godxjp/ui/data-display",
5
+ "name": "TextDiff",
6
+ "props": [
7
+ {
8
+ "description": "The earlier text (the original a translation was written against, the saved version).",
9
+ "name": "before",
10
+ "required": true,
11
+ "type": "string"
12
+ },
13
+ {
14
+ "description": "The current text.",
15
+ "name": "after",
16
+ "required": true,
17
+ "type": "string"
18
+ },
19
+ {
20
+ "defaultValue": "\"inline\"",
21
+ "description": "inline — one flow with removals and additions in place (GitHub's unified view). split — two columns, before | after, with changed hunks aligned row by row and localized column headings.",
22
+ "name": "mode",
23
+ "type": "\"inline\" | \"split\""
24
+ },
25
+ {
26
+ "defaultValue": "\"auto\"",
27
+ "description": "The unit a change is measured in. auto = Intl.Segmenter words, with Han/Hiragana/Katakana/Hangul runs cut per character (no spaces to split on, and a dictionary word boundary moves when one particle changes). word = Intl.Segmenter words in `lang`. char = one grapheme cluster per token. line = whole lines.",
28
+ "name": "granularity",
29
+ "type": "\"auto\" | \"word\" | \"char\" | \"line\""
30
+ },
31
+ {
32
+ "defaultValue": "true",
33
+ "description": "Fold long unchanged runs of lines behind a localized 'show N unchanged lines' button.",
34
+ "name": "collapseUnchanged",
35
+ "type": "boolean"
36
+ },
37
+ {
38
+ "defaultValue": "2",
39
+ "description": "Unchanged lines kept visible beside each change when folding (git's -U<n>).",
40
+ "name": "contextLines",
41
+ "type": "number"
42
+ },
43
+ {
44
+ "defaultValue": "4000000",
45
+ "description": "LCS cost cap (tokens before × tokens after). Past it the changed middle is one removal plus one addition instead of freezing the tab.",
46
+ "name": "maxCells",
47
+ "type": "number"
48
+ },
49
+ {
50
+ "description": "Heading of the before column in split mode. Default: localized 'Before'.",
51
+ "name": "beforeLabel",
52
+ "type": "ReactNode"
53
+ },
54
+ {
55
+ "description": "Heading of the after column in split mode. Default: localized 'After'.",
56
+ "name": "afterLabel",
57
+ "type": "ReactNode"
58
+ },
59
+ {
60
+ "description": "BCP-47 language of the text — set on the element (screen-reader pronunciation, CJK font selection) and used as the Intl.Segmenter locale.",
61
+ "name": "lang",
62
+ "type": "string"
63
+ }
64
+ ],
65
+ "related": [
66
+ "CodeBlock — shows ONE preformatted text; TextDiff shows how two versions differ.",
67
+ "Text — `delete` / `mark` style a run you already chose; TextDiff decides the runs and announces them.",
68
+ "Timeline — the list of revisions; TextDiff is what one revision changed."
69
+ ],
70
+ "rules": [],
71
+ "storyPath": "data-display/TextDiff.stories.tsx",
72
+ "tagline": "What changed between two versions of a text — removed runs in <del>, added runs in <ins>, inline or side by side, per line / word / character, with CJK cut per character and long unchanged runs folded.",
73
+ "usage": [
74
+ "DO import from `@godxjp/ui/data-display`: `import { TextDiff } from \"@godxjp/ui/data-display\";`",
75
+ "DO pass the two raw strings — the component tokenizes, diffs (LCS) and renders. Use the exported `diffText(before, after, { granularity, lang })` only when you need the runs themselves (a change count, a plain-text export).",
76
+ "DO pass `lang` for the text's language, not the UI's: a Japanese original in an English UI still needs lang=\"ja\".",
77
+ "DO use mode=\"split\" when there is room for two columns and the texts are long; inline for a drawer, a list row or a narrow panel.",
78
+ "DON'T hand-roll a diff from `Text delete` / `Text mark` — those carry no spoken start/end label, so a screen reader reads the old and new words run together.",
79
+ "DON'T colour a run without its mark: removals are struck through and additions underlined, plus a visually hidden start/end label — colour is never the only carrier (WCAG 1.4.1)."
80
+ ],
81
+ "useCases": [
82
+ "A translation drawer: what changed in the original since this translation was written (old original → current original).",
83
+ "A document or wiki page revision history: one revision against the previous one.",
84
+ "A settings or template audit log: the previous value of a long text field against the new one."
85
+ ]
86
+ }
@@ -251,6 +251,11 @@
251
251
  "name": "Swatch",
252
252
  "tagline": "A READ-ONLY sample of ONE colour a person chose — a brand's primary_color, a calendar category, a tag tint. The colour is a VALUE on the element, never a token in a stylesheet."
253
253
  },
254
+ {
255
+ "group": "data-display",
256
+ "name": "TextDiff",
257
+ "tagline": "What changed between two versions of a text — removed runs in <del>, added runs in <ins>, inline or side by side, per line / word / character, with CJK cut per character and long unchanged runs folded."
258
+ },
254
259
  {
255
260
  "group": "data-display",
256
261
  "name": "FeatureList",
@@ -4209,6 +4209,92 @@
4209
4209
  "A table cell whose column is 'colour' — one sample per row, named by the row header."
4210
4210
  ]
4211
4211
  },
4212
+ {
4213
+ "example": "import { TextDiff } from \"@godxjp/ui/data-display\";\n\n<TextDiff before={row.previous_original} after={original} lang=\"ja\" />\n\n<TextDiff mode=\"split\" granularity=\"line\" before={previousBody} after={body} />",
4214
+ "group": "data-display",
4215
+ "importPath": "@godxjp/ui/data-display",
4216
+ "name": "TextDiff",
4217
+ "props": [
4218
+ {
4219
+ "description": "The earlier text (the original a translation was written against, the saved version).",
4220
+ "name": "before",
4221
+ "required": true,
4222
+ "type": "string"
4223
+ },
4224
+ {
4225
+ "description": "The current text.",
4226
+ "name": "after",
4227
+ "required": true,
4228
+ "type": "string"
4229
+ },
4230
+ {
4231
+ "defaultValue": "\"inline\"",
4232
+ "description": "inline — one flow with removals and additions in place (GitHub's unified view). split — two columns, before | after, with changed hunks aligned row by row and localized column headings.",
4233
+ "name": "mode",
4234
+ "type": "\"inline\" | \"split\""
4235
+ },
4236
+ {
4237
+ "defaultValue": "\"auto\"",
4238
+ "description": "The unit a change is measured in. auto = Intl.Segmenter words, with Han/Hiragana/Katakana/Hangul runs cut per character (no spaces to split on, and a dictionary word boundary moves when one particle changes). word = Intl.Segmenter words in `lang`. char = one grapheme cluster per token. line = whole lines.",
4239
+ "name": "granularity",
4240
+ "type": "\"auto\" | \"word\" | \"char\" | \"line\""
4241
+ },
4242
+ {
4243
+ "defaultValue": "true",
4244
+ "description": "Fold long unchanged runs of lines behind a localized 'show N unchanged lines' button.",
4245
+ "name": "collapseUnchanged",
4246
+ "type": "boolean"
4247
+ },
4248
+ {
4249
+ "defaultValue": "2",
4250
+ "description": "Unchanged lines kept visible beside each change when folding (git's -U<n>).",
4251
+ "name": "contextLines",
4252
+ "type": "number"
4253
+ },
4254
+ {
4255
+ "defaultValue": "4000000",
4256
+ "description": "LCS cost cap (tokens before × tokens after). Past it the changed middle is one removal plus one addition instead of freezing the tab.",
4257
+ "name": "maxCells",
4258
+ "type": "number"
4259
+ },
4260
+ {
4261
+ "description": "Heading of the before column in split mode. Default: localized 'Before'.",
4262
+ "name": "beforeLabel",
4263
+ "type": "ReactNode"
4264
+ },
4265
+ {
4266
+ "description": "Heading of the after column in split mode. Default: localized 'After'.",
4267
+ "name": "afterLabel",
4268
+ "type": "ReactNode"
4269
+ },
4270
+ {
4271
+ "description": "BCP-47 language of the text — set on the element (screen-reader pronunciation, CJK font selection) and used as the Intl.Segmenter locale.",
4272
+ "name": "lang",
4273
+ "type": "string"
4274
+ }
4275
+ ],
4276
+ "related": [
4277
+ "CodeBlock — shows ONE preformatted text; TextDiff shows how two versions differ.",
4278
+ "Text — `delete` / `mark` style a run you already chose; TextDiff decides the runs and announces them.",
4279
+ "Timeline — the list of revisions; TextDiff is what one revision changed."
4280
+ ],
4281
+ "rules": [],
4282
+ "storyPath": "data-display/TextDiff.stories.tsx",
4283
+ "tagline": "What changed between two versions of a text — removed runs in <del>, added runs in <ins>, inline or side by side, per line / word / character, with CJK cut per character and long unchanged runs folded.",
4284
+ "usage": [
4285
+ "DO import from `@godxjp/ui/data-display`: `import { TextDiff } from \"@godxjp/ui/data-display\";`",
4286
+ "DO pass the two raw strings — the component tokenizes, diffs (LCS) and renders. Use the exported `diffText(before, after, { granularity, lang })` only when you need the runs themselves (a change count, a plain-text export).",
4287
+ "DO pass `lang` for the text's language, not the UI's: a Japanese original in an English UI still needs lang=\"ja\".",
4288
+ "DO use mode=\"split\" when there is room for two columns and the texts are long; inline for a drawer, a list row or a narrow panel.",
4289
+ "DON'T hand-roll a diff from `Text delete` / `Text mark` — those carry no spoken start/end label, so a screen reader reads the old and new words run together.",
4290
+ "DON'T colour a run without its mark: removals are struck through and additions underlined, plus a visually hidden start/end label — colour is never the only carrier (WCAG 1.4.1)."
4291
+ ],
4292
+ "useCases": [
4293
+ "A translation drawer: what changed in the original since this translation was written (old original → current original).",
4294
+ "A document or wiki page revision history: one revision against the previous one.",
4295
+ "A settings or template audit log: the previous value of a long text field against the new one."
4296
+ ]
4297
+ },
4212
4298
  {
4213
4299
  "docPath": "data-display/feature-list.tsx",
4214
4300
  "example": "import { FeatureList } from \"@godxjp/ui/data-display\";\n\n<FeatureList\n items={[\n { state: \"included\", label: \"SSO\", description: \"SAML と OIDC\" },\n { state: \"limited\", label: \"API 呼び出し\", description: \"月 10,000 回まで\" },\n { state: \"excluded\", label: \"監査ログのエクスポート\" },\n ]}\n/>",
package/agent/index.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "counts": {
3
3
  "anti-ai-tells": 26,
4
- "components": 178,
4
+ "components": 179,
5
5
  "patterns": 21,
6
6
  "rules": 50,
7
- "tokens": 2121,
7
+ "tokens": 2126,
8
8
  "vocabulary": 14
9
9
  },
10
10
  "files": [
11
11
  {
12
12
  "file": "components-index.json",
13
- "note": "48 KB — name + group + tagline for all 178. FETCH THIS FIRST, then fetch only the components you chose.",
13
+ "note": "48 KB — name + group + tagline for all 179. 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,19 +48,19 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v31.15.0/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v31.16.0/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
55
55
  "tokenTiers": {
56
56
  "component": "per-part knobs, --{component}-{part}-{property}; usually leave these alone",
57
57
  "counts": {
58
- "component": 1806,
58
+ "component": 1811,
59
59
  "foundation": 211,
60
60
  "semantic": 104
61
61
  },
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "31.15.0"
65
+ "version": "31.16.0"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
- > A Japanese-enterprise React design system: 178 components, 2121 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 31.15.0.
3
+ > A Japanese-enterprise React design system: 179 components, 2126 design tokens,
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 31.16.0.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@31.15.0`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@31.16.0`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -15,10 +15,10 @@ 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): 48 KB — all 178 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): 48 KB — all 179 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–36 KB, median 6 KB). Read the index, then fetch only the ones you chose — this is the selective route, and the reason you do not need the blob.
20
20
  - [components.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components.json): 1.2 MB — every entry in one file. Most URL fetchers truncate a response this size without saying so; prefer the per-component files.
21
- - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 104 `semantic` roles, 1806 `component` knobs.
21
+ - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 104 `semantic` roles, 1811 `component` knobs.
22
22
  - [vocabulary.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/vocabulary.json): the controlled prop vocabulary — which prop name means what, across every component.
23
23
  - [rules.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/rules.json): 50 cardinal rules.
24
24
  - [anti-ai-tells.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/anti-ai-tells.json): 26 shapes that make generated UI look generated, each with its fix.
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v31.15.0/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v31.16.0/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
package/agent/tokens.json CHANGED
@@ -5651,6 +5651,36 @@
5651
5651
  "tier": "component",
5652
5652
  "value": "initial"
5653
5653
  },
5654
+ {
5655
+ "description": "TextDiff (gh#1096) — the grounds under a removed and an added run. The INK stays the text colour: the mark that carries the meaning is the strike-through / underline plus a spoken label, so the tint only has to separate the run from its neighbours. The grounds read the status surface role (gh#866) and fall back to the tone at these alphas.",
5656
+ "name": "--text-diff-removed-background-alpha",
5657
+ "tier": "component",
5658
+ "value": "0.12"
5659
+ },
5660
+ {
5661
+ "description": "Data-display component tokens — small-by-design text knobs (rule #45/#46).",
5662
+ "name": "--text-diff-added-background-alpha",
5663
+ "tier": "component",
5664
+ "value": "0.16"
5665
+ },
5666
+ {
5667
+ "description": "The underline/strike weight — the resting hairline, so it survives forced-colors as ink.",
5668
+ "name": "--text-diff-mark-line-width",
5669
+ "tier": "component",
5670
+ "value": "var(--stroke-hairline)"
5671
+ },
5672
+ {
5673
+ "description": "Space between the before and after columns in split mode, and around a folded run.",
5674
+ "name": "--text-diff-space-gap",
5675
+ "tier": "component",
5676
+ "value": "var(--space-3)"
5677
+ },
5678
+ {
5679
+ "description": "Data-display component tokens — small-by-design text knobs (rule #45/#46).",
5680
+ "name": "--text-diff-space-inset",
5681
+ "tier": "component",
5682
+ "value": "var(--space-2)"
5683
+ },
5654
5684
  {
5655
5685
  "description": "Timeline accents — `initial` so the dot/line role defaults re-resolve under a scoped theme. ONE HUE down the whole progress column (gh#731): done, current and the travelled line all default to hsl(var(--primary)), the way Steps paints `finish` and `process`; current is told apart by its ring, not by a second role. To restore the pre-27.9 green/violet pairing, set the done dot's FILL and its INK together — the two roles have opposite ink polarity, so the fill alone leaves a near-white glyph on 若竹 green at 2.19:1 (gh#643): --timeline-dot-done-background: hsl(var(--success)); --timeline-dot-done-foreground: hsl(var(--success-foreground));",
5656
5686
  "name": "--timeline-dot-done-background",
@@ -31,6 +31,8 @@ export { EmptyState } from "./empty-state.js";
31
31
  export { Progress } from "./progress.js";
32
32
  export { CodeBlock } from "./code-block.js";
33
33
  export type { CodeBlockProp, CodeBlockProps } from "./code-block.js";
34
+ export { TextDiff, diffText, tokenizeText } from "./text-diff.js";
35
+ export type { TextDiffGranularity, TextDiffProp, TextDiffProps, TextDiffSegment, } from "./text-diff.js";
34
36
  export { Prose } from "./prose.js";
35
37
  export type { ProseProp, ProseProps } from "./prose.js";
36
38
  export type { ProgressProps, ProgressSegment, ProgressTone } from "./progress.js";
@@ -31,6 +31,7 @@ import { DataTable, flexRender } from "./data-table.js";
31
31
  import { EmptyState } from "./empty-state.js";
32
32
  import { Progress } from "./progress.js";
33
33
  import { CodeBlock } from "./code-block.js";
34
+ import { TextDiff, diffText, tokenizeText } from "./text-diff.js";
34
35
  import { Prose } from "./prose.js";
35
36
  import { Timeline } from "./timeline.js";
36
37
  import { TimelineGrid } from "./timeline-grid.js";
@@ -134,6 +135,7 @@ export {
134
135
  TableHead,
135
136
  TableHeader,
136
137
  TableRow,
138
+ TextDiff,
137
139
  ThoughtChain,
138
140
  ThoughtChainItem,
139
141
  Thumbnail,
@@ -141,6 +143,8 @@ export {
141
143
  TimelineGrid,
142
144
  Tree,
143
145
  Welcome,
146
+ diffText,
144
147
  flexRender,
148
+ tokenizeText,
145
149
  useCarousel
146
150
  };
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import type { TextDiffProp, TextDiffSegment } from "../../props/components/data-display.prop.js";
3
+ export type { TextDiffProp, TextDiffProp as TextDiffProps, TextDiffSegment, } from "../../props/components/data-display.prop.js";
4
+ /** The unit a change is measured in — see `TextDiffProp["granularity"]`. */
5
+ export type TextDiffGranularity = NonNullable<TextDiffProp["granularity"]>;
6
+ /**
7
+ * Cut a text into diff tokens. `line` keeps each line with its newline; `char` is one grapheme
8
+ * cluster per token (an emoji or a combining mark stays whole); `word` is `Intl.Segmenter` word
9
+ * segmentation in `lang`; `auto` is `word`, except that a CJK run is cut per character, because a
10
+ * dictionary word boundary in Japanese moves when one particle changes. A newline is always its
11
+ * own token so a changed line never swallows the line after it.
12
+ */
13
+ export declare function tokenizeText(text: string, granularity?: TextDiffGranularity, lang?: string): string[];
14
+ /**
15
+ * The diff `TextDiff` renders, for a caller that needs the runs themselves (a count of changes, a
16
+ * plain-text export). A longest-common-subsequence over {@link tokenizeText} tokens, with the
17
+ * common prefix and suffix trimmed first; adjacent runs of one kind are merged, and a removal is
18
+ * always emitted before the addition that replaces it. Past `maxCells` LCS cells the changed
19
+ * middle is one removal plus one addition — a bounded cost instead of a frozen tab.
20
+ */
21
+ export declare function diffText(before: string, after: string, options?: {
22
+ granularity?: TextDiffGranularity;
23
+ lang?: string;
24
+ maxCells?: number;
25
+ }): TextDiffSegment[];
26
+ /**
27
+ * TextDiff — what changed between two versions of a text (gh#1096): removed runs in `<del>`,
28
+ * added runs in `<ins>`, each with a struck-through or underlined mark AND a spoken start/end
29
+ * label, so the change is never carried by colour alone. Inline (one flow, GitHub's "unified")
30
+ * or split (before | after side by side); per line, per word, per character, or `auto` (words,
31
+ * with CJK cut per character). Long unchanged runs fold behind a button that reveals them.
32
+ */
33
+ export declare const TextDiff: React.ForwardRefExoticComponent<Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
34
+ before: string;
35
+ after: string;
36
+ mode?: "inline" | "split";
37
+ granularity?: "auto" | "word" | "char" | "line";
38
+ collapseUnchanged?: boolean;
39
+ contextLines?: number;
40
+ maxCells?: number;
41
+ beforeLabel?: React.ReactNode;
42
+ afterLabel?: React.ReactNode;
43
+ lang?: string;
44
+ className?: import("../../props/index.js").ClassNameProp;
45
+ } & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,240 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
5
+ import { cn } from "../../lib/utils.js";
6
+ import { Button } from "../general/button.js";
7
+ import { VisuallyHidden } from "../general/visually-hidden.js";
8
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
9
+ const DEFAULT_MAX_CELLS = 4e6;
10
+ const MIN_HIDDEN_LINES = 2;
11
+ function segmenter(lang, granularity) {
12
+ if (typeof Intl === "undefined" || typeof Intl.Segmenter !== "function") return null;
13
+ try {
14
+ return new Intl.Segmenter(lang, { granularity });
15
+ } catch {
16
+ return new Intl.Segmenter(void 0, { granularity });
17
+ }
18
+ }
19
+ function graphemes(text, lang) {
20
+ const s = segmenter(lang, "grapheme");
21
+ return s ? Array.from(s.segment(text), (part) => part.segment) : Array.from(text);
22
+ }
23
+ function tokenizeText(text, granularity = "auto", lang) {
24
+ if (text === "") return [];
25
+ if (granularity === "line") return text.match(/[^\n]*\n|[^\n]+/g) ?? [];
26
+ if (granularity === "char") return graphemes(text, lang);
27
+ const s = segmenter(lang, "word");
28
+ const words = s ? Array.from(s.segment(text), (part) => part.segment) : text.match(/\s+|[^\s]+/g) ?? [];
29
+ const tokens = [];
30
+ for (const word of words) {
31
+ const pieces = word.length > 1 && word.includes("\n") ? word.split(/(\n)/) : [word];
32
+ for (const piece of pieces) {
33
+ if (piece === "") continue;
34
+ if (granularity === "auto" && CJK.test(piece)) tokens.push(...graphemes(piece, lang));
35
+ else tokens.push(piece);
36
+ }
37
+ }
38
+ return tokens;
39
+ }
40
+ function push(segments, kind, text) {
41
+ if (text === "") return;
42
+ const last = segments[segments.length - 1];
43
+ if (last !== void 0 && last.kind === kind) last.text += text;
44
+ else segments.push({ kind, text });
45
+ }
46
+ function diffText(before, after, options = {}) {
47
+ const { granularity = "auto", lang, maxCells = DEFAULT_MAX_CELLS } = options;
48
+ const a = tokenizeText(before, granularity, lang);
49
+ const b = tokenizeText(after, granularity, lang);
50
+ let start = 0;
51
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
52
+ let endA = a.length;
53
+ let endB = b.length;
54
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
55
+ endA--;
56
+ endB--;
57
+ }
58
+ const segments = [];
59
+ push(segments, "same", a.slice(0, start).join(""));
60
+ const midA = a.slice(start, endA);
61
+ const midB = b.slice(start, endB);
62
+ if ((midA.length + 1) * (midB.length + 1) > maxCells) {
63
+ push(segments, "removed", midA.join(""));
64
+ push(segments, "added", midB.join(""));
65
+ } else {
66
+ const width = midB.length + 1;
67
+ const lengths = new Uint32Array((midA.length + 1) * width);
68
+ for (let i2 = midA.length - 1; i2 >= 0; i2--) {
69
+ for (let j2 = midB.length - 1; j2 >= 0; j2--) {
70
+ lengths[i2 * width + j2] = midA[i2] === midB[j2] ? lengths[(i2 + 1) * width + j2 + 1] + 1 : Math.max(lengths[(i2 + 1) * width + j2], lengths[i2 * width + j2 + 1]);
71
+ }
72
+ }
73
+ let i = 0;
74
+ let j = 0;
75
+ while (i < midA.length || j < midB.length) {
76
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
77
+ push(segments, "same", midA[i]);
78
+ i++;
79
+ j++;
80
+ } else if (i < midA.length && (j === midB.length || lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])) {
81
+ push(segments, "removed", midA[i]);
82
+ i++;
83
+ } else {
84
+ push(segments, "added", midB[j]);
85
+ j++;
86
+ }
87
+ }
88
+ }
89
+ push(segments, "same", a.slice(endA).join(""));
90
+ return segments;
91
+ }
92
+ function toLines(segments) {
93
+ const lines = [];
94
+ let current = [];
95
+ for (const segment of segments) {
96
+ for (const piece of segment.text.split(/(?<=\n)/)) {
97
+ if (piece === "") continue;
98
+ current.push({ kind: segment.kind, text: piece });
99
+ if (piece.endsWith("\n")) {
100
+ lines.push(current);
101
+ current = [];
102
+ }
103
+ }
104
+ }
105
+ if (current.length > 0) lines.push(current);
106
+ return lines;
107
+ }
108
+ function toItems(lines, collapse, context) {
109
+ const runs = [];
110
+ for (const line of lines) {
111
+ const changed = line.some((piece) => piece.kind !== "same");
112
+ const last = runs[runs.length - 1];
113
+ if (last !== void 0 && last.changed === changed) last.lines.push(line);
114
+ else runs.push({ changed, lines: [line] });
115
+ }
116
+ const anyChange = runs.some((run) => run.changed);
117
+ const items = [];
118
+ runs.forEach((run, index) => {
119
+ if (run.changed || !collapse || !anyChange) {
120
+ items.push({ type: "lines", ...run });
121
+ return;
122
+ }
123
+ const head = index === 0 ? 0 : context;
124
+ const tail = index === runs.length - 1 ? 0 : context;
125
+ const hidden = run.lines.length - head - tail;
126
+ if (hidden < MIN_HIDDEN_LINES) {
127
+ items.push({ type: "lines", ...run });
128
+ return;
129
+ }
130
+ if (head > 0) items.push({ type: "lines", changed: false, lines: run.lines.slice(0, head) });
131
+ items.push({ type: "gap", id: index, lines: run.lines.slice(head, head + hidden) });
132
+ if (tail > 0) {
133
+ items.push({ type: "lines", changed: false, lines: run.lines.slice(head + hidden) });
134
+ }
135
+ });
136
+ return items;
137
+ }
138
+ function Runs({ lines, side }) {
139
+ const { t } = useTranslation();
140
+ const runs = [];
141
+ for (const piece of lines.flat()) {
142
+ if (side === "before" && piece.kind === "added") continue;
143
+ if (side === "after" && piece.kind === "removed") continue;
144
+ push(runs, piece.kind, piece.text);
145
+ }
146
+ return /* @__PURE__ */ jsx(Fragment, { children: runs.map(
147
+ (run, index) => run.kind === "same" ? /* @__PURE__ */ jsx(React.Fragment, { children: run.text }, index) : run.kind === "removed" ? /* @__PURE__ */ jsxs("del", { className: "ui-text-diff-removed", children: [
148
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.removedStart") }),
149
+ run.text,
150
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.removedEnd") })
151
+ ] }, index) : /* @__PURE__ */ jsxs("ins", { className: "ui-text-diff-added", children: [
152
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.addedStart") }),
153
+ run.text,
154
+ /* @__PURE__ */ jsx(VisuallyHidden, { children: t("ui.textDiff.addedEnd") })
155
+ ] }, index)
156
+ ) });
157
+ }
158
+ const TextDiff = React.forwardRef(function TextDiff2({
159
+ before,
160
+ after,
161
+ mode = "inline",
162
+ granularity = "auto",
163
+ collapseUnchanged = true,
164
+ contextLines = 2,
165
+ maxCells,
166
+ beforeLabel,
167
+ afterLabel,
168
+ lang,
169
+ className,
170
+ ...props
171
+ }, ref) {
172
+ const { t } = useTranslation();
173
+ const items = React.useMemo(
174
+ () => toItems(
175
+ toLines(diffText(before, after, { granularity, lang, maxCells })),
176
+ collapseUnchanged,
177
+ Math.max(0, contextLines)
178
+ ),
179
+ [before, after, granularity, lang, maxCells, collapseUnchanged, contextLines]
180
+ );
181
+ const [expanded, setExpanded] = React.useState(() => /* @__PURE__ */ new Set());
182
+ React.useEffect(() => setExpanded(/* @__PURE__ */ new Set()), [items]);
183
+ const gap = (item, split) => /* @__PURE__ */ jsx("div", { className: "ui-text-diff-gap", "data-span": split || void 0, children: /* @__PURE__ */ jsx(
184
+ Button,
185
+ {
186
+ type: "button",
187
+ variant: "ghost",
188
+ size: "xs",
189
+ onClick: () => setExpanded((previous) => new Set(previous).add(item.id)),
190
+ children: t("ui.textDiff.showUnchanged", { count: item.lines.length })
191
+ }
192
+ ) }, `gap-${item.id}`);
193
+ const resolved = items.map(
194
+ (item) => item.type === "gap" && expanded.has(item.id) ? { type: "lines", changed: false, lines: item.lines } : item
195
+ );
196
+ return /* @__PURE__ */ jsx(
197
+ "div",
198
+ {
199
+ ref,
200
+ "data-slot": "text-diff",
201
+ "data-mode": mode,
202
+ lang,
203
+ className: cn("ui-text-diff", className),
204
+ ...props,
205
+ children: mode === "split" ? /* @__PURE__ */ jsxs("div", { className: "ui-text-diff-split", children: [
206
+ /* @__PURE__ */ jsx("div", { className: "ui-text-diff-heading", children: beforeLabel ?? t("ui.textDiff.before") }),
207
+ /* @__PURE__ */ jsx("div", { className: "ui-text-diff-heading", children: afterLabel ?? t("ui.textDiff.after") }),
208
+ resolved.map(
209
+ (item, index) => item.type === "gap" ? gap(item, true) : /* @__PURE__ */ jsxs(React.Fragment, { children: [
210
+ /* @__PURE__ */ jsx(
211
+ "div",
212
+ {
213
+ className: "ui-text-diff-cell",
214
+ "data-side": "before",
215
+ "data-changed": item.changed || void 0,
216
+ children: /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "before" })
217
+ }
218
+ ),
219
+ /* @__PURE__ */ jsx(
220
+ "div",
221
+ {
222
+ className: "ui-text-diff-cell",
223
+ "data-side": "after",
224
+ "data-changed": item.changed || void 0,
225
+ children: /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "after" })
226
+ }
227
+ )
228
+ ] }, index)
229
+ )
230
+ ] }) : /* @__PURE__ */ jsx("div", { className: "ui-text-diff-body", children: resolved.map(
231
+ (item, index) => item.type === "gap" ? gap(item, false) : /* @__PURE__ */ jsx(Runs, { lines: item.lines, side: "both" }, index)
232
+ ) })
233
+ }
234
+ );
235
+ });
236
+ export {
237
+ TextDiff,
238
+ diffText,
239
+ tokenizeText
240
+ };
@@ -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": "31.15.0",
3
+ "version": "31.16.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -457,6 +457,18 @@
457
457
  "copy": "Copy code",
458
458
  "copied": "Copied",
459
459
  "copiedAnnounce": "Code copied to clipboard"
460
+ },
461
+ "textDiff": {
462
+ "before": "Before",
463
+ "after": "After",
464
+ "removedStart": "[removed: ",
465
+ "removedEnd": "]",
466
+ "addedStart": "[added: ",
467
+ "addedEnd": "]",
468
+ "showUnchanged": {
469
+ "one": "Show {count} unchanged line",
470
+ "other": "Show {count} unchanged lines"
471
+ }
460
472
  }
461
473
  },
462
474
  "navigation": {
@@ -450,6 +450,15 @@
450
450
  "copy": "コードをコピー",
451
451
  "copied": "コピーしました",
452
452
  "copiedAnnounce": "コードをクリップボードにコピーしました"
453
+ },
454
+ "textDiff": {
455
+ "before": "変更前",
456
+ "after": "変更後",
457
+ "removedStart": "[削除: ",
458
+ "removedEnd": "]",
459
+ "addedStart": "[追加: ",
460
+ "addedEnd": "]",
461
+ "showUnchanged": "変更のない{count}行を表示"
453
462
  }
454
463
  },
455
464
  "navigation": {
@@ -451,6 +451,15 @@
451
451
  "copy": "Sao chép mã",
452
452
  "copied": "Đã sao chép",
453
453
  "copiedAnnounce": "Đã sao chép mã vào bộ nhớ tạm"
454
+ },
455
+ "textDiff": {
456
+ "before": "Trước",
457
+ "after": "Sau",
458
+ "removedStart": "[đã xoá: ",
459
+ "removedEnd": "]",
460
+ "addedStart": "[đã thêm: ",
461
+ "addedEnd": "]",
462
+ "showUnchanged": "Hiện {count} dòng không đổi"
454
463
  }
455
464
  },
456
465
  "navigation": {
@@ -1334,3 +1334,46 @@ export type ImagePreviewGroupProp = {
1334
1334
  preview?: boolean | ImagePreviewGroupConfigProp;
1335
1335
  children?: React.ReactNode;
1336
1336
  };
1337
+ /** @see diffText — one run of a diff: kept, taken out of `before`, or put into `after`. */
1338
+ export type TextDiffSegment = {
1339
+ kind: "same" | "removed" | "added";
1340
+ text: string;
1341
+ };
1342
+ /**
1343
+ * @see TextDiff — what changed between two versions of a text (gh#1096).
1344
+ *
1345
+ * antd ships no diff, so the shape follows GitHub/GitLab: `<del>`/`<ins>` with a spoken start/end
1346
+ * label (never colour alone), inline ("unified") or split, unchanged runs folded around the
1347
+ * changes. Text keeps its newlines (`pre-wrap`) and a long token breaks anywhere.
1348
+ */
1349
+ export type TextDiffProp = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
1350
+ /** The earlier text — what a translation was written against, the saved version. */
1351
+ before: string;
1352
+ /** The current text. */
1353
+ after: string;
1354
+ /** `inline` (default) — one flow; `split` — two columns, before | after, hunks aligned. */
1355
+ mode?: "inline" | "split";
1356
+ /**
1357
+ * The unit a change is measured in. `auto` (default) is words for alphabetic text and one
1358
+ * character per token for Han / Hiragana / Katakana / Hangul runs, which have no spaces to split
1359
+ * on. `word` is `Intl.Segmenter` word segmentation in `lang` (dictionary words for Japanese),
1360
+ * `char` is one grapheme cluster per token, `line` compares whole lines.
1361
+ */
1362
+ granularity?: "auto" | "word" | "char" | "line";
1363
+ /** Fold long unchanged runs of lines behind a "show N unchanged lines" button. Default `true`. */
1364
+ collapseUnchanged?: boolean;
1365
+ /** Unchanged lines kept visible next to each change when folding. Default `2`. */
1366
+ contextLines?: number;
1367
+ /**
1368
+ * The LCS cost cap (tokens before × tokens after). Past it the changed middle is shown as one
1369
+ * removal and one addition instead of freezing the tab. Default `4_000_000`.
1370
+ */
1371
+ maxCells?: number;
1372
+ /** Heading of the before column in `split` mode. Default: the localized "Before". */
1373
+ beforeLabel?: React.ReactNode;
1374
+ /** Heading of the after column in `split` mode. Default: the localized "After". */
1375
+ afterLabel?: React.ReactNode;
1376
+ /** BCP-47 language of the text — set on the element and used for `Intl.Segmenter`. */
1377
+ lang?: string;
1378
+ className?: ClassNameProp;
1379
+ };
@@ -2881,6 +2881,51 @@ export declare const COMPONENT_PROP_REGISTRY: {
2881
2881
  readonly reason: "The sample's accessible NAME, which is what lets a read-only colour be shown with no visible label. Absent, the mark is aria-hidden (Legend's rule) — so colour is never the sole carrier either way.";
2882
2882
  }];
2883
2883
  };
2884
+ readonly TextDiffProp: {
2885
+ readonly group: "data-display";
2886
+ readonly file: "components/data-display.prop.ts";
2887
+ readonly vocabulary: readonly ["ClassNameProp", {
2888
+ readonly field: "before";
2889
+ readonly local: true;
2890
+ readonly reason: "The earlier version of the text being compared — DATA, the left side of a diff (gh#1096).";
2891
+ }, {
2892
+ readonly field: "after";
2893
+ readonly local: true;
2894
+ readonly reason: "The current version of the text being compared — DATA, the right side of a diff (gh#1096).";
2895
+ }, {
2896
+ readonly field: "mode";
2897
+ readonly local: true;
2898
+ readonly reason: "inline | split — GitHub's unified vs side-by-side diff layout; not a size or tone axis.";
2899
+ }, {
2900
+ readonly field: "granularity";
2901
+ readonly local: true;
2902
+ readonly reason: "auto | word | char | line — the token unit, the same word Intl.Segmenter uses for it.";
2903
+ }, {
2904
+ readonly field: "collapseUnchanged";
2905
+ readonly local: true;
2906
+ readonly reason: "Positive boolean: fold long unchanged runs of lines behind a reveal button.";
2907
+ }, {
2908
+ readonly field: "contextLines";
2909
+ readonly local: true;
2910
+ readonly reason: "Unchanged lines kept beside each change when folding — git's -U<n> context.";
2911
+ }, {
2912
+ readonly field: "maxCells";
2913
+ readonly local: true;
2914
+ readonly reason: "Cost cap of the LCS table; past it the changed middle is one removal plus one addition.";
2915
+ }, {
2916
+ readonly field: "beforeLabel";
2917
+ readonly local: true;
2918
+ readonly reason: "Heading of the before column in split mode; defaults to the localized word.";
2919
+ }, {
2920
+ readonly field: "afterLabel";
2921
+ readonly local: true;
2922
+ readonly reason: "Heading of the after column in split mode; defaults to the localized word.";
2923
+ }, {
2924
+ readonly field: "lang";
2925
+ readonly local: true;
2926
+ readonly reason: "The HTML lang attribute, also the Intl.Segmenter locale for word/char segmentation.";
2927
+ }];
2928
+ };
2884
2929
  readonly FeatureStateProp: {
2885
2930
  readonly group: "data-display";
2886
2931
  readonly file: "components/data-display.prop.ts";
@@ -3398,6 +3398,63 @@ const COMPONENT_PROP_REGISTRY = {
3398
3398
  }
3399
3399
  ]
3400
3400
  },
3401
+ TextDiffProp: {
3402
+ group: "data-display",
3403
+ file: "components/data-display.prop.ts",
3404
+ vocabulary: [
3405
+ "ClassNameProp",
3406
+ {
3407
+ field: "before",
3408
+ local: true,
3409
+ reason: "The earlier version of the text being compared \u2014 DATA, the left side of a diff (gh#1096)."
3410
+ },
3411
+ {
3412
+ field: "after",
3413
+ local: true,
3414
+ reason: "The current version of the text being compared \u2014 DATA, the right side of a diff (gh#1096)."
3415
+ },
3416
+ {
3417
+ field: "mode",
3418
+ local: true,
3419
+ reason: "inline | split \u2014 GitHub's unified vs side-by-side diff layout; not a size or tone axis."
3420
+ },
3421
+ {
3422
+ field: "granularity",
3423
+ local: true,
3424
+ reason: "auto | word | char | line \u2014 the token unit, the same word Intl.Segmenter uses for it."
3425
+ },
3426
+ {
3427
+ field: "collapseUnchanged",
3428
+ local: true,
3429
+ reason: "Positive boolean: fold long unchanged runs of lines behind a reveal button."
3430
+ },
3431
+ {
3432
+ field: "contextLines",
3433
+ local: true,
3434
+ reason: "Unchanged lines kept beside each change when folding \u2014 git's -U<n> context."
3435
+ },
3436
+ {
3437
+ field: "maxCells",
3438
+ local: true,
3439
+ reason: "Cost cap of the LCS table; past it the changed middle is one removal plus one addition."
3440
+ },
3441
+ {
3442
+ field: "beforeLabel",
3443
+ local: true,
3444
+ reason: "Heading of the before column in split mode; defaults to the localized word."
3445
+ },
3446
+ {
3447
+ field: "afterLabel",
3448
+ local: true,
3449
+ reason: "Heading of the after column in split mode; defaults to the localized word."
3450
+ },
3451
+ {
3452
+ field: "lang",
3453
+ local: true,
3454
+ reason: "The HTML lang attribute, also the Intl.Segmenter locale for word/char segmentation."
3455
+ }
3456
+ ]
3457
+ },
3401
3458
  FeatureStateProp: {
3402
3459
  group: "data-display",
3403
3460
  file: "components/data-display.prop.ts",
@@ -467,6 +467,58 @@
467
467
  block-size: var(--thumbnail-block-size-lg);
468
468
  }
469
469
 
470
+ .ui-text-diff {
471
+ min-inline-size: 0;
472
+ }
473
+
474
+ :is(.ui-text-diff-body, .ui-text-diff-cell) {
475
+ white-space: pre-wrap;
476
+ overflow-wrap: anywhere;
477
+ min-inline-size: 0;
478
+ }
479
+
480
+ .ui-text-diff-removed {
481
+ background: var(
482
+ --surface-destructive,
483
+ hsl(var(--destructive) / var(--text-diff-removed-background-alpha))
484
+ );
485
+ color: inherit;
486
+ text-decoration-line: line-through;
487
+ text-decoration-thickness: var(--text-diff-mark-line-width);
488
+ }
489
+
490
+ .ui-text-diff-added {
491
+ background: var(
492
+ --surface-success,
493
+ hsl(var(--success) / var(--text-diff-added-background-alpha))
494
+ );
495
+ color: inherit;
496
+ text-decoration-line: underline;
497
+ text-decoration-thickness: var(--text-diff-mark-line-width);
498
+ }
499
+
500
+ .ui-text-diff-gap {
501
+ display: block;
502
+ padding-block: var(--text-diff-space-inset);
503
+ }
504
+
505
+ .ui-text-diff-split {
506
+ display: grid;
507
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
508
+ column-gap: var(--text-diff-space-gap);
509
+ }
510
+
511
+ .ui-text-diff-split > .ui-text-diff-gap {
512
+ grid-column: 1 / -1;
513
+ }
514
+
515
+ .ui-text-diff-heading {
516
+ padding-block-end: var(--text-diff-space-inset);
517
+ color: hsl(var(--muted-foreground));
518
+ font-size: var(--font-size-sm, var(--font-size-base));
519
+ font-weight: var(--font-weight-medium);
520
+ }
521
+
470
522
  .ui-timeline {
471
523
  display: flex;
472
524
  flex-direction: column;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "31.15.0",
2
+ "version": "31.16.0",
3
3
  "generatedBy": "scripts/gen-style-layers.mjs — do not edit by hand; run `pnpm gen:style-layers`",
4
4
  "base": "base.css",
5
5
  "fonts": "fonts.css",
@@ -1053,6 +1053,11 @@
1053
1053
  "dialog-layout.css"
1054
1054
  ]
1055
1055
  },
1056
+ "TextDiff": {
1057
+ "layers": [
1058
+ "control.css"
1059
+ ]
1060
+ },
1056
1061
  "Textarea": {
1057
1062
  "layers": [
1058
1063
  "control.css"
@@ -173,6 +173,14 @@
173
173
  --thumbnail-border-width: initial;
174
174
  --thumbnail-radius: initial;
175
175
 
176
+ --text-diff-removed-background-alpha: 0.12;
177
+ --text-diff-added-background-alpha: 0.16;
178
+
179
+ --text-diff-mark-line-width: var(--stroke-hairline);
180
+
181
+ --text-diff-space-gap: var(--space-3);
182
+ --text-diff-space-inset: var(--space-2);
183
+
176
184
  --timeline-dot-done-background: initial;
177
185
  --timeline-dot-done-foreground: initial;
178
186
  --timeline-dot-current-background: initial;
@@ -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: **178**
7
- - With ≥1 frame: **154** · zero-frame: **24**
6
+ - Public components: **179**
7
+ - With ≥1 frame: **155** · zero-frame: **24**
8
8
  - Fully covered (every contract axis declared): **0**
9
9
 
10
10
  ## UNTESTED — no `/frame/**` route
@@ -88,6 +88,7 @@ Legend: ✓ covered · N/A reasoned skip · · UNTESTED (blank cell = UNTESTED).
88
88
  | Badge | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
89
89
  | Legend | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
90
90
  | Swatch | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
91
+ | TextDiff | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
91
92
  | FeatureList | ✓ | N/A | N/A | N/A | · | · | · | · | · | · |
92
93
  | Thumbnail | ✓ | · | N/A | N/A | · | · | · | · | · | · |
93
94
  | ListRow | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
@@ -0,0 +1,89 @@
1
+ import {
2
+ Card,
3
+ CardContent,
4
+ CardDescription,
5
+ CardHeader,
6
+ CardTitle,
7
+ TextDiff,
8
+ } from "@godxjp/ui/data-display";
9
+ import { useTranslation } from "@godxjp/ui/i18n";
10
+ import { Flex, PageContainer } from "@godxjp/ui/layout";
11
+
12
+ const PREVIOUS_ORIGINAL = "来週の定例会議までに、見積書を承認してください。";
13
+ const CURRENT_ORIGINAL = "今週金曜までに、見積書と請求書を却下してください。";
14
+
15
+ const PREVIOUS_POLICY = [
16
+ "1. 申請は月末までに提出する。",
17
+ "2. 領収書の原本を添付する。",
18
+ "3. 上長の承認を得る。",
19
+ "4. 経理部が内容を確認する。",
20
+ "5. 承認後、翌月の給与と合わせて支払う。",
21
+ "6. 不備がある場合は差し戻す。",
22
+ "7. 差し戻しから五営業日以内に再提出する。",
23
+ "8. 期限を過ぎた申請は受け付けない。",
24
+ ].join("\n");
25
+
26
+ const CURRENT_POLICY = [
27
+ "1. 申請は毎月二十日までに提出する。",
28
+ "2. 領収書の原本を添付する。",
29
+ "3. 上長の承認を得る。",
30
+ "4. 経理部が内容を確認する。",
31
+ "5. 承認後、翌月の給与と合わせて支払う。",
32
+ "6. 不備がある場合は差し戻す。",
33
+ "7. 差し戻しから五営業日以内に再提出する。",
34
+ "8. 期限を過ぎた申請は、部長の承認があれば受け付ける。",
35
+ ].join("\n");
36
+
37
+ /**
38
+ * TextDiff — what changed between two versions of a text (gh#1096).
39
+ * Composed only from real @godxjp/ui components.
40
+ */
41
+ export default function Demo() {
42
+ const { t } = useTranslation();
43
+ return (
44
+ <PageContainer title="TextDiff" subtitle={t("textDiffDocs.subtitle")}>
45
+ <Flex direction="col" gap="lg">
46
+ <Card>
47
+ <CardHeader>
48
+ <CardTitle level={2}>{t("textDiffDocs.source.title")}</CardTitle>
49
+ <CardDescription>{t("textDiffDocs.source.description")}</CardDescription>
50
+ </CardHeader>
51
+ <CardContent>
52
+ <TextDiff before={PREVIOUS_ORIGINAL} after={CURRENT_ORIGINAL} lang="ja" />
53
+ </CardContent>
54
+ </Card>
55
+
56
+ <Card>
57
+ <CardHeader>
58
+ <CardTitle level={2}>{t("textDiffDocs.policy.title")}</CardTitle>
59
+ <CardDescription>{t("textDiffDocs.policy.description")}</CardDescription>
60
+ </CardHeader>
61
+ <CardContent>
62
+ <TextDiff
63
+ mode="split"
64
+ before={PREVIOUS_POLICY}
65
+ after={CURRENT_POLICY}
66
+ lang="ja"
67
+ beforeLabel={t("textDiffDocs.policy.before")}
68
+ afterLabel={t("textDiffDocs.policy.after")}
69
+ />
70
+ </CardContent>
71
+ </Card>
72
+
73
+ <Card>
74
+ <CardHeader>
75
+ <CardTitle level={2}>{t("textDiffDocs.words.title")}</CardTitle>
76
+ <CardDescription>{t("textDiffDocs.words.description")}</CardDescription>
77
+ </CardHeader>
78
+ <CardContent>
79
+ <TextDiff
80
+ before="Please approve the quotation before next week's meeting."
81
+ after="Please reject the quotation and the invoice by Friday."
82
+ lang="en"
83
+ />
84
+ </CardContent>
85
+ </Card>
86
+ </Flex>
87
+ </PageContainer>
88
+ );
89
+ }
@@ -1549,5 +1549,22 @@
1549
1549
  "comment": "Nguyen commented",
1550
1550
  "assignee": "Tanaka changed the assignee"
1551
1551
  }
1552
+ },
1553
+ "textDiffDocs": {
1554
+ "subtitle": "Show what changed between two versions of a text, as removals and additions",
1555
+ "source": {
1556
+ "title": "See what changed in the original",
1557
+ "description": "The original a translation was written against, compared with the original now. Japanese is compared character by character, so changing one particle does not mark the whole sentence."
1558
+ },
1559
+ "policy": {
1560
+ "title": "Policy revision (side by side)",
1561
+ "description": "Unchanged lines fold away behind a button. Removals are struck through and additions underlined, so colour is never the only signal.",
1562
+ "before": "Before revision",
1563
+ "after": "After revision"
1564
+ },
1565
+ "words": {
1566
+ "title": "Alphabetic text, word by word",
1567
+ "description": "Languages that separate words with spaces are compared per word. Pass granularity=\"line\" to compare whole lines."
1568
+ }
1552
1569
  }
1553
1570
  }
@@ -1542,5 +1542,22 @@
1542
1542
  "comment": "グエン がコメント",
1543
1543
  "assignee": "田中 が担当者を変更"
1544
1544
  }
1545
+ },
1546
+ "textDiffDocs": {
1547
+ "subtitle": "二つの版のテキストの差分を、削除と追加で示す",
1548
+ "source": {
1549
+ "title": "原文の変更を見る",
1550
+ "description": "翻訳を書いた時点の原文と、いまの原文の差分です。日本語は一文字ずつ比べるので、助詞ひとつの変更でも文全体が変わったようには見えません。"
1551
+ },
1552
+ "policy": {
1553
+ "title": "規程の改訂(左右に並べる)",
1554
+ "description": "変更のない行は折りたたまれ、ボタンで開けます。削除は取り消し線、追加は下線で示し、色だけに頼りません。",
1555
+ "before": "改訂前",
1556
+ "after": "改訂後"
1557
+ },
1558
+ "words": {
1559
+ "title": "英文は単語単位で",
1560
+ "description": "スペースで区切られる言語は単語ごとに比べます。行単位にしたい場合は granularity=\"line\" を指定します。"
1561
+ }
1545
1562
  }
1546
1563
  }
@@ -1542,5 +1542,22 @@
1542
1542
  "comment": "Nguyễn đã bình luận",
1543
1543
  "assignee": "Tanaka đổi người phụ trách"
1544
1544
  }
1545
+ },
1546
+ "textDiffDocs": {
1547
+ "subtitle": "Cho thấy điều gì đã đổi giữa hai phiên bản của một văn bản, dưới dạng phần xoá và phần thêm",
1548
+ "source": {
1549
+ "title": "Xem thay đổi của bản gốc",
1550
+ "description": "Bản gốc lúc viết bản dịch so với bản gốc hiện tại. Tiếng Nhật được so từng ký tự, nên đổi một trợ từ không làm cả câu bị đánh dấu."
1551
+ },
1552
+ "policy": {
1553
+ "title": "Sửa đổi quy định (đặt cạnh nhau)",
1554
+ "description": "Các dòng không đổi được thu gọn sau một nút. Phần xoá bị gạch ngang, phần thêm được gạch chân, không chỉ dựa vào màu.",
1555
+ "before": "Trước sửa đổi",
1556
+ "after": "Sau sửa đổi"
1557
+ },
1558
+ "words": {
1559
+ "title": "Văn bản chữ Latinh, theo từng từ",
1560
+ "description": "Ngôn ngữ tách từ bằng dấu cách được so theo từng từ. Truyền granularity=\"line\" để so cả dòng."
1561
+ }
1545
1562
  }
1546
1563
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "31.15.0",
4
- "godxUiMcp": "31.15.0",
3
+ "version": "31.16.0",
4
+ "godxUiMcp": "31.16.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",