jui-tools-mcp-server 2.14.0 → 2.15.2

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
@@ -70,7 +70,7 @@ bash ~/.jsonui-mcp-server/uninstall.sh
70
70
  | `list_screen_specs` | 画面 spec 一覧(メタデータ付き) |
71
71
  | `list_component_specs` | コンポーネント spec 一覧 |
72
72
  | `list_layouts` | Layout JSON ファイル一覧 |
73
- | `read_spec_file` | spec ファイルの内容を返す |
73
+ | `read_spec_file` | spec ファイルの内容を返す。散文フィールド(description / notes / intent / purpose / processing / handling / rule / meaning / note / reason〔`harnessConditions`・`canonicalDivergence` の下を除く〕/ condition〔トップレベルの `transitions` の下のみ〕)の `{"md": "key"}`(`<name>.texts.yaml` への参照)は `{"md", "text"}` に展開して返す(編集は YAML 側)。参照の無い spec はファイルそのまま。`search_specs` は texts ファイルの本文を検索しない |
74
74
  | `read_layout_file` | Layout JSON の内容を返す |
75
75
  | `search_specs` | 全 spec(画面・サブディレクトリの sub-spec・コンポーネント)をキーワード検索し、ファイル・JSON path・抜粋を返す |
76
76
 
@@ -697,11 +697,17 @@
697
697
  "string",
698
698
  "array"
699
699
  ],
700
- "description": "Click handler function name (selector-based, lowercase) - string only, no binding"
700
+ "description": "Click handler function name (selector-based, lowercase) - string only, no binding",
701
+ "notApplicableTo": {
702
+ "Web": "the embedded page takes the taps; a tap laid over it would break the page's own taps and scrolling (ruling 2026-10-03: not delivered, the build warns)"
703
+ }
701
704
  },
702
705
  "onClick": {
703
706
  "type": "binding",
704
- "description": "Click handler (camelCase) - binding only (@{functionName}). On a type the SSoT does not declare (an app's own component) JsonUI gives the tap no screen-reader role; the component carries its own."
707
+ "description": "Click handler (camelCase) - binding only (@{functionName}). On a type the SSoT does not declare (an app's own component) JsonUI gives the tap no screen-reader role; the component carries its own.",
708
+ "notApplicableTo": {
709
+ "Web": "the embedded page takes the taps; a tap laid over it would break the page's own taps and scrolling (ruling 2026-10-03: not delivered, the build warns)"
710
+ }
705
711
  },
706
712
  "onLongPress": {
707
713
  "type": "binding",
@@ -714,7 +720,12 @@
714
720
  },
715
721
  "onPan": {
716
722
  "type": "binding",
717
- "description": "Pan gesture handler (camelCase) - binding only (@{functionName}). Invoked repeatedly while the user drags. Payload: cumulative translation since the gesture began (iOS CGSize, Android Offset; web passes the PointerEvent). A handler declared () -> Void is called without the payload."
723
+ "description": "Pan gesture handler (camelCase) - binding only (@{functionName}). Invoked repeatedly while the user drags. Payload: cumulative translation since the gesture began (iOS CGSize, Android Offset; web passes the PointerEvent). A handler declared () -> Void is called without the payload.",
724
+ "notApplicableTo": {
725
+ "TextField": "the field's own drag (text selection, caret placement) takes the gesture; a pan laid over it would break editing (ruling 2026-10-03: not delivered, the build warns)",
726
+ "TextView": "the field's own drag (text selection, caret placement) takes the gesture; a pan laid over it would break editing (ruling 2026-10-03: not delivered, the build warns)",
727
+ "Slider": "the slider's own drag moves its value; a pan laid over it would break that (ruling 2026-10-03: not delivered, the build warns)"
728
+ }
718
729
  },
719
730
  "onPinch": {
720
731
  "type": "binding",
@@ -732,7 +743,7 @@
732
743
  "string",
733
744
  "binding"
734
745
  ],
735
- "description": "Tint color - hex string or color name from colors.json (binding supported)"
746
+ "description": "Tint color - hex string or color name from colors.json (binding supported). It is the accent of the node's operable parts, SwiftUI `.tint` / CSS `accent-color`: a control's accent, a link's colour, the text cursor - never the text colour (jsonui-cli 1.9.0 ruling). From 1.9.0 Compose no longer tints the text or icons inside a tinted node (the LocalContentColor wrap is gone), as iOS and web never did. The per-type declarations say what the accent is on that type (TextField: the cursor; Switch / Slider / Progress: the tint; Segment / TabView: the selected item). A container's tintColor reaches the controls inside it (included views, cells and embedded screens too), as SwiftUI's `.tint` does: on Compose, from the release after jsonui-cli 1.9.1, through the library's LocalJsonUITint; a control's own tintColor (or a more specific colour such as onTintColor) wins. Images and icons do not take it."
736
747
  },
737
748
  "binding": {
738
749
  "type": [
@@ -767,7 +778,7 @@
767
778
  },
768
779
  "include": {
769
780
  "type": "string",
770
- "description": "Include another JSON layout (filename without extension)"
781
+ "description": "Include another JSON layout (its path from the layouts root, without extension). An include is an inline expansion: the included layout is drawn where this node is, and it reads the including layout's data — the parent owns the ViewModel. Under an `id`, the id prefixes the included layout's ids and data names, camelCase-joined (id `side` + `title` -> `sideTitle`), so the included layout's `@{title}` reads the including layout's `sideTitle`; without one it reads `title`. The include node's object maps, `shared_data` and then `data`, are laid over the including layout's data: a key is a name the included layout binds, a value is a literal or a binding read in the including layout's scope. The same on every platform and in Dynamic mode (ruling 2026-10-02). Until jsonui-cli 1.9.6 web handed an include without a map none of the including layout's data (the included layout drew its own defaults), and sjui / kjui codegen ignored an object map."
771
782
  },
772
783
  "variables": {
773
784
  "type": "object",
@@ -777,7 +788,7 @@
777
788
  },
778
789
  "shared_data": {
779
790
  "type": "object",
780
- "description": "Data passed to included components"
791
+ "description": "On an include node: an object map laid over the including layout's data for the included layout, before `data` (`data` wins on a key both set). A key is a name the included layout binds; a value is a literal or a binding read in the including layout's scope (see `include`)."
781
792
  },
782
793
  "style": {
783
794
  "type": "string",
@@ -901,7 +912,7 @@
901
912
  "systemChromeMaterial": "Chrome"
902
913
  },
903
914
  "default": "Regular",
904
- "description": "Visual effect (blur) style. Measured across the three converters 2026-08-05: the UIKit trio Light/Dark/ExtraLight is what all three map (sjui blur_converter.rb:71, kjui blurview_component.rb:38, rjui blur_converter.rb:98), and web additionally maps the SwiftUI material names. Every converter downcases before matching, so the casing here is presentational. `Regular` is the fallback all three already use for an unrecognised or absent value, hence the declared default. Enumerated because it was declared as a bare string while Blur.effectStyle — the same concept on the component that owns it — carried the enum: the split let the common spelling reach the render stage unvalidated, which is what the codegen-effect gate reported once the mode audit put these fixtures back in scope. Declared on `common`, so it applies on all three platforms and not only on Blur; the ios material/tint table is pinned in attribute_semantics.json -> effectStyle, which is also where the system* valueAliases and the tint layer order are ruled."
915
+ "description": "Visual effect (blur) style. A value is its declared spelling, case and all (jsonui-cli 1.9.0): no converter downcases before matching, so `thick` or `light` is an undeclared spelling, which the validator names and which draws the default. A node that declares its own effectStyle is judged on that declaration: a Blur takes only Blur.effectStyle's Light / Dark / ExtraLight, so Thick on a Blur draws the default too. The spellings here apply to every other node. The `system*Material` spellings are valueAliases of the material names (systemMaterial -> Regular, systemUltraThinMaterial -> UltraThin, and so on). `Regular` is the fallback every path uses for an unrecognised or absent value, hence the declared default. Enumerated because it was declared as a bare string while Blur.effectStyle — the same concept on the component that owns it — carried the enum: the split let the common spelling reach the render stage unvalidated, which is what the codegen-effect gate reported once the mode audit put these fixtures back in scope. Declared on `common`, so it applies on all three platforms and not only on Blur; the ios material/tint table is pinned in attribute_semantics.json -> effectStyle, which is also where the system* valueAliases and the tint layer order are ruled."
905
916
  },
