jui-tools-mcp-server 2.6.0 → 2.8.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.
@@ -1544,6 +1544,24 @@
1544
1544
  "hintAttributes": {
1545
1545
  "type": "object",
1546
1546
  "description": "Hint text attributes (font, fontSize, fontColor). The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment.",
1547
+ "properties": {
1548
+ "font": {
1549
+ "type": "string",
1550
+ "description": "Font name"
1551
+ },
1552
+ "fontSize": {
1553
+ "type": "number",
1554
+ "description": "Font size"
1555
+ },
1556
+ "fontColor": {
1557
+ "type": "string",
1558
+ "description": "Font color - hex string or color name from colors.json"
1559
+ },
1560
+ "lineHeightMultiple": {
1561
+ "type": "number",
1562
+ "description": "Line height multiplier"
1563
+ }
1564
+ },
1547
1565
  "platform": "swift"
1548
1566
  },
1549
1567
  "font": {
@@ -1971,7 +1989,25 @@
1971
1989
  },
1972
1990
  "hintAttributes": {
1973
1991
  "type": "object",
1974
- "description": "Hint styling attributes. The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment."
1992
+ "description": "Hint styling attributes. The nested keys win over the flat spellings (hintColor / hintFont / hintFontSize): a bag scoped to the hint is the more specific statement. That is the ordinary cascade rule and what all four readers do (rjui label_converter, kjui text_component, sjui Label and SelectBox) - it was unwritten until 2026-08-05, when the one converter that had it backwards was found by contradicting its own comment.",
1993
+ "properties": {
1994
+ "font": {
1995
+ "type": "string",
1996
+ "description": "Font name"
1997
+ },
1998
+ "fontSize": {
1999
+ "type": "number",
2000
+ "description": "Font size"
2001
+ },
2002
+ "fontColor": {
2003
+ "type": "string",
2004
+ "description": "Font color - hex string or color name from colors.json"
2005
+ },
2006
+ "lineHeightMultiple": {
2007
+ "type": "number",
2008
+ "description": "Line height multiplier"
2009
+ }
2010
+ }
1975
2011
  },
1976
2012
  "hideOnFocused": {
1977
2013
  "type": "boolean",
@@ -8,28 +8,26 @@
8
8
  "styleAlone": "no-draw",
9
9
  "ruling": "A border is drawn only when BOTH borderWidth and borderColor are declared; borderWidth alone renders nothing on every platform — there is no default border color (2026-08-03 user ruling; supersedes the transient gray-default direction of d2c8628, unified across toolchains in 3e87b96). borderColor alone renders nothing for the same reason — the pair is what requests a border, and neither half requests one on its own (measured 2026-08-04, plan 34 re-measure: 0 px against the control on all three platforms; the android codegen does emit on the attribute's PRESENCE, which is a codegen-stage fact plan 41 tracks, not a rendered difference). borderStyle alone likewise renders nothing: it styles a border the width+color pair requests, it never summons one (2026-08-03 user ruling #2). NOTE: TextField.borderStyle is a DIFFERENT attribute (UIKit text-field chrome: roundedRect/line/bezel/none) and is outside this entry. The `__*_with_border` fixtures are the paired variants (borderStyle declared ALONGSIDE borderWidth + borderColor) that plan 49 added once three lanes had implemented the attribute with nothing measuring it — the lone-style fixtures above stay inert and keep testing the AND rule. `solid_with_border` is inert for a different reason from the lone ones: solid is the declared default of borderStyle, so stating it explicitly draws the same picture as omitting it (codegen_effect calls this class `value-is-default`). dashed/dotted are the two that must differ from the control. If the 3PF measurement comes back split rather than uniform, the honest record is a per-platform map like skinIdioms uses, not a forced uniform verdict: plan 34 caught ios emitting identical text for solid and dashed (C2/presence-only), and B/C/A implemented the three platforms separately in this wave. MEASURED 2026-08-05 (conformance-mobile run 30950551601 @ 5aada98), and the split the paragraph above anticipated is what happened: dashed and dotted are active on android and web and INERT on ios — the three borderStyle values render pixel-identical to each other and to the control on ios, which is the same symptom plan 34 recorded at the codegen stage (sjui emitting the same text for solid and dashed, C2/presence-only). They are therefore recorded as per-platform maps, not forced to a uniform verdict. This records today's reality; it does not bless it. If ios is supposed to draw a dashed border, the entry moves to uniformly-active once it does. solid_with_border stays uniformly-inert on all three because solid is borderStyle's declared default. RESOLVED 2026-08-05 on run 30990560599: ios draws both dashed and dotted, so the per-platform split is folded back to uniformly-active. The gap was never in sjui's codegen — that emitted StrokeStyle from dc35e82 — but in the SwiftUI dynamic renderer's applyBorder, which stroked with lineWidth only and so drew every style as solid. Recorded because the shape recurs: a codegen and a dynamic path disagreeing looks exactly like a platform not implementing an attribute.",
10
10
  "observable": {
11
- "common/borderWidth__static": "uniformly-inert",
12
- "common/borderColor__static": "uniformly-inert",
11
+ "common/borderWidth__static": "uniformly-active",
12
+ "common/borderColor__static": "uniformly-active",
13
13
  "common/borderStyle__solid": "uniformly-inert",
14
14
  "common/borderStyle__dashed": "uniformly-inert",
15
15
  "common/borderStyle__dotted": "uniformly-inert",
16
16
  "common/borderStyle__solid_with_border": "uniformly-inert",
17
17
  "common/borderStyle__dashed_with_border": "uniformly-active",
18
18
  "common/borderStyle__dotted_with_border": "uniformly-active"
19
- }
19
+ },
20
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
20
21
  },
21
22
  "highlight": {
22
23
  "highlightedAlone": "no-draw",
23
24
  "highlightBackgroundAlone": "no-draw",
24
25
  "ruling": "The highlighted appearance is drawn only when BOTH `highlighted` and a highlight colour are declared — the same pair-required shape as `border`, and ruled the same way. `highlighted: true` alone swaps the background to a colour that was never given, so nothing changes; `highlightBackground` alone names a colour for a state the view is never in. Every codegen already treats them as a pair (kjui modifier_builder.rb:490 combines highlight_cond with highlight_bg; sjui apply_highlighted_to_bag does the same), so a single-attribute fixture measures the absence of the companion rather than the converter. Established by measurement, not assumption: plan 41's paired probe carried View.highlighted as its one PROVISIONAL entry, and giving it `highlightBackground` made the ios/C0 and android/C0 findings disappear — the converters were correct and the fixture was missing the other half. This entry retires that PROVISIONAL: the pairing is now derived from the ledger like every other family (2026-08-05, plan 49-E, on plan 41's evidence). MEASURED 2026-08-05 (same run): web is active — A routed the highlight background through an inline style instead of a losing Tailwind class — while ios and android are still inert, so the verdict is recorded per platform. NOTE for anyone tidying this block: `common/highlightBackground__static` carries no control in the generated fixture set, so it is never compared and its verdict here is not checked against anything. It must NOT be deleted regardless — companions.py derives the pairing from this topic's `observable` KEYS, and a topic with fewer than two members stops producing companions at all. Same permanent-unverifiable note as networkImage's loadingImage applies to `common/highlightBackground__static`: rules.py scopes it to the paired form, so the bare fixture has no control and the entry can never be checked at the render stage. It stays because companions.py derives the View.highlighted pairing from these keys — deleting it would stop the pairing, not just the check. MEASURED 2026-08-06 on run 31068716186: android is now active (152,960 px) — the highlighted+highlightBackground pair landed on android; ios remains inert.",
25
26
  "observable": {
26
- "View/highlighted__true": {
27
- "ios": "inert",
28
- "android": "active",
29
- "web": "active"
30
- },
27
+ "View/highlighted__true": "uniformly-active",
31
28
  "common/highlightBackground__static": "uniformly-inert"
32
- }
29
+ },
30
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
33
31
  },
