Skip to content

Appendix C — iOS / SwiftUI priors that fail in StemJSON

StemJSON is declarative and closed. LLMs trained on UIKit, SwiftUI, and React Native code routinely reach for patterns that look right but fail validation or render verbatim. This appendix names the wrong patterns explicitly — paired with the StemJSON equivalent — so the model has a direct mapping rather than having to infer from absence.

Add to this list whenever a new class of recurring mistake appears in generated output.

See §7.3 anti-patterns. Do NOT use UIKit semantic color names (label, secondaryLabel, tertiaryLabel, quaternaryLabel, systemBlue, systemRed, systemGray, systemBackground, etc.). Use the §7.3 closed list of supported keywords.

Wrong (iOS instinct)Right (StemJSON)
"foreground": "label""foreground": "primary"
"foreground": "secondaryLabel""foreground": "secondary"
"foreground": "tertiaryLabel""foreground": { "color": "primary", "opacity": 0.6 }
"background": "systemBackground""background": "background" (semantic)
"background": "secondarySystemBackground""background": "surface"
"foreground": "systemBlue""foreground": "accentColor" or "blue"
  • "padding": { "all": 16 } — no all key
  • "padding": 16 (single number = all sides)
  • "padding": { "top": 16, "bottom": 16, "leading": 16, "trailing": 16 }
  • "padding": { "horizontal": 16, "vertical": 12 }
  • "font": { "size": 18, "weight": "semibold", "design": "rounded" } — no size outside { "kind": "system", ... }, no design, no tracking, no leading
  • "font": "headline" — preset string (see §7.2)
  • "font": { "kind": "title2", "weight": "semibold" } — named kind + weight
  • "font": { "kind": "system", "size": 18 } — only the system kind takes size
  • "layout": { "frame": { "maxWidth": "infinity", "maxHeight": "infinity" } } — no frame wrapper
  • "layout": { "maxWidth": "infinity", "maxHeight": "infinity" } — dimensions live directly under layout (see §7.6)
  • ❌ Pinning primary content to fixed width / height in points on a non-scrolling root (e.g. a 300×300 dial that fills the screen) — overflows smaller viewports and large Dynamic Type, and the runtime clips the overflow (see §4.6). Size it responsively, or put the screen in a scroll.
  • "style": { "layout": { "spacing": 24, "alignment": "leading" } } on a vstack / hstack
  • "context": { "_spacing": 24, "_alignment": "leading" } — stacks take spacing/alignment in their context, not style
  • "context": { "_text": "Save" } on a button_text is the content key for text components only
  • "context": { "_label": "Save" } — buttons use _label
  • ✅ Or omit _label and provide children for custom button content
  • "events": { "onSave": [...], "onIncrement": [...] } — there are no developer-named events; the event vocabulary is closed (see §9.1)
  • ✅ Wire actions directly to the standard event the gesture/lifecycle fires (onTap, onSubmit, onAppear, etc.)
  • "events": { "onTap": "handleSave" }onTap is an array of action wrapper objects, never a string handle to a named handler
  • "events": { "onTap": [ { "state": { "id": "...", "input": { ... } } } ] } — inline action array
  • "_format": "date" — bare-string _format is silently ignored, raw double renders (see §4.3)
  • "_format": { "style": "date" } — missing formatter-kind wrapper
  • "_format": { "date": "friendly" } — single-key wrapper, value is style string or dict
  • .padding(), .foregroundColor(), .font(), .cornerRadius() — there are no modifiers
  • ✅ Everything is declarative style.* blocks on the component
  • "Color.blue", "UIColor.systemBlue", ".systemBackground", "Color(red: 0.2, green: 0.5, blue: 0.8)"
  • ✅ Bare keyword from §7.3 closed list, or hex literal
  • "sf:plus.circle", "plus.circle" as an _image / _source value
  • "sf://plus.circle" — full sf:// scheme prefix required everywhere except the _tabImage exception (see §6.4)
  • "background": { "linear": { ... } } — bare gradient without the gradient discriminator wrapper
  • "background": { "gradient": { "linear": { ... } } } — see §7.5
  • "_options": [ { "label": "Small", "value": "s" }, { "label": "Medium", "value": "m" } ] — pickers take a flat array of primitive values, not labeled objects
  • "_options": ["small", "medium", "large"] — see §4.4 picker
  • { "module": { "id": "...", "type": "module", ... } } — no wrapper key
  • { { "id": "...", "type": "module", ... } } — no double brace
  • ✅ The outermost { IS the module’s opening brace: { "id": "...", "type": "module", "version": "1.0", ... }
  • "_text": "{{ @${forecast}.temperature }}"@$ is not a marker prefix. The runtime parses the whole string as a literal and renders it verbatim, curly braces included.
  • "_text": "@${path}", "_text": "${@{ctx}}", "_text": "#{${state}}" — every merged form is invalid; markers do not nest or combine.
  • ✅ Pick exactly one marker per reference based on where the data lives:
    • state → ${forecast.temperature} or {{ ${forecast.temperature} }}
    • context → @{forecast.temperature} or {{ @{forecast.temperature} }}
    • system → #{now} or {{ #{now} }}

Most common AI-authoring trigger: the model has seen examples using both ${} and @{} and “helpfully” combines them. There is no combined form; each marker maps to exactly one resolution scope. If you don’t know whether the data is in state or context, look at where it was last written (state action target vs. context-key declaration) and use the matching marker.

  • "navigate": { "input": { "operation": "push", "source": "file://detail.json" } } in a single-file inline module — file:// resolves inside a zip package; in a single-file module there IS no file named detail.json, so the push silently fails (no transition, no error).
  • "source": "detail" — bare string, no scheme prefix; rejected as not a source string.
  • ✅ Declare the destination as a component dependency, then push via dp://:
    "dependencies": [
    { "component": { "id": "detail", "type": "module", "children": [ /* … */ ] } }
    ],
    "events": { "onTap": [
    { "navigate": { "id": "go", "input": { "operation": "push", "source": "dp://detail" } } }
    ] }

This is the same class as the “wrapper-required” mistakes elsewhere — the spec accepts two source schemes in navigate.input.source but only one of them is meaningful for inline modules. Authoring AI tends to pick whichever LOOKS most natural (“file://detail.json” reads like a file reference) without understanding that single-file modules have no file system to reach into.