906
917
  "tag": {
907
918
  "type": [
@@ -981,7 +992,7 @@
981
992
  },
982
993
  "data": {
983
994
  "type": "array",
984
- "description": "Data model definitions for binding"
995
+ "description": "Data model definitions for binding: an array of declarations ({name, class, defaultValue}). On an include node an array is declarations merged into the included layout's, and an object is a map laid over the including layout's data for the included layout, after `shared_data` — `data` wins on a key both set (see `include`)."
985
996
  },
986
997
  "bindingScript": {
987
998
  "type": "string",
@@ -1233,7 +1244,7 @@
1233
1244
  "string",
1234
1245
  "binding"
1235
1246
  ],
1236
- "description": "Text content (can be data binding, supports interpolation). PLAIN TEXT: markdown is not interpreted, so \"[label](/path)\", \"**bold**\" and backticks render literally, brackets and all. To emphasise or link part of the string, use partialAttributes ('range' plus font/color/underline, and 'onClick' for a tappable span). Rendering real markdown is out of scope for Label — write a custom component."
1247
+ "description": "Text content: a literal, or one data binding (`@{text}`). A value that mixes literal text with a binding (`Title: @{x}`) is a build warning, binding-mixed-text — compose the string in the ViewModel and bind it as one value (until jsonui-cli 1.9.6 this read \"supports interpolation\"). PLAIN TEXT: markdown is not interpreted, so \"[label](/path)\", \"**bold**\" and backticks render literally, brackets and all. To emphasise or link part of the string, use partialAttributes ('range' plus font/color/underline, and 'onClick' for a tappable span). Rendering real markdown is out of scope for Label — write a custom component."
1237
1248
  },
1238
1249
  "hint": {
1239
1250
  "type": "string",
@@ -1307,7 +1318,7 @@
1307
1318
  "center",
1308
1319
  "right"
1309
1320
  ],
1310
- "description": "Where the label's text sits across the label's own width: Left the start, Center the middle, Right the end (binding supported). Without textAlign, the horizontal part of gravity places it (left / right / centerHorizontal, center); without either, the start. Vertically the text sits at the vertical gravity names, else the middle (gravityDefaults -> leafOwnFrameChannel). The lines of a multi-line or wrapped label follow the same rule across. (4f ruling 2026-09-27, and the user's ruling for iOS, round 6: align iOS to start.) The web draws it so from jsonui-cli 1.9.0, its lines too (rjui gives gravity's text-center / text-right when textAlign is not declared; measured in Chromium, a wrapped label's lines stayed at the start until then); Compose from jsonui-cli 1.9.0 (4f round 17); iOS from jsonui-cli 1.9.0 and SwiftJsonUI 10.29.0 (round 6) - until then, measured on sjui codegen and SwiftJsonUI Dynamic, a fixed-width label with neither drew its text in the middle (\"Mon\" in 50, a 200 x 44 label), one with gravity right or center at the start, and the lines of a multi-line one at the start whatever its gravity."
1321
+ "description": "Where the label's text sits across the label's own width: Left the start, Center the middle, Right the end (binding supported). Without textAlign, the horizontal part of gravity places it (left / right / centerHorizontal, center); without either, the start. When gravity names both a center part (center / centerHorizontal) and right, center wins, on every platform. Vertically the text sits at the vertical gravity names, else the middle (gravityDefaults -> leafOwnFrameChannel). The lines of a multi-line or wrapped label follow the same rule across. (Ruled 2026-09-27; for iOS: align to start.) The web draws it so from jsonui-cli 1.9.0, its lines too (rjui gives gravity's text-center / text-right when textAlign is not declared; measured in Chromium, a wrapped label's lines stayed at the start until then); Compose from jsonui-cli 1.9.0, except the lines of a wrapContent-width label whose text wraps (by reading, they stayed at the start) and a gravity naming both center and right (by reading, it took right), both from the release after jsonui-cli 1.9.1; iOS from jsonui-cli 1.9.0 and SwiftJsonUI 10.29.0 (round 6) - until then, measured on sjui codegen and SwiftJsonUI Dynamic, a fixed-width label with neither drew its text in the middle (\"Mon\" in 50, a 200 x 44 label), one with gravity right or center at the start, and the lines of a multi-line one at the start whatever its gravity."
1311
1322
  },
1312
1323
  "lines": {
1313
1324
  "type": [
@@ -2380,7 +2391,7 @@
2380
2391
  "string",
2381
2392
  "binding"
2382
2393
  ],
2383
- "description": "Button text (can be data binding, supports interpolation)"
2394
+ "description": "Button text: a literal, or one data binding (`@{title}`). A value that mixes literal text with a binding is a build warning, binding-mixed-text — compose the string in the ViewModel and bind it as one value (until jsonui-cli 1.9.6 this read \"supports interpolation\")."
2384
2395
  },
2385
2396
  "font": {
2386
2397
  "type": "string",
@@ -2617,7 +2628,13 @@
2617
2628
  "valueAliases": {
2618
2629
  "ScaleToFill": "fill"
2619
2630
  },
2620
- "description": "Content mode (binding supported). ScaleToFill is a declared synonym of fill (the stretch — see attribute_semantics.json#semantics.image); the normalizer folds it, fixtures are generated for the canonical spelling only."
2631
+ "description": "Content mode (binding supported). ScaleToFill is a declared synonym of fill (the stretch — see attribute_semantics.json#semantics.image); the normalizer folds it, fixtures are generated for the canonical spelling only.",
2632
+ "bindingInfo": {
2633
+ "platform": [
2634
+ "swift"
2635
+ ],
2636
+ "note": "a contentMode bound to a value that is AspectFill at run time is cropped to its frame by the generated code only when width and height are numbers; on a frame that is not two numbers the image can be drawn past the frame (jsonui-cli ticket sjui-aspectfill-image-is-not-cropped-to-its-frame, known gap). A literal contentMode is cropped on every frame."
2637
+ }
2621
2638
  },
2622
2639
  "renderingMode": {
2623
2640
  "type": "string",
@@ -2753,7 +2770,13 @@
2753
2770
  "AspectFit",
2754
2771
  "Center"
2755
2772
  ],
2756
- "description": "Content mode (binding supported)"
2773
+ "description": "Content mode (binding supported)",
2774
+ "bindingInfo": {
2775
+ "platform": [
2776
+ "swift"
2777
+ ],
2778
+ "note": "a contentMode bound to a value that is AspectFill at run time is cropped to its frame by the generated code only when width and height are numbers; on a frame that is not two numbers the image can be drawn past the frame (jsonui-cli ticket sjui-aspectfill-image-is-not-cropped-to-its-frame, known gap). A literal contentMode is cropped on every frame."
2779
+ }
2757
2780
  },