34
32
  "image": {
35
33
  "defaultContentMode": "fit",
@@ -70,11 +68,9 @@
70
68
  },
71
69
  "NetworkImage/hint__static": "uniformly-inert",
72
70
  "NetworkImage/errorImage__static": "uniformly-active",
73
- "NetworkImage/loadingImage__static": {
74
- "ios": "inert",
75
- "android": "active"
76
- }
77
- }
71
+ "NetworkImage/loadingImage__static": "uniformly-active"
72
+ },
73
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
78
74
  },
79
75
  "margins": {
80
76
  "offsetModel": "ZStack/overlay child margins are full-margin offsets; an explicit center declaration disables that axis margin; margins arrays and start/endMargin apply as padding",
@@ -86,7 +82,11 @@
86
82
  "ruling": "2026-08-03 campaign adjudication: the default content gravity is top|start on every container (CollectionStack pins Top; SafeAreaView spacer layouts anchor start). A platform centering by default is the deviant. No activeness observable — default-vs-default renders identically; the geometry probe closes it mechanically. 2026-08-07 (plan 51-E), Wave 2 carry-over: what happens to the axis a single-value gravity does NOT name. Ruled from the declaration alone, as the semantics rule requires, not from any platform's behaviour. common.gravity declares an enum of SINGLE-AXIS values (top, bottom, left, right, centerVertical, centerHorizontal) alongside an array type whose whole purpose is naming both axes at once. If a single-axis value left the other axis undefined, then `gravity: \"left\"` would have no defined rendering at all, and a declaration cannot leave its own enum values meaningless — so each axis resolves independently, and the axis that is not named takes the default already ruled above (top | start). It does NOT inherit the parent's gravity and it is not \"unset\". CONSEQUENCE, stated because it looks like a bug when measured: in LTR, `gravity: \"left\"` and `gravity: \"top\"` both resolve to (start, top) and are therefore observationally IDENTICAL. The Wave 2 note that ios draws `left` and `top` the same picture is the contract being honoured, not a defect; a fixture that expects those two to differ is testing something the SSoT never promised. Whether a given fixture can see the horizontal axis at all is a separate, real question — a child that fills the container's width makes it unobservable (D's lane). 2026-08-07 (plan 51-E), B's follow-up measurement: sjui emits `alignment: .topLeading` for every NAMED form (`left|top`, `left`, `top`) but drops the `alignment:` argument entirely when gravity is omitted, so SwiftUI falls to `.center` (51-B-progress). That is the deviation this ruling already named — \"a platform centering by default is the deviant\" — and the argument is the same one that settled the unspecified axis: if a partially specified gravity fills the unnamed axis with the default, then an omitted gravity fills BOTH axes with it. Any other reading makes omitting gravity mean something different from naming the default explicitly, which no declaration says. So the omitted form must also emit topLeading. NOTE FOR ROLLOUT, not for the canon: every View in every project that does not declare gravity goes through this line, so the fix wants a 3PF measurement and a fixture that can actually see it (a fixed-size container whose child does not stretch — with a stretching child the Spacer construction is identical in all four forms and the difference is unobservable). Sequencing is the orchestrator's; the canon is not in doubt.",
87
83
  "observable": {},
88
84
  "unspecifiedAxis": "falls to the container default for that axis (top vertically, start horizontally) — never \"unset\", never inherited from the parent",
89
- "omittedEntirely": "same as the default: top | start. Omitting gravity must render identically to naming the default explicitly"
85
+ "omittedEntirely": "same as the default: top | start. Omitting gravity must render identically to naming the default explicitly",
86
+ "leafOwnFrameChannel": {
87
+ "default": "center",
88
+ "ruling": "2026-08-08 adjudication (gravity leaf-scope hotfix, v1.5.2/10.15.1): the container default above scopes to CONTAINERS — nodes whose content the framework itself lays out. A childless node's own-frame content (a fit image's bitmap inside its declared box, a custom component's internals, a text block smaller than its fixed box) is a SEPARATE channel whose default is Center: Compose's Image/extension alignment default, which kjui never overrides, and the position the android face measurably draws a shrunken autoShrink line in its fixed box. ios converged in SwiftJsonUI 245d059+4d41bf9 (codegen gravity_to_frame_alignment leaf scope; dynamic fixed-frame text drop). A DECLARED gravity or textAlign still applies to any node — this entry names only the omitted-spelling default. Implementations were aligned BEFORE this text landed; the entry records the ruling the code already obeys."
89
+ }
90
90
  },
91
91
  "shadow": {
92
92
  "stringPipe": "five-field strict",
@@ -104,16 +104,8 @@
104
104
  "demarcation": "control chrome may differ per platform; the DIFFERENCE ITSELF is contract-pinned",
105
105
  "ruling": "2026-08-03 campaign adjudication: platform-owned control chrome is a legitimate difference, but only in its adjudicated SHAPE — the observable pins each accepted divergence as an expected-difference pattern, so both disappearing and mutating fail the gate. Members: Blur effectStyle (android material variants differ visibly, ios materials render alike on flat surfaces per the 31 saturation analysis, web CSS blur is single-variant), TextField.borderStyle (UIKit field chrome — each platform draws its own; web renders none for the UIKit-only bezel variants), TabView.showLabels WAS a member on the strength of a `__true` measurement — the DEFAULT value — and left the topic on 2026-08-05 when run 30990560599 measured both values on all three: `true` is inert everywhere (it is the default, so declaring it draws what omitting it draws) and `false` is active everywhere (ios 1491 px, android 8427, web 3019), with the two values distinguishable on every platform. There is no ios-only divergence to pin, so the entry moves to `__false` as a plain uniformly-active fact and the attribute is no longer a skin idiom. The original reading came from comparing the default value against its control, which cannot show what a non-default value does. The tabview glyph-source family is the codegen-side counterpart, pinned permanently in codegen_parity.json (2026-08-03 user ruling).",
106
106
  "observable": {
107
- "Blur/effectStyle__extralight": {
108
- "ios": "inert",
109
- "android": "active",
110
- "web": "inert"
111
- },
112
- "Blur/effectStyle__light": {
113
- "ios": "inert",
114
- "android": "active",
115
- "web": "inert"
116
- },
107
+ "Blur/effectStyle__extralight": "uniformly-active",
108
+ "Blur/effectStyle__light": "uniformly-active",
117
109
  "TextField/borderStyle__bezel": {
118
110
  "ios": "inert",
119
111
  "android": "active",
@@ -145,7 +137,8 @@
145
137
  "web": "inert"
146
138
  },
147
139
  "TabView/showLabels__false": "uniformly-active"
148
- }
140
+ },
141
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
149
142
  },