2758
2781
  "cachePolicy": {
2759
2782
  "type": "string",
@@ -2880,7 +2903,7 @@
2880
2903
  "valueAliases": {
2881
2904
  "datetime": "dateAndTime"
2882
2905
  },
2883
- "description": "Date picker mode. datetime is a declared synonym of dateAndTime and the normalizer folds it: sjui and rjui accept both spellings (selectbox_converter.rb case arms; rjui downcases), but KotlinJsonUI's DateSelectBox matches \"dateAndTime\" literally and datetime would silently fall to the date-only branch — so the UIKit spelling is canonical, same reasoning as gradientDirection's capitalised canon."
2906
+ "description": "Date picker mode. datetime is a declared synonym of dateAndTime and the normalizer folds it: sjui and rjui accept both spellings as declared, case and all (sjui matches them literally in selectbox_converter.rb's case arms; rjui's select_box_converter.rb takes a value only when it is a declared spelling, through EnumSpelling.lowered, and lowercases it just for its own case arms), but KotlinJsonUI's DateSelectBox matches \"dateAndTime\" literally and datetime would silently fall to the date-only branch — so the UIKit spelling is canonical, same reasoning as gradientDirection's capitalised canon."
2884
2907
  },
2885
2908
  "datePickerStyle": {
2886
2909
  "type": "string",
@@ -3094,11 +3117,10 @@
3094
3117
  },
3095
3118
  "onValueChange": {
3096
3119
  "type": "binding",
3097
- "description": "Value change handler - binding only (@{functionName}). The handler receives the new value of the selection binding, the same on every platform: with selectedIndex bound it is the Int index (declare ((Int) -> Void)?, or ((String, Int) -> Void)? for viewId + index); with selectedItem / selectedValue bound it is the String item (declare ((String, String) -> Void)? for viewId + item). A lone (String) parameter is the selected item as a String, with selectedIndex bound too; the viewId comes only as the first of two parameters. (() -> Void)? receives nothing and the ViewModel reads the bound value back."
3098
- },
3099
- "onValueChanged": {
3100
- "type": "binding",
3101
- "description": "Value change handler (alias for onValueChange) - binding only (@{functionName})"
3120
+ "description": "Value change handler - binding only (@{functionName}). The handler receives the new value of the selection binding, the same on every platform: with selectedIndex bound it is the Int index (declare ((Int) -> Void)?, or ((String, Int) -> Void)? for viewId + index); with selectedItem / selectedValue bound it is the String item (declare ((String, String) -> Void)? for viewId + item). A lone (String) parameter is the selected item as a String, with selectedIndex bound too; the viewId comes only as the first of two parameters. (() -> Void)? receives nothing and the ViewModel reads the bound value back. `onValueChanged` folds here, as on Slider, Collection and CheckBox; it was declared as its own attribute, so the normalizer never folded it and sjui codegen called nothing for it (ticket sjui-selectbox-onvaluechanged-alias-is-never-called).",
3121
+ "aliases": [
3122
+ "onValueChanged"
3123
+ ]
3102
3124
  },
3103
3125
  "colorScheme": {
3104
3126
  "type": "string",
@@ -3564,7 +3586,7 @@
3564
3586
  "Diagonal": "Oblique"
3565
3587
  },
3566
3588
  "default": "Vertical",
3567
- "description": "Gradient direction. Canon copied from GradientView.gradientDirection, which declared the enum for the same concept while this spelling stayed a bare string. Vertical is the fallback on all three. CASE MATTERS on ios: sjui matches 'Horizontal'/'Oblique' literally (modifier_helper.rb:16) without downcasing, while Compose and web downcase — so the capitalised spellings are canonical and the aliases normalise to them before any converter sees the value. RightToLeft and BottomToTop are declared without an alias on purpose: they are reversed directions web implements (gradient_view_converter.rb:88,90) and the other two have no equivalent, so folding them into Horizontal/Vertical would silently reverse the gradient rather than preserve it.",
3589
+ "description": "Gradient direction. Canon copied from GradientView.gradientDirection, which declared the enum for the same concept while this spelling stayed a bare string. Vertical is the fallback on all three. A value is its declared spelling, case and all (jsonui-cli 1.9.0): sjui matches the capitalised spellings literally (modifier_helper.rb gradient_expression), so 'horizontal' is an undeclared spelling that draws the Vertical fallback; the aliases normalise to the capitalised canon before any converter sees the value. Compose and web do not read this declaration (platform swift); their GradientView converters read GradientView.gradientDirection by the same rule (EnumSpelling.lowered: a declared spelling only, lowercased just for their own case arms). RightToLeft and BottomToTop are declared without an alias on purpose: they are reversed directions (sjui draws them trailing to leading and bottom to top), so folding them into Horizontal/Vertical would silently reverse the gradient rather than preserve it.",
3568
3590
  "platform": "swift"
3569
3591
  },
3570
3592
  "locations": {
@@ -3862,7 +3884,7 @@
3862
3884
  "binding"
3863
3885
  ],
3864
3886
  "min": 1,
3865
- "description": "Number of columns. Literal int -> static GridCells.Fixed(N) / Array(repeating: ..., count: N). Binding @{prop} -> runtime-resolved data.prop; the Collection always renders on the multi-column grid path (LazyVerticalGrid / LazyHorizontalGrid on Android, LazyVGrid / LazyHGrid on iOS) even when the binding resolves to 1, so the grid layout stays stable across runtime column changes."
3887
+ "description": "Number of columns. Literal int -> static GridCells.Fixed(N) / Array(repeating: ..., count: N). Binding @{prop} -> runtime-resolved data.prop; the Collection always renders on the multi-column grid path (LazyVerticalGrid / LazyHorizontalGrid on Android, LazyVGrid / LazyHGrid on iOS) even when the binding resolves to 1, so the grid layout stays stable across runtime column changes. On a horizontal Collection `columns` is the number of lanes (jsonui-cli 1.9.0; until then every horizontal route drew one lane), and a section's own `columns` is that section block's lanes. A paging Collection draws no lanes: paging is not a lane route, whatever `columns` says."
3866
3888
  },
3867
3889
  "columnSpacing": {
3868
3890
  "type": "number",
@@ -4089,7 +4111,7 @@
4089
4111
  "binding"
4090
4112
  ],
4091
4113
  "binding_direction": "one-way",
4092
- "description": "Collection page/selection change handler. Canonical; prefer over onPageChanged.",
4114
+ "description": "Collection page/selection change handler. Canonical; prefer over onPageChanged. Called when the page changes — by a swipe, a scrollTo or a currentPage write — with the new page; not called when the pager first appears (ruling 2026-10-02).",
4093
4115
  "platform": [
4094
4116
  "swift",
4095
4117
  "kotlin",
@@ -4836,7 +4858,7 @@
4836
4858
  "binding"
4837
4859
  ],
4838
4860
  "binding_direction": "one-way",
4839
- "description": "Tab/page selection change handler. Canonical; prefer over onTabChange/onPageChanged.",
4861
+ "description": "Tab/page selection change handler. Canonical; prefer over onTabChange/onPageChanged. Called when the selected tab changes — by a tap on another tab or a selectedIndex write — with the new index; not called when the TabView first appears, nor when the selected tab is tapped again (ruling 2026-10-02).",
4840
4862
  "platform": [
4841
4863
  "swift",
4842
4864
  "kotlin",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  "contexts": {
12
12
  "text": {
13
- "description": "Mixed-text interpolation. A string attribute may contain any number of '@{...}' occurrences with surrounding literal text; each occurrence is replaced by the stringified resolved value.",
14
- "interpolation": true,
13
+ "description": "A text attribute's binding: the whole value is one '@{...}' expression, stringified. A value that mixes literal text with a binding ('Title: @{x}', '@{a} / @{b}') is a warning, binding-mixed-text (ruling 2026-10-02): compose the string in the ViewModel and bind it as one value — a layout holds no logic, and a composed string cannot be localized as one text. Until jsonui-cli 1.9.6 this context declared mixed-text interpolation (each occurrence replaced by its stringified value); sjui and rjui still interpolate such a value and kjui draws its first binding alone, outside any guarantee.",
14
+ "interpolation": false,
15
15
  "features": ["path", "default"],
16
16
  "unresolved": "emptyString"
17
17
  },
@@ -28,8 +28,8 @@
28
28
  "unresolved": "dropKey"
29
29
  },
30
30
  "nameRef": {
31
- "readBy": "Hand-mirrored per face, measured 2026-09-10 and pinned per face: sjui_tools/lib/swiftui/views/base_view_converter.rb (onClick = binding, onclick = bare selector name) pinned by sjui_tools/spec/swiftui/views/handler_name_spelling_spec.rb; kjui_tools/lib/compose/helpers/modifier_builder.rb (camelCase events require the binding form) pinned by kjui_tools/spec/compose/components/handler_name_spelling_spec.rb; rjui_tools/lib/react/converters/base_converter.rb (onClick = binding only, onclick = bare only) pinned by rjui_tools/spec/react/converters/handler_name_spelling_spec.rb. 0 vectors: this context is code generation, not runtime resolution. Known gap, declared here and not a rule: a bare camelCase handler compiles to a no-op with no build-time report (an inline ERROR comment on kjui and rjui, nothing on sjui). A validator rule would need scoping to the declared event attributes first — consumer layouts also use camelCase keys as custom-component props (measured 2026-09-10).",
32
- "description": "Event / handler NAME references. The camelCase event attributes (onClick, onAppear, onLongPress, …) take the binding form only: '@{name}' names the handler, and a bare string is NOT a handler on any face (measured 2026-09-10: kjui and rjui emit an inline ERROR comment and no call; sjui emits an empty action). The lowercase `onclick` is the UIKit selector legacy and takes the bare name only. Name extraction, not value resolution: path, default and negation are not applicable. Until 2026-09-10 this entry said the two spellings were equivalent; no generator ever implemented that, and the sentence was corrected to what they do.",
31
+ "readBy": "Hand-mirrored per face and pinned per face: sjui_tools/lib/swiftui/views/base_view_converter.rb (onClick = binding, onclick = bare selector name; handler_reference? = binding or bare name for the string-declared events) pinned by sjui_tools/spec/swiftui/views/handler_name_spelling_spec.rb and sjui_tools/spec/swiftui/bare_handler_on_string_events_spec.rb; kjui_tools/lib/compose/helpers/modifier_builder.rb pinned by kjui_tools/spec/compose/components/handler_name_spelling_spec.rb; rjui_tools/lib/react/converters/base_converter.rb (onClick = binding only, onclick = bare only) pinned by rjui_tools/spec/react/converters/handler_name_spelling_spec.rb. The binding-only rule is shared/core/binding_validator_core.rb check_bare_event, pinned by each face's spec/core/binding_bare_event_spec.rb, scoped to the types the SSoT declares (consumer layouts use camelCase keys as custom-component props). 0 vectors: this context is code generation, not runtime resolution. Measured 2026-10-02 over 48 event attributes: a bare name on a string-declared event was still dropped by kjui and rjui on a Collection's onValueChange / onItemAppear and TabView onValueChange (sjui calls it from jsonui-cli 1.9.6) — ticket bare-event-handler-is-dropped-without-a-warning.",
32
+ "description": "Event / handler NAME references. What a camelCase event attribute takes is its declared type (attribute_definitions): declared binding-only (type \"binding\" with no \"string\": onClick, onLongPress, Switch / Slider / Segment / CheckBox / Radio / SelectBox onValueChange, …) it takes '@{name}' only, and a bare string is not a handler — the binding validator reports one (binding-bare-event); declared with \"string\" (onAppear, TextField / TextView onTextChange, TextField onSubmit, a Collection's onValueChange / onItemAppear, TabView onValueChange, …) it takes '@{name}' or the bare name, both naming the data's closure. The lowercase `onclick` is the UIKit selector legacy and takes the bare name only. Name extraction, not value resolution: path, default and negation are not applicable. Until 2026-09-10 this entry said the two spellings were equivalent everywhere, and from then until 2026-10-02 that every camelCase event took the binding form only; neither matched the declarations (the conformance generator writes onAppear's bare name), and the sentence follows the declared types since.",
33
33
  "interpolation": false,
34
34
  "features": [],
35
35
  "unresolved": "platformEventContract"
@@ -122,6 +122,8 @@
122
122
  {"id": "binding-negation-context", "severity": "error", "context": "text|embedParams|nameRef|twoWay", "rule": "'!' negation used outside a boolean value context."},
123
123
  {"id": "binding-default-in-params", "severity": "error", "context": "embedParams", "rule": "'??' default used in an embed params leaf (defaults belong to the embedded screen's data section)."},
124
124
  {"id": "binding-params-array", "severity": "error", "context": "embedParams", "rule": "Array anywhere inside params (pre-existing rule from the embed isolated+params track; restated here)."},
125
- {"id": "binding-cell-parent-scope", "severity": "warning", "context": "collectionCell", "rule": "Cell layout binds a key that is only present in the parent screen's data."}
125
+ {"id": "binding-cell-parent-scope", "severity": "warning", "context": "collectionCell", "rule": "Cell layout binds a key that is only present in the parent screen's data."},
126
+ {"id": "binding-mixed-text", "severity": "warning", "context": "text|value", "rule": "A value of an attribute the component declares holds a binding and is not one binding (literal text around it, or two bindings). Compose the string in the ViewModel (it also keeps the text localizable) and bind it as one value; a layout holds no logic."},
127
+ {"id": "binding-bare-event", "severity": "warning", "context": "nameRef", "rule": "A bare name given to an event attribute of a declared type whose declared type is binding-only (\"binding\" with no \"string\")."}
126
128
  ]
127
129
  }
@@ -43,7 +43,7 @@
43
43
  }
44
44
  },