150
143
  "progressValue": {
151
144
  "canonical": "progress",
@@ -179,11 +172,7 @@
179
172
  "spacingWins": "an explicit spacing pins the gap; distribution still governs size",
180
173
  "ruling": "2026-08-05 (plan 49-E), raised by F/G and measured across all three toolchains. The vocabulary is UIStackView's, and its four values split into TWO KINDS that the current implementations conflate: `fill` and `fillEqually` distribute SIZE among the children, while `equalSpacing` and `equalCentering` distribute the FREE SPACE between them. fill = children grow to consume the axis, leaving no free space (proportional to their content size); fillEqually = every child gets the SAME size regardless of content; equalSpacing = equal gaps between adjacent children, with no leading or trailing gap; equalCentering = equal centre-to-centre distances. CONSEQUENCE, and this is the part every platform gets wrong today: the two size values are NOT an arrangement. They are `Modifier.weight(1f)` on each child in Compose, `.frame(maxWidth: .infinity)` (or equal weights) in SwiftUI, and `flex-1` in CSS — not Arrangement.* and not justify-content. Mapping `fill` to SpaceBetween/justify-between is precisely backwards: fill means there is no free space left to distribute. MEASURED STATE 2026-08-05 — all three disagree with the canon AND with each other, and each collapses a DIFFERENT pair of values into one output, so no fixture comparing two declared values can tell them apart: android (kjui container_component.rb:129-140) sends fillEqually and equalCentering both to Arrangement.SpaceEvenly; web (rjui view_converter.rb:184-195) sends the same pair both to justify-evenly; ios (sjui view_converter.rb:217,367,383) synthesises identical Spacer sets for equalSpacing and equalCentering, and gives fillEqually between-only spacers, which is space-between rather than equal sizes. `fill` emits nothing on ios (correct by accident) and a space-between arrangement on the other two (wrong). WHY NO GATE CAUGHT IT: --inert-complete asks whether a fixture differs from its CONTROL. Two declared values that both differ from the control but are identical to EACH OTHER pass that check, and android parity passes too because codegen and dynamic collapse them the same way. A value-vs-value comparison is the missing instrument — this is the 'both wrong in the same direction reads as green' shape. SPACING PRECEDENCE: `spacing` and `distribution` are not mutually exclusive and the current Compose behaviour (spacing != null wins outright, distribution never read) is wrong for the size values. An explicit `spacing` pins the GAP, so it overrides the gap that equalSpacing/equalCentering would compute; it says nothing about size, so fill/fillEqually still apply underneath it. Same shape as the size topic's explicit > bounds > fill ordering: the more specific declaration wins the axis it speaks about, and only that axis. No observable entries yet: the fixtures that would prove this need to compare two declared VALUES against each other rather than each against the control, which the current generator does not emit. That instrument is the follow-up this ruling asks for. PER-VALUE MAPPING added 2026-08-05 after A implemented the ruling on web (jsonui-cli 651403e) — recorded so B and C match that choice instead of re-deriving it, which is how the three drifted apart in the first place. The `perValueMapping` block above is the table; the two decisions worth calling out are that the SIZE values are carried to the CHILDREN (a per-child flex/weight instruction, not a container arrangement) and that equalCentering maps to SpaceAround / justify-around rather than SpaceEvenly — SpaceEvenly equalises the GAPS, which is equalSpacing's job, and collapsing the two is precisely the conflation this ruling exists to undo. web landed first and is the reference implementation; ios (sjui view_converter.rb, still synthesising Spacers for both gap values) and android (kjui container_component.rb:129, still mapping four values onto two Arrangements) are outstanding. CHILD SIZE PRECEDENCE, ruled 2026-08-05 after G measured android and found `fill` leaving 60dp unconsumed: an EXPLICIT width/height on the child wins over the size half of distribution, so `fill` and `fillEqually` do not override a declared child size. This is not a new principle — the `size` topic's 2026-08-03 campaign adjudication already ordered explicit > bounds > fill, and distribution's size half IS a fill instruction on the child's axis, so it sits at the bottom of that order like any other. It is also what `spacingWins` says one line up: the more specific declaration wins the axis it speaks about. CONSEQUENCE for the measurement, and the reason this had to be ruled rather than implemented: a fixture whose children declare `width: 40` cannot demonstrate `fill` at all — the 60dp G measured is the correct outcome, not a defect. The fixture has to omit the child dimension on the distribution axis for the attribute to have anything to do. That makes it the fourth case of a fixture shape hiding the attribute it is supposed to measure (after SafeAreaView's matchParent, alignTop/LeftOfView off-screen, and SelectBox with nothing to decorate). android's current implementation is correct as it stands; no three-platform change follows from this. FIRST VALUE-VS-VALUE MEASUREMENT 2026-08-05, on run 30972192150's artifacts once D's fixtures (0035321) and H's --value-discrimination gate (f54d3d5) existed, with control_diff.ignore_bands applied. It confirms the collapse this ruling was written from, platform by platform and by measurement rather than by reading converters: ios draws equalSpacing and equalCentering pixel-identically, android and web draw fillEqually and equalCentering pixel-identically. Three platforms, two different collapsed pairs, nobody agreeing — recorded in `collapsedPairs` so the fix can be checked off per platform. `fill` is ALSO inert against its control on ios while active on android and web, which is not a contradiction of explicitChildSizeWins: the fixture's children carry an explicit width, so no platform should be moving them, and the two that do are moving the TRACK rather than the child. The entry is per-platform for that reason and must not be flattened to uniformly-inert until the fixture drops the child width — at which point fill becomes measurable and this entry gets re-measured, not edited. RE-MEASURED on run 30990560599 after D removed the child width from the size-half fixtures (661bfba). web now distinguishes all four values — A's implementation is the first to satisfy the ruling end to end, and `collapsedPairs` loses its web row. ios still collapses equalSpacing with equalCentering and android now collapses fill with fillEqually (it had been fillEqually with equalCentering): the pair moved because the fixture changed, so android's remaining defect is in the SIZE half, where fill and fillEqually must differ — fill grows children from their content, fillEqually makes them equal regardless of it. `fill` stays inert on ios: with the child width gone the fixture can finally demonstrate it, and ios does not. Run 31068716186 (2026-08-06) measured android fill != fillEqually with both active against the control — C's 27b9b13 (codegen) and G's c7d2dfb (DistributionFillRow) closed the SIZE-half collapse, so the android collapsedPairs row is deleted BY MEASUREMENT, not by edit. The ios pair (equalSpacing == equalCentering) persists: run-5 geometry shows identical child positions and a 32 px gap for both values. Run 31080217407 (2026-08-06) measured ios distinguishing equalSpacing from equalCentering — B (b57e327/3dc3795) and F (e8f99c7) implemented the pinned iosGapConstruction, and the geometry is exactly the canon's: equalCentering shifts the first child right by the half-gap edge spacer (box_a 154 -> 168 px) while every child keeps its declared size (118 px). collapsedPairs is now EMPTY — all three platforms distinguish all four values, which is the state this ruling was written to reach. The empty map stays as the healthy-state marker, same convention as companions.py's emptied PROVISIONAL.",
181
174
  "observable": {
182
- "View/distribution__fill": {
183
- "ios": "inert",
184
- "android": "active",
185
- "web": "active"
186
- },
175
+ "View/distribution__fill": "uniformly-active",
187
176
  "View/distribution__fillequally": "uniformly-active",
188
177
  "View/distribution__equalspacing": "uniformly-active",
189
178
  "View/distribution__equalcentering": "uniformly-active"
@@ -220,7 +209,8 @@
220
209
  "note": "Pinned 2026-08-06 (E's proposal, orchestrator-approved: implement this release, B codegen + F dynamic) so the two ios paths emit ONE counting instead of deriving Spacer arithmetic twice — two paths growing children separately is exactly what collapsed distribution's SIZE half before c7d2dfb. Spacer counting suffices because HStack/VStack split free space equally among unconstrained Spacers: the 1:2 end:between count ratio IS space-around's half-gap. No custom Layout, no measurement pass. Acceptance is already wired: when this lands, run 6 must delete collapsedPairs' ios row and turn the two value_discrimination receiving rows stale. Regression-test discipline from F's landing: a partial reversion (between 2->1 only) builds an equal-three-spacer structure the first sample points slipped past — pin BOTH sides of the computed boundary (55 in the 300pt fixture) so partial and full reversion both fail."
221
210
  },
222
211
  "explicitChildSizeWins": "an explicit child width/height beats distribution's size half — size topic's explicit > bounds > fill",
223
- "collapsedPairs": {}
212
+ "collapsedPairs": {},
213
+ "observableNote": "MEASURED 2026-08-08 (run 31243724782 + local-env): uniformly active — the divergence this entry recorded was fixed in-wave (ios Blur bridge / ios highlighted swap / ios distribution fill / ios loadingImage via pending.invalid / border emit on all faces)."
224
214
  },