45
45
  "rules": [
46
- "text attribute supports interpolation with @{binding} syntax",
46
+ "text is a literal or one binding (@{name}); a value that mixes literal text with a binding is the binding-mixed-text warning",
47
47
  "partialAttributes allows styling ranges within text",
48
48
  "linkable enables auto-detection of URLs",
49
49
  "Frame alignment must be .leading for textAlign to work correctly (Swift)"
@@ -0,0 +1,18 @@
1
+ export declare const TEXT_KEYS: readonly string[];
2
+ export declare const TEXT_KEY_SCOPE: Readonly<Record<string, ["not-under" | "only-under", readonly string[]]>>;
3
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
4
+ * indices left out), is a prose field a reference may stand in. */
5
+ export declare function isTextField(ancestors: readonly string[], key: string): boolean;
6
+ export interface TextsExpansion {
7
+ /** The spec with each reference expanded, or the input when it has none. */
8
+ data: unknown;
9
+ /** How many references were found / expanded. */
10
+ found: number;
11
+ expanded: number;
12
+ /** One line per reference that could not be expanded. */
13
+ problems: string[];
14
+ /** Texts files read (relative to the spec's directory). */
15
+ files: string[];
16
+ }
17
+ export declare function pairedTextsName(specFile: string): string;
18
+ export declare function expandSpecTexts(data: unknown, specPath: string): TextsExpansion;
@@ -0,0 +1,145 @@
1
+ // Expands `{"md": ...}` prose references in a spec for a reader.
2
+ //
3
+ // A spec's prose fields (TEXT_KEYS below, scoped by TEXT_KEY_SCOPE) may
4
+ // point into a YAML texts file instead of holding the text inline (jsonui-cli
5
+ // shared/core/spec_texts.py is the definition, and `isTextField` mirrors its
6
+ // `is_text_field`):
7
+ //
8
+ // {"md": "a.b.c"} -> <spec name>.texts.yaml, key path a.b.c
9
+ // {"md": "shared/x.texts.yaml#a.b"} -> that file (relative to the spec)
10
+ //
11
+ // This is a READER, not the validator. It expands what it can and marks what
12
+ // it cannot; the YAML rules (string keys, no '.', no duplicates, ...) are
13
+ // enforced by `jsonui-doc validate spec`, and are deliberately not copied
14
+ // here — a second copy of a rule is the drift this codebase keeps removing.
15
+ //
16
+ // Each reference becomes `{"md": <as written>, "text": <the text>}` rather
17
+ // than the bare text: an agent reading the spec must see both what it says
18
+ // and WHERE it lives, or it edits the JSON and the YAML stays stale. The
19
+ // `text` key is not valid in a spec, so a copy pasted back is refused by the
20
+ // validator instead of silently kept.
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { basename, dirname, join } from "path";
23
+ import { parse } from "yaml";
24
+ // Mirrors TEXT_KEYS / TEXT_KEY_SCOPE / is_text_field in jsonui-cli
25
+ // shared/core/spec_texts.py. Prose only: a key a tool parses, matches as a
26
+ // name or emits as code is not here, and at the paths TEXT_KEY_SCOPE names
27
+ // the same key is not prose either.
28
+ export const TEXT_KEYS = [
29
+ "description", "notes", "intent", "purpose", "processing",
30
+ "handling", "rule", "meaning", "note", "reason", "condition",
31
+ ];
32
+ // key -> [mode, ancestor keys]. "not-under": prose unless one of those keys
33
+ // is an ancestor; "only-under": prose only when the first (top-level)
34
+ // ancestor is one of them.
35
+ export const TEXT_KEY_SCOPE = {
36
+ reason: ["not-under", ["harnessConditions", "canonicalDivergence"]],
37
+ condition: ["only-under", ["transitions"]],
38
+ };
39
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
40
+ * indices left out), is a prose field a reference may stand in. */
41
+ export function isTextField(ancestors, key) {
42
+ if (!TEXT_KEYS.includes(key))
43
+ return false;
44
+ const scope = Object.prototype.hasOwnProperty.call(TEXT_KEY_SCOPE, key)
45
+ ? TEXT_KEY_SCOPE[key]
46
+ : undefined;
47
+ if (scope === undefined)
48
+ return true;
49
+ const [mode, names] = scope;
50
+ if (mode === "not-under")
51
+ return !ancestors.some((a) => names.includes(a));
52
+ return ancestors.length > 0 && names.includes(ancestors[0]);
53
+ }
54
+ const REF_KEY = "md";
55
+ export function pairedTextsName(specFile) {
56
+ let name = basename(specFile);
57
+ for (const suffix of [".spec.json", ".component.json", ".json"]) {
58
+ if (name.endsWith(suffix)) {
59
+ name = name.slice(0, -suffix.length);
60
+ break;
61
+ }
62
+ }
63
+ return `${name}.texts.yaml`;
64
+ }
65
+ function isReference(value) {
66
+ return (typeof value === "object" && value !== null && !Array.isArray(value) &&
67
+ REF_KEY in value);
68
+ }
69
+ export function expandSpecTexts(data, specPath) {
70
+ const baseDir = dirname(specPath);
71
+ const paired = pairedTextsName(specPath);
72
+ const cache = new Map();
73
+ const result = { data, found: 0, expanded: 0, problems: [], files: [] };
74
+ const load = (rel) => {
75
+ if (!cache.has(rel)) {
76
+ const abs = join(baseDir, rel);
77
+ if (!existsSync(abs)) {
78
+ cache.set(rel, new Error(`${rel} does not exist`));
79
+ }
80
+ else {
81
+ try {
82
+ cache.set(rel, parse(readFileSync(abs, "utf-8")));
83
+ result.files.push(rel);
84
+ }
85
+ catch (e) {
86
+ cache.set(rel, new Error(`${rel} could not be parsed: ${e.message}`));
87
+ }
88
+ }
89
+ }
90
+ return cache.get(rel);
91
+ };
92
+ const expand = (ref, where) => {
93
+ result.found += 1;
94
+ const target = ref[REF_KEY];
95
+ if (typeof target !== "string" || !target.trim()) {
96
+ result.problems.push(`${where}: malformed reference`);
97
+ return ref;
98
+ }
99
+ const hash = target.indexOf("#");
100
+ const file = hash >= 0 ? target.slice(0, hash) : paired;
101
+ const key = hash >= 0 ? target.slice(hash + 1) : target;
102
+ const doc = load(file);
103
+ if (doc instanceof Error) {
104
+ result.problems.push(`${where}: ${doc.message}`);
105
+ return ref;
106
+ }
107
+ let node = doc;
108
+ for (const part of key.split(".")) {
109
+ node = typeof node === "object" && node !== null && !Array.isArray(node)
110
+ ? node[part]
111
+ : undefined;
112
+ }
113
+ if (typeof node !== "string") {
114
+ result.problems.push(`${where}: '${file}#${key}' is ${node === undefined ? "not defined" : "not a text"}`);
115
+ return ref;
116
+ }
117
+ result.expanded += 1;
118
+ return { ...ref, text: node };
119
+ };
120
+ const walk = (node, path, inText, ancestors) => {
121
+ if (Array.isArray(node)) {
122
+ // `notes: [...]` — each entry is prose too. Deeper lists under a text
123
+ // key are not prose, so the flag is not carried past one.
124
+ return node.map((v, i) => walk(v, `${path}[${i}]`, inText && !Array.isArray(v), ancestors));
125
+ }
126
+ if (typeof node === "object" && node !== null) {
127
+ if (isReference(node)) {
128
+ if (inText)
129
+ return expand(node, path);
130
+ // Outside a prose field the validator refuses it; a reader leaves it.
131
+ return node;
132
+ }
133
+ const out = {};
134
+ for (const [k, v] of Object.entries(node)) {
135
+ out[k] = walk(v, path ? `${path}.${k}` : k, isTextField(ancestors, k), [...ancestors, k]);
136
+ }
137
+ return out;
138
+ }
139
+ return node;
140
+ };
141
+ const walked = walk(data, "", false, []);
142
+ if (result.found > 0)
143
+ result.data = walked;
144
+ return result;
145
+ }
@@ -1,8 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
+ import { expandSpecTexts } from "../../spec_texts.js";
4
5
  export function register(server, config) {
5
- server.tool("read_spec_file", "Read the contents of a screen or component spec file", {
6
+ server.tool("read_spec_file", "Read the contents of a screen or component spec file. Prose fields " +
7
+ "(description / notes / intent / purpose / processing / handling / " +
8
+ "rule / meaning / note / reason — not under harnessConditions or " +
9
+ "canonicalDivergence — and condition under transitions only) written " +
10
+ "as {\"md\": \"key\"} live in a " +
11
+ "YAML texts file (<name>.texts.yaml beside the spec, or " +
12
+ "{\"md\": \"file.texts.yaml#key\"}); they are returned expanded as " +
13
+ "{\"md\": ..., \"text\": ...} — edit the text in the YAML file, not " +
14
+ "here. A spec without such references is returned byte-for-byte. " +
15
+ "search_specs does not search text held in a texts file.", {
6
16
  file: z.string().describe("Filename (e.g., 'login.spec.json' or 'my_card.component.json')"),
7
17
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
8
18
  }, async ({ file, project_dir }) => {
@@ -18,7 +28,29 @@ export function register(server, config) {
18
28
  return { content: [{ type: "text", text: `File not found: ${filePath}` }] };
19
29
  }
20
30
  const content = readFileSync(filePath, "utf-8");
21
- return { content: [{ type: "text", text: content }] };
31
+ // Only a spec that uses texts references is re-serialised; every
32
+ // other spec comes back exactly as it is on disk.
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(content);
36
+ }
37
+ catch {
38
+ return { content: [{ type: "text", text: content }] };
39
+ }
40
+ const texts = expandSpecTexts(parsed, filePath);
41
+ if (texts.found === 0) {
42
+ return { content: [{ type: "text", text: content }] };
43
+ }
44
+ const blocks = [{ type: "text", text: JSON.stringify(texts.data, null, 2) }];
45
+ const summary = `texts: ${texts.expanded}/${texts.found} reference(s) expanded as ` +
46
+ `{"md", "text"} from ${texts.files.join(", ") || "(no file read)"} — ` +
47
+ `the "text" key is for reading only; edit the YAML file.`;
48
+ const problems = texts.problems.length
49
+ ? "\nunresolved (run jsonui-doc validate spec for the full check):\n" +
50
+ texts.problems.map((p) => ` - ${p}`).join("\n")
51
+ : "";
52
+ blocks.push({ type: "text", text: summary + problems });
53
+ return { content: blocks };
22
54
  }
23
55
  catch (e) {
24
56
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
package/docs/design.md CHANGED
@@ -100,7 +100,7 @@ These are served directly by the `get_modifier_order`,
100
100
  | `list_screen_specs` | Screen-spec index with metadata. |
101
101
  | `list_component_specs` | Component-spec index with metadata. |
102
102
  | `list_layouts` | Layout JSON inventory. |
103
- | `read_spec_file` | One spec file (no parsing beyond `JSON.parse`). |
103
+ | `read_spec_file` | One spec file. Byte-for-byte unless it carries `{"md": ...}` prose references (jsonui-cli `shared/core/spec_texts.py`); those are expanded to `{"md", "text"}` from the YAML texts file by `src/spec_texts.ts` — a reader only, the YAML rules stay in `jsonui-doc validate`. Which fields are prose is path-aware and mirrors `is_text_field` (description / notes / intent / purpose / processing / handling / rule / meaning / note / reason — not under `harnessConditions` / `canonicalDivergence` — and `condition` under top-level `transitions` only). `search_specs` does not search texts-file text. |
104
104
  | `read_layout_file` | One layout JSON file. |
105
105
 
106
106
  ### Group C — `jui` CLI wrappers (8)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.14.0",
3
+ "version": "2.15.2",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -15,6 +15,7 @@
15
15
  "type": "module",
16
16
  "dependencies": {
17
17
  "@modelcontextprotocol/sdk": "^1.12.1",
18
+ "yaml": "^2.9.1",
18
19
  "zod": "^3.23.0"
19
20
  },
20
21
  "devDependencies": {
@@ -44,7 +44,7 @@ const DATA_DIR = join(PROJECT_ROOT, "data");
44
44
  // 3. `npm run fetch-definitions` and commit the data/ diff together with
45
45
  // this pin in one commit
46
46
  // JSONUI_CLI_RAW_BASE still overrides the whole base URL for manual testing.
47
- const JSONUI_CLI_TAG = "v1.9.0";
47
+ const JSONUI_CLI_TAG = "v1.9.7";
48
48
  const BASE_URL =
49
49
  process.env.JSONUI_CLI_RAW_BASE ||
50
50
  `https://raw.githubusercontent.com/Tai-Kimura/jsonui-cli/${JSONUI_CLI_TAG}`;
@@ -0,0 +1,167 @@
1
+ // Expands `{"md": ...}` prose references in a spec for a reader.
2
+ //
3
+ // A spec's prose fields (TEXT_KEYS below, scoped by TEXT_KEY_SCOPE) may
4
+ // point into a YAML texts file instead of holding the text inline (jsonui-cli
5
+ // shared/core/spec_texts.py is the definition, and `isTextField` mirrors its
6
+ // `is_text_field`):
7
+ //
8
+ // {"md": "a.b.c"} -> <spec name>.texts.yaml, key path a.b.c
9
+ // {"md": "shared/x.texts.yaml#a.b"} -> that file (relative to the spec)
10
+ //
11
+ // This is a READER, not the validator. It expands what it can and marks what
12
+ // it cannot; the YAML rules (string keys, no '.', no duplicates, ...) are
13
+ // enforced by `jsonui-doc validate spec`, and are deliberately not copied
14
+ // here — a second copy of a rule is the drift this codebase keeps removing.
15
+ //
16
+ // Each reference becomes `{"md": <as written>, "text": <the text>}` rather
17
+ // than the bare text: an agent reading the spec must see both what it says
18
+ // and WHERE it lives, or it edits the JSON and the YAML stays stale. The
19
+ // `text` key is not valid in a spec, so a copy pasted back is refused by the
20
+ // validator instead of silently kept.
21
+
22
+ import { existsSync, readFileSync } from "fs";
23
+ import { basename, dirname, join } from "path";
24
+ import { parse } from "yaml";
25
+
26
+ // Mirrors TEXT_KEYS / TEXT_KEY_SCOPE / is_text_field in jsonui-cli
27
+ // shared/core/spec_texts.py. Prose only: a key a tool parses, matches as a
28
+ // name or emits as code is not here, and at the paths TEXT_KEY_SCOPE names
29
+ // the same key is not prose either.
30
+ export const TEXT_KEYS: readonly string[] = [
31
+ "description", "notes", "intent", "purpose", "processing",
32
+ "handling", "rule", "meaning", "note", "reason", "condition",
33
+ ];
34
+
35
+ // key -> [mode, ancestor keys]. "not-under": prose unless one of those keys
36
+ // is an ancestor; "only-under": prose only when the first (top-level)
37
+ // ancestor is one of them.
38
+ export const TEXT_KEY_SCOPE: Readonly<Record<string, ["not-under" | "only-under", readonly string[]]>> = {
39
+ reason: ["not-under", ["harnessConditions", "canonicalDivergence"]],
40
+ condition: ["only-under", ["transitions"]],
41
+ };
42
+
43
+ /** Whether `key`, reached through `ancestors` (the dict keys above it, list
44
+ * indices left out), is a prose field a reference may stand in. */
45
+ export function isTextField(ancestors: readonly string[], key: string): boolean {
46
+ if (!TEXT_KEYS.includes(key)) return false;
47
+ const scope = Object.prototype.hasOwnProperty.call(TEXT_KEY_SCOPE, key)
48
+ ? TEXT_KEY_SCOPE[key]
49
+ : undefined;
50
+ if (scope === undefined) return true;
51
+ const [mode, names] = scope;
52
+ if (mode === "not-under") return !ancestors.some((a) => names.includes(a));
53
+ return ancestors.length > 0 && names.includes(ancestors[0]);
54
+ }
55
+ const REF_KEY = "md";
56
+
57
+ export interface TextsExpansion {
58
+ /** The spec with each reference expanded, or the input when it has none. */
59
+ data: unknown;
60
+ /** How many references were found / expanded. */
61
+ found: number;
62
+ expanded: number;
63
+ /** One line per reference that could not be expanded. */
64
+ problems: string[];
65
+ /** Texts files read (relative to the spec's directory). */
66
+ files: string[];
67
+ }
68
+
69
+ export function pairedTextsName(specFile: string): string {
70
+ let name = basename(specFile);
71
+ for (const suffix of [".spec.json", ".component.json", ".json"]) {
72
+ if (name.endsWith(suffix)) {
73
+ name = name.slice(0, -suffix.length);
74
+ break;
75
+ }
76
+ }
77
+ return `${name}.texts.yaml`;
78
+ }
79
+
80
+ function isReference(value: unknown): value is Record<string, unknown> {
81
+ return (
82
+ typeof value === "object" && value !== null && !Array.isArray(value) &&
83
+ REF_KEY in (value as object)
84
+ );
85
+ }
86
+
87
+ export function expandSpecTexts(data: unknown, specPath: string): TextsExpansion {
88
+ const baseDir = dirname(specPath);
89
+ const paired = pairedTextsName(specPath);
90
+ const cache = new Map<string, unknown | Error>();
91
+ const result: TextsExpansion = { data, found: 0, expanded: 0, problems: [], files: [] };
92
+
93
+ const load = (rel: string): unknown | Error => {
94
+ if (!cache.has(rel)) {
95
+ const abs = join(baseDir, rel);
96
+ if (!existsSync(abs)) {
97
+ cache.set(rel, new Error(`${rel} does not exist`));
98
+ } else {
99
+ try {
100
+ cache.set(rel, parse(readFileSync(abs, "utf-8")));
101
+ result.files.push(rel);
102
+ } catch (e: any) {
103
+ cache.set(rel, new Error(`${rel} could not be parsed: ${e.message}`));
104
+ }
105
+ }
106
+ }
107
+ return cache.get(rel);
108
+ };
109
+
110
+ const expand = (ref: Record<string, unknown>, where: string): unknown => {
111
+ result.found += 1;
112
+ const target = ref[REF_KEY];
113
+ if (typeof target !== "string" || !target.trim()) {
114
+ result.problems.push(`${where}: malformed reference`);
115
+ return ref;
116
+ }
117
+ const hash = target.indexOf("#");
118
+ const file = hash >= 0 ? target.slice(0, hash) : paired;
119
+ const key = hash >= 0 ? target.slice(hash + 1) : target;
120
+ const doc = load(file);
121
+ if (doc instanceof Error) {
122
+ result.problems.push(`${where}: ${doc.message}`);
123
+ return ref;
124
+ }
125
+ let node: unknown = doc;
126
+ for (const part of key.split(".")) {
127
+ node = typeof node === "object" && node !== null && !Array.isArray(node)
128
+ ? (node as Record<string, unknown>)[part]
129
+ : undefined;
130
+ }
131
+ if (typeof node !== "string") {
132
+ result.problems.push(
133
+ `${where}: '${file}#${key}' is ${node === undefined ? "not defined" : "not a text"}`);
134
+ return ref;
135
+ }
136
+ result.expanded += 1;
137
+ return { ...ref, text: node };
138
+ };
139
+
140
+ const walk = (
141
+ node: unknown, path: string, inText: boolean, ancestors: readonly string[],
142
+ ): unknown => {
143
+ if (Array.isArray(node)) {
144
+ // `notes: [...]` — each entry is prose too. Deeper lists under a text
145
+ // key are not prose, so the flag is not carried past one.
146
+ return node.map((v, i) =>
147
+ walk(v, `${path}[${i}]`, inText && !Array.isArray(v), ancestors));
148
+ }
149
+ if (typeof node === "object" && node !== null) {
150
+ if (isReference(node)) {
151
+ if (inText) return expand(node, path);
152
+ // Outside a prose field the validator refuses it; a reader leaves it.
153
+ return node;
154
+ }
155
+ const out: Record<string, unknown> = {};
156
+ for (const [k, v] of Object.entries(node)) {
157
+ out[k] = walk(v, path ? `${path}.${k}` : k, isTextField(ancestors, k), [...ancestors, k]);
158
+ }
159
+ return out;
160
+ }
161
+ return node;
162
+ };
163
+
164
+ const walked = walk(data, "", false, []);
165
+ if (result.found > 0) result.data = walked;
166
+ return result;
167
+ }
@@ -3,11 +3,21 @@ import { z } from "zod";
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { ServerConfig } from "../../config.js";
6
+ import { expandSpecTexts } from "../../spec_texts.js";
6
7
 
7
8
  export function register(server: McpServer, config: ServerConfig) {
8
9
  server.tool(
9
10
  "read_spec_file",
10
- "Read the contents of a screen or component spec file",
11
+ "Read the contents of a screen or component spec file. Prose fields " +
12
+ "(description / notes / intent / purpose / processing / handling / " +
13
+ "rule / meaning / note / reason — not under harnessConditions or " +
14
+ "canonicalDivergence — and condition under transitions only) written " +
15
+ "as {\"md\": \"key\"} live in a " +
16
+ "YAML texts file (<name>.texts.yaml beside the spec, or " +
17
+ "{\"md\": \"file.texts.yaml#key\"}); they are returned expanded as " +
18
+ "{\"md\": ..., \"text\": ...} — edit the text in the YAML file, not " +
19
+ "here. A spec without such references is returned byte-for-byte. " +
20
+ "search_specs does not search text held in a texts file.",
11
21
  {
12
22
  file: z.string().describe("Filename (e.g., 'login.spec.json' or 'my_card.component.json')"),
13
23
  project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
@@ -29,7 +39,29 @@ export function register(server: McpServer, config: ServerConfig) {
29
39
  }
30
40
 
31
41
  const content = readFileSync(filePath, "utf-8");
32
- return { content: [{ type: "text", text: content }] };
42
+ // Only a spec that uses texts references is re-serialised; every
43
+ // other spec comes back exactly as it is on disk.
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(content);
47
+ } catch {
48
+ return { content: [{ type: "text", text: content }] };
49
+ }
50
+ const texts = expandSpecTexts(parsed, filePath);
51
+ if (texts.found === 0) {
52
+ return { content: [{ type: "text", text: content }] };
53
+ }
54
+ const blocks = [{ type: "text" as const, text: JSON.stringify(texts.data, null, 2) }];
55
+ const summary =
56
+ `texts: ${texts.expanded}/${texts.found} reference(s) expanded as ` +
57
+ `{"md", "text"} from ${texts.files.join(", ") || "(no file read)"} — ` +
58
+ `the "text" key is for reading only; edit the YAML file.`;
59
+ const problems = texts.problems.length
60
+ ? "\nunresolved (run jsonui-doc validate spec for the full check):\n" +
61
+ texts.problems.map((p) => ` - ${p}`).join("\n")
62
+ : "";
63
+ blocks.push({ type: "text" as const, text: summary + problems });
64
+ return { content: blocks };
33
65
  } catch (e: any) {
34
66
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
35
67
  }
@@ -2,11 +2,12 @@
2
2
  * Group B tools (project context) — exercised against a tmp-dir fixture
3
3
  * project (jui.config.json + specs + layouts). No real project is touched.
4
4
  */
5
- import { symlinkSync, writeFileSync } from "fs";
5
+ import { mkdirSync, symlinkSync, writeFileSync } from "fs";
6
6
  import { join } from "path";
7
7
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
8
 
9
9
  import { ServerConfig } from "../src/config.js";
10
+ import { isTextField } from "../src/spec_texts.js";
10
11
  import { register as registerGetProjectConfig } from "../src/tools/context/get_project_config.js";
11
12
  import { register as registerListScreenSpecs } from "../src/tools/context/list_screen_specs.js";
12
13
  import { register as registerListComponentSpecs } from "../src/tools/context/list_component_specs.js";
@@ -341,6 +342,115 @@ describe("read_spec_file", () => {
341
342
  });
342
343
  });
343
344
 
345
+ describe("read_spec_file — texts-file prose", () => {
346
+ // `{"md": ...}` in a prose field (description / notes / intent / ...,
347
+ // path-scoped for reason and condition) points into a YAML file
348
+ // (jsonui-cli shared/core/spec_texts.py). The reader expands it next to the
349
+ // reference, so an agent sees the text AND where to edit it.
350
+ const specDir = () => join(projectDir, "docs/screens/json");
351
+ const spec = {
352
+ type: "app_contracts_spec",
353
+ version: "1.0",
354
+ metadata: { name: "app", description: { md: "overview" } },
355
+ unitContracts: [
356
+ {
357
+ target: "ApiClient",
358
+ cases: [
359
+ { name: "retries", intent: { md: "cases.api.retries" } },
360
+ { name: "shared", intent: { md: "shared/net.texts.yaml#timeout" } },
361
+ { name: "inline", intent: "plain" },
362
+ ],
363
+ },
364
+ ],
365
+ };
366
+
367
+ async function callFull(file: string) {
368
+ const tool = harness.tools.get("read_spec_file")!;
369
+ const result = await tool.handler({ file, project_dir: projectDir });
370
+ return result.content.map((c: { text: string }) => c.text);
371
+ }
372
+
373
+ beforeEach(() => {
374
+ writeJson(join(specDir(), "app.spec.json"), spec);
375
+ writeFileSync(
376
+ join(specDir(), "app.texts.yaml"),
377
+ "overview: |\n # Title\n\n - item\ncases:\n api:\n retries: \"**once**\"\n"
378
+ );
379
+ });
380
+
381
+ it("expands paired and named references as {md, text}", async () => {
382
+ const shared = join(specDir(), "shared");
383
+ mkdirSync(shared, { recursive: true });
384
+ writeFileSync(join(shared, "net.texts.yaml"), "timeout: ten seconds\n");
385
+ const [json, summary] = await callFull("app.spec.json");
386
+ const data = JSON.parse(json);
387
+ expect(data.metadata.description).toEqual({ md: "overview", text: "# Title\n\n- item\n" });
388
+ const cases = data.unitContracts[0].cases;
389
+ expect(cases[0].intent).toEqual({ md: "cases.api.retries", text: "**once**" });
390
+ expect(cases[1].intent.text).toBe("ten seconds");
391
+ expect(cases[2].intent).toBe("plain");
392
+ expect(summary).toContain("3/3 reference(s) expanded");
393
+ expect(summary).toContain("edit the YAML file");
394
+ });
395
+
396
+ it("names a reference it cannot expand and leaves it as written", async () => {
397
+ const [json, summary] = await callFull("app.spec.json");
398
+ const cases = JSON.parse(json).unitContracts[0].cases;
399
+ expect(cases[1].intent).toEqual({ md: "shared/net.texts.yaml#timeout" });
400
+ expect(summary).toContain("2/3 reference(s) expanded");
401
+ expect(summary).toContain("shared/net.texts.yaml does not exist");
402
+ });
403
+
404
+ it("expands purpose / reason / condition-under-transitions, and leaves reason and condition where they are not prose", async () => {
405
+ const scoped = {
406
+ type: "screen_spec",
407
+ metadata: { name: "Scoped" },
408
+ decorativeElements: [{ id: "bg", purpose: { md: "p" } }],
409
+ apiOutcomeRules: [{ op: "get", reason: { md: "r" } }],
410
+ transitions: [{ destination: "Next", condition: { md: "c" } }],
411
+ harnessConditions: [{ name: "h", reason: { md: "r" } }],
412
+ contracts: { canonicalDivergence: [{ reason: { md: "r" } }] },
413
+ displayLogic: [{ condition: { md: "c" } }],
414
+ navigation: { transitions: [{ condition: { md: "c" } }] },
415
+ };
416
+ writeJson(join(specDir(), "scoped.spec.json"), scoped);
417
+ writeFileSync(join(specDir(), "scoped.texts.yaml"), "p: the purpose\nr: the reason\nc: the condition\n");
418
+ const [json, summary] = await callFull("scoped.spec.json");
419
+ const data = JSON.parse(json);
420
+ expect(data.decorativeElements[0].purpose).toEqual({ md: "p", text: "the purpose" });
421
+ expect(data.apiOutcomeRules[0].reason).toEqual({ md: "r", text: "the reason" });
422
+ expect(data.transitions[0].condition).toEqual({ md: "c", text: "the condition" });
423
+ // Not prose at these paths: found, left exactly as written.
424
+ expect(data.harnessConditions[0].reason).toEqual({ md: "r" });
425
+ expect(data.contracts.canonicalDivergence[0].reason).toEqual({ md: "r" });
426
+ expect(data.displayLogic[0].condition).toEqual({ md: "c" });
427
+ // `transitions` must be the FIRST ancestor, not any ancestor.
428
+ expect(data.navigation.transitions[0].condition).toEqual({ md: "c" });
429
+ expect(summary).toContain("3/3 reference(s) expanded");
430
+ });
431
+
432
+ it("isTextField matches jsonui-cli is_text_field", () => {
433
+ expect(isTextField([], "description")).toBe(true);
434
+ expect(isTextField(["userActions"], "processing")).toBe(true);
435
+ expect(isTextField(["apiOutcomeRules"], "reason")).toBe(true);
436
+ expect(isTextField(["harnessConditions"], "reason")).toBe(false);
437
+ expect(isTextField(["x", "canonicalDivergence", "y"], "reason")).toBe(false);
438
+ expect(isTextField(["transitions"], "condition")).toBe(true);
439
+ expect(isTextField([], "condition")).toBe(false);
440
+ expect(isTextField(["validation", "serverSide"], "condition")).toBe(false);
441
+ expect(isTextField(["a", "transitions"], "condition")).toBe(false);
442
+ expect(isTextField([], "displayName")).toBe(false);
443
+ expect(isTextField([], "toString")).toBe(false);
444
+ });
445
+
446
+ it("returns a spec without references byte-for-byte", async () => {
447
+ const raw = '{"type":"screen_spec", "metadata":{"name":"X","description":"a\\nb"}}';
448
+ writeFileSync(join(specDir(), "raw.spec.json"), raw);
449
+ const texts = await callFull("raw.spec.json");
450
+ expect(texts).toEqual([raw]);
451
+ });
452
+ });
453
+
344
454
  describe("read_layout_file", () => {
345
455
  it("reads a top-level layout", async () => {
346
456
  const text = await harness.call("read_layout_file", {