225
215
  "trackColors": {
226
216
  "sliderTrack": "trackTintColor is the UNFILLED track; progressTintColor is the filled one",
@@ -2,52 +2,6 @@
2
2
  "schemaVersion": 1,
3
3
  "_comment": "Declared attributes no platform converter reads. Hand-maintained: close a gap by implementing it (then delete the entry) or by narrowing platform/mode in attribute_definitions.json. reason is one of: dynamic-key, legacy, platform-na, runtime-only, unimplemented.",
4
4
  "entries": [
5
- {
6
- "component": "Collection",
7
- "attribute": "cellHeight",
8
- "platforms": [
9
- "web"
10
- ],
11
- "reason": "unimplemented"
12
- },
13
- {
14
- "component": "Collection",
15
- "attribute": "cellWidth",
16
- "platforms": [
17
- "android",
18
- "web"
19
- ],
20
- "reason": "unimplemented"
21
- },
22
- {
23
- "component": "Collection",
24
- "attribute": "hideSeparator",
25
- "platforms": [
26
- "ios",
27
- "android",
28
- "web"
29
- ],
30
- "reason": "runtime-only",
31
- "note": "ios dynamic implements it (SwiftJsonUI CollectionConverter.swift:662 / TableConverter.swift:46,49); no codegen face reads it. Not \"unimplemented\" — this scan only walks the Ruby codegen. attribute_semantics.json -> collectionSeparators (plan 51-E)."
32
- },
33
- {
34
- "component": "Collection",
35
- "attribute": "listStyle",
36
- "platforms": [
37
- "android",
38
- "web"
39
- ],
40
- "reason": "unimplemented",
41
- "note": "Was runtime-only while no codegen face read it; B implemented the ios codegen face against the enum (collection_converter.rb LIST_STYLES), so android and web are now a genuine implementation gap. attribute_semantics.json -> collectionSeparators (plan 51-E)."
42
- },
43
- {
44
- "component": "ScrollView",
45
- "attribute": "defaultScrollAnchor",
46
- "platforms": [
47
- "android"
48
- ],
49
- "reason": "unimplemented"
50
- },
51
5
  {
52
6
  "component": "ScrollView",
53
7
  "attribute": "minZoom",
@@ -65,14 +19,6 @@
65
19
  ],
66
20
  "reason": "unimplemented"
67
21
  },
68
- {
69
- "component": "TextField",
70
- "attribute": "hintLineHeightMultiple",
71
- "platforms": [
72
- "android"
73
- ],
74
- "reason": "unimplemented"
75
- },
76
22
  {
77
23
  "component": "common",
78
24
  "attribute": "binding",
@@ -0,0 +1,70 @@
1
+ {
2
+ "version": 1,
3
+ "title": "Platform-scoped layout semantics",
4
+ "purpose": "How to make a node render differently (or not at all) on one platform: the node-level 'platform' directive, the layout-root 'platforms' whitelist, and how they differ from 'responsive'. Canonical implementation: jui_tools/jui_cli/core/platform_resolver.py — this file restates it for lookup and must follow it, never lead it.",
5
+ "keywords": [
6
+ "platform", "platforms", "ios only", "android only", "web only",
7
+ "per-platform", "override", "platform-specific", "platform limited",
8
+ "プラットフォーム", "限定", "出し分け", "iosだけ", "androidだけ", "webだけ",
9
+ "プラットフォーム別", "機種別"
10
+ ],
11
+ "nodeDirective": {
12
+ "attribute": "platform",
13
+ "consumedAt": "distribution (jui build). The key never appears in distributed per-platform output; platform-specific emit is produced from it.",
14
+ "objectForm": {
15
+ "summary": "Per-platform attribute overrides, merged into the node for the matching platform only. Override wins over the base value; merge is per top-level key (the override value replaces the base value for that key wholesale — it is not deep-merged into it).",
16
+ "example": {
17
+ "prompt": "Change fontColor only on iOS",
18
+ "layout": {
19
+ "type": "Label",
20
+ "text": "@{title}",
21
+ "fontColor": "#333333",
22
+ "platform": { "ios": { "fontColor": "#FFD700" } }
23
+ },
24
+ "resolved": {
25
+ "ios": { "type": "Label", "text": "@{title}", "fontColor": "#FFD700" },
26
+ "android": { "type": "Label", "text": "@{title}", "fontColor": "#333333" },
27
+ "web": { "type": "Label", "text": "@{title}", "fontColor": "#333333" }
28
+ }
29
+ },
30
+ "multiPlatformExample": {
31
+ "prompt": "Different heights everywhere, plus a web-only extra attribute",
32
+ "layout": {
33
+ "type": "View",
34
+ "height": 200,
35
+ "platform": {
36
+ "ios": { "height": 220 },
37
+ "android": { "height": 180 },
38
+ "web": { "height": "100vh", "maxWidth": 1200 }
39
+ }
40
+ }
41
+ },
42
+ "detection": "A dict counts as an override map ONLY when every key is one of 'ios' / 'android' / 'web'. Any other key (e.g. a stray 'class') makes the whole value a non-override, so nothing merges — keep override maps pure.",
43
+ "nesting": "Override values are themselves resolved recursively, so an override may contain child nodes with their own 'platform' directives."
44
+ },
45
+ "stringForm": {
46
+ "summary": "Node filter: the node (and its whole subtree) is DROPPED on platforms that do not match. Comma-separated tokens allowed.",
47
+ "example": {
48
+ "prompt": "Render this node on Android only",
49
+ "layout": { "type": "View", "platform": "android", "child": [] }
50
+ },
51
+ "tokens": {
52
+ "ios": ["ios", "swift", "swiftui", "uikit"],
53
+ "android": ["android", "kotlin", "java", "compose", "xml"],
54
+ "web": ["web", "typescript", "javascript", "react"]
55
+ }
56
+ }
57
+ },
58
+ "layoutRootPlatforms": {
59
+ "attribute": "platforms",
60
+ "summary": "Layout-root array whitelisting the distribution targets of the ENTIRE file (e.g. [\"ios\", \"android\"]). Distinct from the node-level 'platform' directive: 'platforms' picks which platforms get the file at all; 'platform' shapes a single node per platform."
61
+ },
62
+ "vsResponsive": {
63
+ "summary": "'responsive' is resolved at RUNTIME by each platform's framework (window size class / orientation); 'platform' is resolved at BUILD time and is invisible at runtime. Use 'platform' for per-OS differences, 'responsive' for per-form-factor differences. The resolver leaves 'responsive' untouched."
64
+ },
65
+ "authoringGuidance": {
66
+ "spec": "Screen specs stay platform-agnostic; when a node intentionally differs per platform, note the difference in the spec text of the section that owns the node (do not fork the spec per platform). Large screens split into sub-specs list them in the parent's 'subSpecs' array — the sub-spec that owns the node carries the note.",
67
+ "precedence": "Per-node 'platform' override > inline base attribute > 'style'-provided value. Distribution bakes the override into the node as a plain inline attribute; style merge (applied later, at codegen or dynamic runtime) always yields to inline attributes.",
68
+ "dynamicFace": "Distribution has already resolved 'platform' by the time a layout reaches a device, so the dynamic face never sees the key either — declaring it costs nothing at runtime on any face."
69
+ }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
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": {
@@ -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.5.0";
47
+ const JSONUI_CLI_TAG = "v1.6.0";
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}`;
@@ -33,6 +33,13 @@ export const FILES = [
33
33
  remote: "shared/core/attribute_semantics.json",
34
34
  local: "attribute_semantics.json",
35
35
  },
36
+ // Canonical platform-scoping semantics (node 'platform' directive, root
37
+ // 'platforms', vs 'responsive') — backs get_platform_rules and the
38
+ // search-side topic-guide index.
39
+ {
40
+ remote: "shared/core/platform_semantics.json",
41
+ local: "platform_semantics.json",
42
+ },
36
43
  // Canonical binding-resolution semantics. This was bundled in data/ but
37
44
  // missing from FILES, so the snapshot only ever refreshed by hand and
38
45
  // silently went stale — the same trap the screen-identity asset would
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { register as registerGetBindingRules } from "./tools/spec/get_binding_ru
14
14
  import { register as registerGetScreenIdentity } from "./tools/spec/get_screen_identity.js";
15
15
  import { register as registerGetPlatformMapping } from "./tools/spec/get_platform_mapping.js";
16
16
  import { register as registerGetDataSource } from "./tools/spec/get_data_source.js";
17
+ import { register as registerGetPlatformRules } from "./tools/spec/get_platform_rules.js";
17
18
 
18
19
  // --- Group B: Project Context ---
19
20
  import { register as registerGetProjectConfig } from "./tools/context/get_project_config.js";
@@ -22,6 +23,7 @@ import { register as registerListComponentSpecs } from "./tools/context/list_com
22
23
  import { register as registerListLayouts } from "./tools/context/list_layouts.js";
23
24
  import { register as registerReadSpecFile } from "./tools/context/read_spec_file.js";
24
25
  import { register as registerReadLayoutFile } from "./tools/context/read_layout_file.js";
26
+ import { register as registerSearchSpecs } from "./tools/context/search_specs.js";
25
27
 
26
28
  // --- Group C: jui CLI ---
27
29
  import { register as registerJuiInit } from "./tools/jui/jui_init.js";
@@ -89,7 +91,7 @@ log(
89
91
  // Initialize server config for Groups B/C/D
90
92
  const config = new ServerConfig();
91
93
 
92
- // Register Group A: Component Spec Lookup (7 tools)
94
+ // Register Group A: Component Spec Lookup (9 tools)
93
95
  registerLookupComponent(server, loader);
94
96
  registerLookupAttribute(server, loader);
95
97
  registerSearchComponents(server, loader);
@@ -98,14 +100,16 @@ registerGetBindingRules(server, loader);
98
100
  registerGetScreenIdentity(server, loader);
99
101
  registerGetPlatformMapping(server, loader);
100
102
  registerGetDataSource(server, loader);
103
+ registerGetPlatformRules(server, loader);
101
104
 
102
- // Register Group B: Project Context (6 tools)
105
+ // Register Group B: Project Context (7 tools)
103
106
  registerGetProjectConfig(server, config);
104
107
  registerListScreenSpecs(server, config);
105
108
  registerListComponentSpecs(server, config);
106
109
  registerListLayouts(server, config);
107
110
  registerReadSpecFile(server, config);
108
111
  registerReadLayoutFile(server, config);
112
+ registerSearchSpecs(server, config);
109
113
 
110
114
  // Register Group C: jui CLI (8 tools)
111
115
  registerJuiInit(server, config);
@@ -61,6 +61,10 @@ export interface DataSourceInfo {
61
61
  /// contract (cross-effect rulings, plan 33). Optional: absent on older
62
62
  /// jsonui-cli checkouts.
63
63
  attributeSemantics: FileInfo | null;
64
+ /// shared/core/platform_semantics.json — platform-scoped layout semantics
65
+ /// (the node-level 'platform' directive, root 'platforms', vs
66
+ /// 'responsive'). Optional: absent on older jsonui-cli checkouts.
67
+ platformSemantics: FileInfo | null;
64
68
  /// conformance/coverage.json — declared-but-unimplemented (component,
65
69
  /// attribute, platform) pairs with their recorded reasons. Optional: absent
66
70
  /// on older jsonui-cli checkouts.
@@ -102,6 +106,7 @@ export class SpecLoader {
102
106
  private commonAttributesRaw: Record<string, any> = {};
103
107
  private bindingSemantics: Record<string, any> | null = null;
104
108
  private attributeSemantics: Record<string, any> | null = null;
109
+ private platformSemantics: Record<string, any> | null = null;
105
110
  /// `${component}.${attribute}` -> [{platform, reason, note}] from the
106
111
  /// coverage ledger. The ledger tracks pairs that ARE declared (platform
107
112
  /// field includes them) but that the platform's codegen does not consume —
@@ -243,9 +248,56 @@ export class SpecLoader {
243
248
  }
244
249
  }
245
250
 
251
+ // Topic-guide index: task vocabulary ("iOSだけ色を変える", "ios only",
252
+ // "platform override") does not contain any component or attribute
253
+ // name, so the loops above can't surface the mechanism. Each guide
254
+ // declares its own keywords (en + ja) in its semantics file; a hit
255
+ // pins a pointer to the dedicated tool at the top of the results.
256
+ for (const guide of this.topicGuides()) {
257
+ const hit = guide.keywords.some(
258
+ (kw) => q.includes(kw) || (q.length >= 3 && kw.includes(q))
259
+ );
260
+ if (hit) {
261
+ results.push({
262
+ guide: guide.topic,
263
+ score: 100,
264
+ matches: [guide.summary],
265
+ nextTool: guide.tool,
266
+ });
267
+ }
268
+ }
269
+
246
270
  return results.sort((a, b) => b.score - a.score);
247
271
  }
248
272
 
273
+ /** Keyword-indexed topical guides, each backed by a dedicated MCP tool. */
274
+ private topicGuides(): Array<{
275
+ topic: string;
276
+ tool: string;
277
+ summary: string;
278
+ keywords: string[];
279
+ }> {
280
+ const guides: Array<{
281
+ topic: string;
282
+ tool: string;
283
+ summary: string;
284
+ keywords: string[];
285
+ }> = [];
286
+ if (this.platformSemantics) {
287
+ const kws = Array.isArray(this.platformSemantics.keywords)
288
+ ? (this.platformSemantics.keywords as string[])
289
+ : [];
290
+ guides.push({
291
+ topic: "platform_rules",
292
+ tool: "get_platform_rules",
293
+ summary:
294
+ "Per-platform styling/visibility (e.g. change fontColor on iOS only): node-level 'platform' directive (object form = per-platform overrides, string form = platform filter), root 'platforms' whitelist, vs 'responsive'. Call get_platform_rules for the full semantics with examples.",
295
+ keywords: kws.map((k) => k.toLowerCase()),
296
+ });
297
+ }
298
+ return guides;
299
+ }
300
+
249
301
  getModifierOrder(platform?: string): any {
250
302
  if (platform) return (MODIFIER_ORDER as any)[platform] || null;
251
303
  return MODIFIER_ORDER;
@@ -275,6 +327,7 @@ export class SpecLoader {
275
327
  this.dataSource.screenIdentity,
276
328
  this.dataSource.bindingSemantics,
277
329
  this.dataSource.attributeSemantics,
330
+ this.dataSource.platformSemantics,
278
331
  this.dataSource.coverage,
279
332
  ].filter((f): f is FileInfo => f != null);
280
333
 
@@ -309,6 +362,15 @@ export class SpecLoader {
309
362
  };
310
363
  }
311
364
 
365
+ getPlatformRules(): any {
366
+ // Canonical platform-scoping semantics (SSoT:
367
+ // shared/core/platform_semantics.json — restates
368
+ // jui_tools/jui_cli/core/platform_resolver.py). Null on jsonui-cli
369
+ // checkouts that predate the file; the tool reports that instead of
370
+ // inventing rules.
371
+ return this.platformSemantics;
372
+ }
373
+
312
374
  getPlatformMapping(category?: string): any {
313
375
  if (category) return (PLATFORM_MAPPING as any)[category] || null;
314
376
  return PLATFORM_MAPPING;
@@ -376,6 +438,23 @@ export class SpecLoader {
376
438
  this.attributeSemantics = null;
377
439
  }
378
440
 
441
+ // Platform-scoped layout semantics (node 'platform' directive, root
442
+ // 'platforms', vs 'responsive'). Optional for the same backward-compat
443
+ // reason.
444
+ let platformSemanticsResolution: FileInfo | null = null;
445
+ try {
446
+ const platSemResolution = this.resolveFile(
447
+ "shared/core/platform_semantics.json",
448
+ "data/platform_semantics.json"
449
+ );
450
+ this.platformSemantics = JSON.parse(
451
+ readFileSync(platSemResolution.path, "utf-8")
452
+ ) as Record<string, any>;
453
+ platformSemanticsResolution = platSemResolution;
454
+ } catch {
455
+ this.platformSemantics = null;
456
+ }
457
+
379
458
  // Canonical screen identity (screen-identity track). Optional so older
380
459
  // jsonui-cli checkouts without the file still load.
381
460
  try {
@@ -462,6 +541,7 @@ export class SpecLoader {
462
541
  screenIdentity: screenIdentityResolution,
463
542
  bindingSemantics: bindingResolution,
464
543
  attributeSemantics: attributeSemanticsResolution,
544
+ platformSemantics: platformSemanticsResolution,
465
545
  coverage: coverageResolution,
466
546
  componentCount,
467
547
  commonAttributeCount: Object.keys(this.commonAttributesRaw).filter(
@@ -22,20 +22,38 @@ export function register(server: McpServer, config: ServerConfig) {
22
22
  }
23
23
 
24
24
  const files = readdirSync(specDir).filter((f) => f.endsWith(".spec.json"));
25
- const specs = files.map((file) => {
25
+ const specs: any[] = [];
26
+ for (const file of files) {
26
27
  try {
27
28
  const data = JSON.parse(readFileSync(join(specDir, file), "utf-8"));
28
- return {
29
+ specs.push({
29
30
  file,
30
31
  name: data.metadata?.name || file.replace(".spec.json", ""),
31
32
  displayName: data.metadata?.displayName || "",
32
33
  type: data.type || "screen_spec",
33
34
  layoutFile: data.metadata?.layoutFile || null,
34
- };
35
+ });
36
+ // A parent spec splits one screen across sub-spec files (its
37
+ // 'subSpecs' array). Surface them inline so a session can see
38
+ // the split and read_spec_file the right part without first
39
+ // opening the parent.
40
+ if (Array.isArray(data.subSpecs)) {
41
+ for (const sub of data.subSpecs) {
42
+ if (!sub?.file) continue;
43
+ specs.push({
44
+ file: sub.file,
45
+ name: sub.name || sub.file,
46
+ displayName: sub.description || "",
47
+ type: "screen_sub_spec",
48
+ parent: file,
49
+ layoutFile: data.metadata?.layoutFile || null,
50
+ });
51
+ }
52
+ }
35
53
  } catch {
36
- return { file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null };
54
+ specs.push({ file, name: file.replace(".spec.json", ""), displayName: "", type: "unknown", layoutFile: null });
37
55
  }
38
- });
56
+ }
39
57
 
40
58
  return { content: [{ type: "text", text: JSON.stringify(specs, null, 2) }] };
41
59
  } catch (e: any) {
@@ -0,0 +1,124 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
4
+ import { join, relative } from "path";
5
+ import { ServerConfig } from "../../config.js";
6
+
7
+ const MAX_RESULTS = 30;
8
+ const SNIPPET_LEN = 200;
9
+
10
+ /** Recursively collect *.spec.json / *.component.json under dir. */
11
+ function collectSpecFiles(dir: string, out: string[] = []): string[] {
12
+ for (const entry of readdirSync(dir)) {
13
+ const p = join(dir, entry);
14
+ if (statSync(p).isDirectory()) {
15
+ collectSpecFiles(p, out);
16
+ } else if (entry.endsWith(".spec.json") || entry.endsWith(".component.json")) {
17
+ out.push(p);
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /**
24
+ * Walk a parsed spec and report the JSON paths whose keys or string values
25
+ * contain the query. One entry per path, with a short snippet.
26
+ */
27
+ function findMatches(
28
+ node: any,
29
+ q: string,
30
+ path: string,
31
+ out: { path: string; snippet: string }[]
32
+ ) {
33
+ if (out.length >= MAX_RESULTS) return;
34
+ if (typeof node === "string") {
35
+ if (node.toLowerCase().includes(q)) {
36
+ out.push({ path, snippet: node.slice(0, SNIPPET_LEN) });
37
+ }
38
+ return;
39
+ }
40
+ if (Array.isArray(node)) {
41
+ node.forEach((v, i) => findMatches(v, q, `${path}[${i}]`, out));
42
+ return;
43
+ }
44
+ if (node && typeof node === "object") {
45
+ for (const [k, v] of Object.entries(node)) {
46
+ const childPath = path ? `${path}.${k}` : k;
47
+ if (k.toLowerCase().includes(q)) {
48
+ const snippet =
49
+ typeof v === "string"
50
+ ? v.slice(0, SNIPPET_LEN)
51
+ : JSON.stringify(v)?.slice(0, SNIPPET_LEN) ?? "";
52
+ out.push({ path: childPath, snippet });
53
+ if (out.length >= MAX_RESULTS) return;
54
+ }
55
+ findMatches(v, q, childPath, out);
56
+ if (out.length >= MAX_RESULTS) return;
57
+ }
58
+ }
59
+ }
60
+
61
+ export function register(server: McpServer, config: ServerConfig) {
62
+ server.tool(
63
+ "search_specs",
64
+ "Keyword-search across ALL project spec files (screen specs, sub-specs in subdirectories, component specs). Returns file + JSON path + snippet per match — the fast way to find which spec (or which sub-spec of a split screen) covers a topic",
65
+ {
66
+ query: z.string().describe("Search keyword (matched case-insensitively against keys and string values)"),
67
+ project_dir: z.string().optional().describe("Project directory (overrides JUI_PROJECT_DIR env)"),
68
+ },
69
+ async ({ query, project_dir }) => {
70
+ try {
71
+ const projectDir = config.resolveProjectDir(project_dir);
72
+ const projectConfig = config.readProjectConfig(projectDir);
73
+ const q = query.toLowerCase();
74
+ if (!q.trim()) {
75
+ return { content: [{ type: "text", text: "Empty query." }] };
76
+ }
77
+
78
+ const dirs = new Set<string>();
79
+ for (const field of ["spec_directory", "component_spec_directory"] as const) {
80
+ try {
81
+ const d = config.resolveDir(projectConfig, field, projectDir);
82
+ if (existsSync(d)) dirs.add(d);
83
+ } catch {
84
+ /* directory not configured */
85
+ }
86
+ }
87
+ if (dirs.size === 0) {
88
+ return { content: [{ type: "text", text: "No spec directories found." }] };
89
+ }
90
+
91
+ const results: any[] = [];
92
+ for (const dir of dirs) {
93
+ for (const filePath of collectSpecFiles(dir)) {
94
+ if (results.length >= MAX_RESULTS) break;
95
+ let matches: { path: string; snippet: string }[] = [];
96
+ try {
97
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
98
+ findMatches(data, q, "", matches);
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (matches.length > 0) {
103
+ results.push({
104
+ file: relative(dir, filePath),
105
+ matchCount: matches.length,
106
+ matches: matches.slice(0, 5),
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ if (results.length === 0) {
113
+ return { content: [{ type: "text", text: `No matches for '${query}'.` }] };
114
+ }
115
+ results.sort((a, b) => b.matchCount - a.matchCount);
116
+ return {
117
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
118
+ };
119
+ } catch (e: any) {
120
+ return { content: [{ type: "text", text: `Error: ${e.message}` }] };
121
+ }
122
+ }
123
+ );
124
+ }
@@ -0,0 +1,26 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SpecLoader } from "../../spec_loader.js";
3
+
4
+ export function register(server: McpServer, loader: SpecLoader) {
5
+ server.tool(
6
+ "get_platform_rules",
7
+ "Get platform-scoping rules for layouts: how to change an attribute on one platform only (node-level 'platform' object form, e.g. iOS-only fontColor), how to drop a node per platform (string form + language tokens), the layout-root 'platforms' whitelist, precedence, and how this differs from 'responsive'",
8
+ {},
9
+ async () => {
10
+ const result = loader.getPlatformRules();
11
+ if (!result) {
12
+ return {
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: "platform_semantics.json not found — the connected jsonui-cli checkout predates it. Fall back to lookup_attribute('platform') for the short definition.",
17
+ },
18
+ ],
19
+ };
20
+ }
21
+ return {
22
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
23
+ };
24
+ }
25
+ );
26
+ }
@@ -45,6 +45,7 @@ function reportedFiles(source: DataSourceInfo) {
45
45
  source.screenIdentity,
46
46
  source.bindingSemantics,
47
47
  source.attributeSemantics,
48
+ source.platformSemantics,
48
49
  source.coverage,
49
50
  ].filter((f): f is NonNullable<typeof f> => f != null);
50
51
  }
@@ -386,6 +386,61 @@ describe("SpecLoader spec model", () => {
386
386
  });
387
387
  });
388
388
 
389
+ describe("SpecLoader platform rules + topic-guide search", () => {
390
+ const SEMANTICS = {
391
+ version: 1,
392
+ title: "Platform-scoped layout semantics",
393
+ keywords: ["platform", "ios only", "iosだけ", "出し分け"],
394
+ nodeDirective: { attribute: "platform" },
395
+ };
396
+
397
+ function loaderWithSemantics(): SpecLoader {
398
+ const mcpRoot = makeTempDir("mcp-root");
399
+ makeBundledDataset(mcpRoot);
400
+ writeJson(join(mcpRoot, "data/platform_semantics.json"), SEMANTICS);
401
+ return new SpecLoader(mcpRoot);
402
+ }
403
+
404
+ it("getPlatformRules returns the semantics file verbatim", () => {
405
+ expect(loaderWithSemantics().getPlatformRules()).toEqual(SEMANTICS);
406
+ });
407
+
408
+ it("getPlatformRules is null (not invented) without the file", () => {
409
+ const mcpRoot = makeTempDir("mcp-root");
410
+ makeBundledDataset(mcpRoot);
411
+ expect(new SpecLoader(mcpRoot).getPlatformRules()).toBeNull();
412
+ });
413
+
414
+ it("task vocabulary surfaces the guide pointer at the top of search", () => {
415
+ // No component or attribute name in the query — only the guide can hit.
416
+ const results = loaderWithSemantics().searchComponents(
417
+ "iosだけフォントカラーを変えたい"
418
+ );
419
+ expect(results[0]).toMatchObject({
420
+ guide: "platform_rules",
421
+ nextTool: "get_platform_rules",
422
+ score: 100,
423
+ });
424
+ });
425
+
426
+ it("an english task query hits the guide too", () => {
427
+ const results = loaderWithSemantics().searchComponents("ios only color");
428
+ expect(results.some((r: any) => r.guide === "platform_rules")).toBe(true);
429
+ });
430
+
431
+ it("guide keywords do not fire on unrelated queries", () => {
432
+ const results = loaderWithSemantics().searchComponents("zebra");
433
+ expect(results.every((r: any) => r.guide == null)).toBe(true);
434
+ });
435
+
436
+ it("no guide entries without the semantics file", () => {
437
+ const mcpRoot = makeTempDir("mcp-root");
438
+ makeBundledDataset(mcpRoot);
439
+ const results = new SpecLoader(mcpRoot).searchComponents("ios only");
440
+ expect(results.every((r: any) => r.guide == null)).toBe(true);
441
+ });
442
+ });
443
+
389
444
  // ----- root helper ----------------------------------------------------------
390
445
 
391
446
  describe("mcpRootFromImportMetaUrl", () => {
@@ -13,6 +13,7 @@ import { register as registerListComponentSpecs } from "../src/tools/context/lis
13
13
  import { register as registerListLayouts } from "../src/tools/context/list_layouts.js";
14
14
  import { register as registerReadSpecFile } from "../src/tools/context/read_spec_file.js";
15
15
  import { register as registerReadLayoutFile } from "../src/tools/context/read_layout_file.js";
16
+ import { register as registerSearchSpecs } from "../src/tools/context/search_specs.js";
16
17
  import {
17
18
  cleanupTempDirs,
18
19
  createToolHarness,
@@ -38,8 +39,46 @@ beforeEach(() => {
38
39
  registerListLayouts(harness.server, config);
39
40
  registerReadSpecFile(harness.server, config);
40
41
  registerReadLayoutFile(harness.server, config);
42
+ registerSearchSpecs(harness.server, config);
41
43
  });
42
44
 
45
+ /** A screen split across a parent spec + sub-spec files in a subdirectory. */
46
+ function writeSplitSpecFixture(root: string) {
47
+ writeJson(join(root, "docs/screens/json/messages.spec.json"), {
48
+ type: "screen_parent_spec",
49
+ metadata: {
50
+ name: "Messages",
51
+ displayName: "Messages Screen",
52
+ layoutFile: "messages",
53
+ },
54
+ subSpecs: [
55
+ {
56
+ file: "messages/messages-core.spec.json",
57
+ name: "Messages - Core",
58
+ description: "Core structure and message list",
59
+ },
60
+ {
61
+ file: "messages/messages-composer.spec.json",
62
+ name: "Messages - Composer",
63
+ description: "Input area and attachments",
64
+ },
65
+ ],
66
+ });
67
+ writeJson(join(root, "docs/screens/json/messages/messages-core.spec.json"), {
68
+ type: "screen_spec",
69
+ metadata: { name: "Messages - Core" },
70
+ structure: { messageList: { paging: true, currentPage: "@{currentPage}" } },
71
+ });
72
+ writeJson(
73
+ join(root, "docs/screens/json/messages/messages-composer.spec.json"),
74
+ {
75
+ type: "screen_spec",
76
+ metadata: { name: "Messages - Composer" },
77
+ structure: { attachButton: { note: "opens the attachment sheet" } },
78
+ }
79
+ );
80
+ }
81
+
43
82
  afterEach(() => {
44
83
  delete process.env.JUI_PROJECT_DIR;
45
84
  cleanupTempDirs();
@@ -121,6 +160,81 @@ describe("list_screen_specs", () => {
121
160
  const text = await harness.call("list_screen_specs", { project_dir: proj });
122
161
  expect(text).toContain("Spec directory not found");
123
162
  });
163
+
164
+ it("expands a parent spec's subSpecs inline so the split is visible", async () => {
165
+ writeSplitSpecFixture(projectDir);
166
+ const specs = JSON.parse(
167
+ await harness.call("list_screen_specs", { project_dir: projectDir })
168
+ );
169
+ const core = specs.find(
170
+ (s: any) => s.file === "messages/messages-core.spec.json"
171
+ );
172
+ expect(core).toEqual({
173
+ file: "messages/messages-core.spec.json",
174
+ name: "Messages - Core",
175
+ displayName: "Core structure and message list",
176
+ type: "screen_sub_spec",
177
+ parent: "messages.spec.json",
178
+ layoutFile: "messages",
179
+ });
180
+ // The parent itself still lists normally.
181
+ expect(
182
+ specs.find((s: any) => s.file === "messages.spec.json")?.type
183
+ ).toBe("screen_parent_spec");
184
+ });
185
+ });
186
+
187
+ describe("search_specs", () => {
188
+ it("finds a keyword inside a sub-spec file in a subdirectory", async () => {
189
+ writeSplitSpecFixture(projectDir);
190
+ const results = JSON.parse(
191
+ await harness.call("search_specs", {
192
+ query: "currentPage",
193
+ project_dir: projectDir,
194
+ })
195
+ );
196
+ const hit = results.find(
197
+ (r: any) => r.file === "messages/messages-core.spec.json"
198
+ );
199
+ expect(hit).toBeTruthy();
200
+ expect(hit.matches[0].path).toContain("structure.messageList");
201
+ expect(hit.matches[0].snippet).toContain("@{currentPage}");
202
+ });
203
+
204
+ it("matches keys as well as string values, case-insensitively", async () => {
205
+ writeSplitSpecFixture(projectDir);
206
+ const results = JSON.parse(
207
+ await harness.call("search_specs", {
208
+ query: "ATTACHMENT",
209
+ project_dir: projectDir,
210
+ })
211
+ );
212
+ expect(
213
+ results.some(
214
+ (r: any) => r.file === "messages/messages-composer.spec.json"
215
+ )
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("searches component specs too", async () => {
220
+ const results = JSON.parse(
221
+ await harness.call("search_specs", {
222
+ query: "ExampleCard",
223
+ project_dir: projectDir,
224
+ })
225
+ );
226
+ expect(
227
+ results.some((r: any) => r.file === "example_card.component.json")
228
+ ).toBe(true);
229
+ });
230
+
231
+ it("reports no matches as a plain message", async () => {
232
+ const text = await harness.call("search_specs", {
233
+ query: "zzz_nothing_zzz",
234
+ project_dir: projectDir,
235
+ });
236
+ expect(text).toContain("No matches");
237
+ });
124
238
  });
125
239
 
126
240
  describe("list_component_specs", () => {