carterkit 0.1.0__py3-none-any.whl

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.
Files changed (60) hide show
  1. carterkit/__init__.py +69 -0
  2. carterkit/buffer.py +230 -0
  3. carterkit/catalog.py +285 -0
  4. carterkit/client.py +138 -0
  5. carterkit/codegen.py +188 -0
  6. carterkit/controldocs/accordion.md +99 -0
  7. carterkit/controldocs/actions.md +51 -0
  8. carterkit/controldocs/animations.md +41 -0
  9. carterkit/controldocs/appearance.md +67 -0
  10. carterkit/controldocs/button.md +143 -0
  11. carterkit/controldocs/cardList.md +25 -0
  12. carterkit/controldocs/carousel.md +155 -0
  13. carterkit/controldocs/chat.md +114 -0
  14. carterkit/controldocs/color-picker.md +84 -0
  15. carterkit/controldocs/control-def.md +101 -0
  16. carterkit/controldocs/date-picker.md +148 -0
  17. carterkit/controldocs/divider.md +59 -0
  18. carterkit/controldocs/flip-card.md +99 -0
  19. carterkit/controldocs/gauge.md +198 -0
  20. carterkit/controldocs/graph.md +318 -0
  21. carterkit/controldocs/group-def.md +103 -0
  22. carterkit/controldocs/haptics.md +32 -0
  23. carterkit/controldocs/image.md +110 -0
  24. carterkit/controldocs/index.md +141 -0
  25. carterkit/controldocs/joystick.md +111 -0
  26. carterkit/controldocs/label.md +138 -0
  27. carterkit/controldocs/layout-config.md +129 -0
  28. carterkit/controldocs/list.md +92 -0
  29. carterkit/controldocs/log-console.md +121 -0
  30. carterkit/controldocs/long-press.md +46 -0
  31. carterkit/controldocs/map.md +165 -0
  32. carterkit/controldocs/picker.md +130 -0
  33. carterkit/controldocs/privacy.md +52 -0
  34. carterkit/controldocs/progress-ring.md +139 -0
  35. carterkit/controldocs/pulse.md +82 -0
  36. carterkit/controldocs/qr-code.md +101 -0
  37. carterkit/controldocs/segmented.md +145 -0
  38. carterkit/controldocs/slider.md +235 -0
  39. carterkit/controldocs/spacer.md +36 -0
  40. carterkit/controldocs/sparkline.md +120 -0
  41. carterkit/controldocs/status-light.md +114 -0
  42. carterkit/controldocs/stepper.md +131 -0
  43. carterkit/controldocs/sync.md +50 -0
  44. carterkit/controldocs/terms.md +40 -0
  45. carterkit/controldocs/text-input.md +126 -0
  46. carterkit/controldocs/theming.md +104 -0
  47. carterkit/controldocs/toggle.md +185 -0
  48. carterkit/controldocs/visibility.md +43 -0
  49. carterkit/controldocs/web-view.md +97 -0
  50. carterkit/e2ee.py +47 -0
  51. carterkit/grid.py +114 -0
  52. carterkit/infer.py +137 -0
  53. carterkit/theming.py +157 -0
  54. carterkit/tune.py +129 -0
  55. carterkit/validate.py +132 -0
  56. carterkit-0.1.0.dist-info/METADATA +98 -0
  57. carterkit-0.1.0.dist-info/RECORD +60 -0
  58. carterkit-0.1.0.dist-info/WHEEL +5 -0
  59. carterkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. carterkit-0.1.0.dist-info/top_level.txt +1 -0
carterkit/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """carterkit — build and drive CAR-TER layouts from Python.
2
+
3
+ The control vocabulary *is* the bundled documentation: every control's schema,
4
+ fields, and examples are parsed at runtime from the ControlDocs markdown shipped
5
+ inside this package (``carterkit/controldocs/``) — the same docs the CAR-TER app
6
+ renders. So the docs never drift from the definitions; they are one and the same.
7
+
8
+ Quick map:
9
+ - ``controls()`` / ``doc()`` / ``examples()`` — the docs-as-catalog surface
10
+ - ``LayoutBuffer`` — incrementally build a layout (auto-placement, dedupe)
11
+ - ``validate_layout()`` — schema + grid lint against the bundled catalog
12
+ - ``infer`` / ``codegen`` / ``theming`` / ``tune`` — generate layouts, servers, themes
13
+ - ``CarterClient`` / ``notify_http`` — connect over MeshSocket, push, send alerts
14
+ """
15
+ from importlib.resources import files
16
+ from pathlib import Path
17
+
18
+ from . import catalog, grid, codegen, infer, theming, tune
19
+ from .buffer import LayoutBuffer, BufferError
20
+ from .validate import validate_layout as _validate_layout, format_findings
21
+ from .client import CarterClient, notify_http, CarterNotifyError
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ #: The layout/wire protocol version carterkit emits and understands. The JSON
26
+ #: contract — not this Python API — is the real compatibility boundary across the
27
+ #: app, the relay, and this library; unknown fields are tolerated on read.
28
+ PROTOCOL_VERSION = 1
29
+
30
+
31
+ def controldocs_dir() -> Path:
32
+ """Filesystem path to the bundled ControlDocs markdown (the source of truth)."""
33
+ return Path(files(__package__) / "controldocs")
34
+
35
+
36
+ def controls(types=None, include_theme: bool = False) -> dict:
37
+ """Machine-readable schema for every placeable control, keyed by layout ``type``."""
38
+ return catalog.build_catalog(controldocs_dir(), types=types, include_theme=include_theme)
39
+
40
+
41
+ def doc(control: str):
42
+ """Full parsed doc (fields, themeFields, body, examples) for a control type or node_id."""
43
+ return catalog.resolve_doc(controldocs_dir(), control)
44
+
45
+
46
+ def doc_markdown(control: str):
47
+ """The control's human/AI documentation prose (the rendered markdown body)."""
48
+ d = doc(control)
49
+ return d["body"] if d else None
50
+
51
+
52
+ def examples(control: str):
53
+ """Documented example snippets for a control: ``[{"name", "json"}, ...]``."""
54
+ return catalog.get_examples(controldocs_dir(), control)
55
+
56
+
57
+ def validate_layout(layout: dict, catalog_: dict = None) -> list:
58
+ """Lint a layout (schema + grid). Defaults to the bundled control catalog."""
59
+ return _validate_layout(layout, catalog_ if catalog_ is not None else controls(include_theme=True))
60
+
61
+
62
+ __all__ = [
63
+ "__version__", "PROTOCOL_VERSION",
64
+ "CarterClient", "notify_http", "CarterNotifyError",
65
+ "LayoutBuffer", "BufferError",
66
+ "controls", "doc", "doc_markdown", "examples", "validate_layout",
67
+ "format_findings", "controldocs_dir",
68
+ "catalog", "grid", "codegen", "infer", "theming", "tune",
69
+ ]
carterkit/buffer.py ADDED
@@ -0,0 +1,230 @@
1
+ """Working-layout buffer + incremental patch ops.
2
+
3
+ The headline authoring model: instead of re-emitting an 800-line layout on every
4
+ change, the LLM issues surgical operations against a server-held draft. The buffer
5
+ keeps the full document; each op mutates it and the result is pushed to the device as
6
+ a full layout (which it already knows how to render). Pure (no I/O, no socket) so it
7
+ is fully unit-testable; the async push/save live in server.py.
8
+
9
+ Top-level children (controls + groups) of a tab are addressable by id. Editing inside
10
+ a group is not yet supported (ids inside groups still count for uniqueness).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ from typing import Optional
17
+
18
+ from . import grid as gridmod
19
+
20
+ DEFAULT_COLUMNS = 4
21
+ DEFAULT_ROWS = 8
22
+
23
+
24
+ class BufferError(Exception):
25
+ """Raised on an invalid buffer operation (rendered to the user as a tool error)."""
26
+
27
+
28
+ class LayoutBuffer:
29
+ def __init__(self, layout: dict):
30
+ self.layout = layout
31
+
32
+ # ─── construction ────────────────────────────────────────────────────────
33
+
34
+ @classmethod
35
+ def blank(cls, name: str = "Untitled", columns: int = DEFAULT_COLUMNS,
36
+ rows: int = DEFAULT_ROWS, accent: str = "#667eea",
37
+ tab_title: str = "Tab 1", tab_icon: str = "house.fill") -> "LayoutBuffer":
38
+ return cls({
39
+ "name": name,
40
+ "version": 1,
41
+ "accentColor": accent,
42
+ "tabs": [{
43
+ "title": tab_title,
44
+ "icon": tab_icon,
45
+ "grid": {"columns": columns, "rows": rows},
46
+ "children": [],
47
+ }],
48
+ })
49
+
50
+ @classmethod
51
+ def from_layout(cls, layout: dict) -> "LayoutBuffer":
52
+ if not isinstance(layout, dict) or "tabs" not in layout:
53
+ raise BufferError("source layout must be an object with a 'tabs' array")
54
+ return cls(copy.deepcopy(layout))
55
+
56
+ # ─── access ──────────────────────────────────────────────────────────────
57
+
58
+ @property
59
+ def tabs(self) -> list:
60
+ return self.layout.setdefault("tabs", [])
61
+
62
+ def _tab(self, i: int) -> dict:
63
+ tabs = self.tabs
64
+ if i < 0 or i >= len(tabs):
65
+ raise BufferError(f"tab index {i} out of range (have {len(tabs)} tab(s))")
66
+ return tabs[i]
67
+
68
+ @staticmethod
69
+ def _grid_dims(tab: dict) -> tuple[int, int]:
70
+ g = tab.get("grid") or {}
71
+ return int(g.get("columns", DEFAULT_COLUMNS)), int(g.get("rows", DEFAULT_ROWS))
72
+
73
+ def all_ids(self) -> set[str]:
74
+ ids: set[str] = set()
75
+
76
+ def walk(children):
77
+ for ch in children or []:
78
+ if isinstance(ch, dict):
79
+ if "id" in ch:
80
+ ids.add(ch["id"])
81
+ if ch.get("type") == "group":
82
+ walk(ch.get("children"))
83
+
84
+ for tab in self.tabs:
85
+ walk(tab.get("children"))
86
+ return ids
87
+
88
+ def unique_id(self, base: str) -> str:
89
+ base = base or "control"
90
+ ids = self.all_ids()
91
+ if base not in ids:
92
+ return base
93
+ i = 2
94
+ while f"{base}-{i}" in ids:
95
+ i += 1
96
+ return f"{base}-{i}"
97
+
98
+ def find(self, control_id: str) -> Optional[tuple[int, int, dict]]:
99
+ """Locate a top-level child by id -> (tab_index, child_index, child)."""
100
+ for ti, tab in enumerate(self.tabs):
101
+ for ci, ch in enumerate(tab.get("children", [])):
102
+ if isinstance(ch, dict) and ch.get("id") == control_id:
103
+ return ti, ci, ch
104
+ return None
105
+
106
+ # ─── mutations ───────────────────────────────────────────────────────────
107
+
108
+ def add_control(self, control: dict, tab_index: int = 0,
109
+ position: Optional[list[int]] = None,
110
+ default_span: Optional[list[int]] = None) -> dict:
111
+ if not isinstance(control, dict) or "type" not in control:
112
+ raise BufferError("control must be an object with a 'type'")
113
+ control = copy.deepcopy(control)
114
+ control["id"] = self.unique_id(control.get("id") or control["type"])
115
+
116
+ tab = self._tab(tab_index)
117
+ children = tab.setdefault("children", [])
118
+ cols, rows = self._grid_dims(tab)
119
+
120
+ if control.get("span") is None and default_span and default_span != [1, 1]:
121
+ control["span"] = default_span
122
+ span = control.get("span") or [1, 1]
123
+
124
+ if position is None:
125
+ slot = gridmod.find_slot(children, cols, rows, span)
126
+ if slot is None:
127
+ raise BufferError(
128
+ f"no free {span} slot in tab {tab_index} ({rows}x{cols} grid). "
129
+ f"Grow the grid (add_tab/edit grid) or pass an explicit position.")
130
+ position = slot
131
+ control["position"] = position
132
+ children.append(control)
133
+ return control
134
+
135
+ def update_control(self, control_id: str, patch: dict) -> dict:
136
+ found = self.find(control_id)
137
+ if not found:
138
+ raise BufferError(f"no control '{control_id}' in the buffer")
139
+ ch = found[2]
140
+ for k, v in patch.items():
141
+ if v is None:
142
+ ch.pop(k, None)
143
+ else:
144
+ ch[k] = v
145
+ return ch
146
+
147
+ def remove_control(self, control_id: str) -> dict:
148
+ found = self.find(control_id)
149
+ if not found:
150
+ raise BufferError(f"no control '{control_id}' in the buffer")
151
+ ti, ci, _ = found
152
+ return self.tabs[ti]["children"].pop(ci)
153
+
154
+ def move_control(self, control_id: str, position: Optional[list[int]] = None,
155
+ span: Optional[list[int]] = None,
156
+ tab_index: Optional[int] = None) -> dict:
157
+ found = self.find(control_id)
158
+ if not found:
159
+ raise BufferError(f"no control '{control_id}' in the buffer")
160
+ ti, ci, ch = found
161
+ if tab_index is not None and tab_index != ti:
162
+ ch = self.tabs[ti]["children"].pop(ci)
163
+ self._tab(tab_index).setdefault("children", []).append(ch)
164
+ if position is not None:
165
+ ch["position"] = position
166
+ if span is not None:
167
+ ch["span"] = span
168
+ return ch
169
+
170
+ def add_tab(self, title: str, icon: str = "square.grid.2x2",
171
+ columns: int = DEFAULT_COLUMNS, rows: int = DEFAULT_ROWS) -> int:
172
+ self.tabs.append({
173
+ "title": title, "icon": icon,
174
+ "grid": {"columns": columns, "rows": rows}, "children": [],
175
+ })
176
+ return len(self.tabs) - 1
177
+
178
+ def add_group(self, group: dict, tab_index: int = 0,
179
+ position: Optional[list[int]] = None) -> dict:
180
+ group = copy.deepcopy(group)
181
+ group["type"] = "group"
182
+ group["id"] = self.unique_id(group.get("id") or "group")
183
+ tab = self._tab(tab_index)
184
+ children = tab.setdefault("children", [])
185
+ cols, rows = self._grid_dims(tab)
186
+ span = group.get("span") or [1, 1]
187
+ if position is None:
188
+ slot = gridmod.find_slot(children, cols, rows, span)
189
+ if slot is None:
190
+ raise BufferError(f"no free {span} slot in tab {tab_index} for the group")
191
+ position = slot
192
+ group["position"] = position
193
+ children.append(group)
194
+ return group
195
+
196
+ # ─── views ───────────────────────────────────────────────────────────────
197
+
198
+ def issues(self) -> list[dict]:
199
+ """Placement issues across all tabs, each tagged with its tab index."""
200
+ out: list[dict] = []
201
+ for ti, tab in enumerate(self.tabs):
202
+ cols, rows = self._grid_dims(tab)
203
+ for issue in gridmod.validate_placement(tab.get("children", []), cols, rows):
204
+ out.append({"tab": ti, **issue})
205
+ return out
206
+
207
+ def summary(self, show_grids: bool = True) -> str:
208
+ name = self.layout.get("name", "Untitled")
209
+ accent = self.layout.get("accentColor")
210
+ head = f"**{name}**" + (f" · accent {accent}" if accent else "")
211
+ lines = [head]
212
+ for ti, tab in enumerate(self.tabs):
213
+ cols, rows = self._grid_dims(tab)
214
+ children = tab.get("children", [])
215
+ lines.append(f"\nTab {ti}: {tab.get('title','?')} ({tab.get('icon','')}) "
216
+ f"— {rows}x{cols}, {len(children)} item(s)")
217
+ for ch in children:
218
+ pos = ch.get("position")
219
+ span = ch.get("span")
220
+ extra = f" span {span}" if span and span != [1, 1] else ""
221
+ lines.append(f" - {ch.get('id','?')} ({ch.get('type','?')}) @ {pos}{extra}")
222
+ if show_grids and children:
223
+ lines.append(gridmod.render_grid(children, cols, rows))
224
+ problems = self.issues()
225
+ if problems:
226
+ lines.append("\n⚠ placement issues:")
227
+ for p in problems:
228
+ ids = p.get("ids") or [p.get("id")]
229
+ lines.append(f" - [tab {p['tab']}] {p['kind']}: {', '.join(ids)} — {p['detail']}")
230
+ return "\n".join(lines)
carterkit/catalog.py ADDED
@@ -0,0 +1,285 @@
1
+ """Control catalog + example extraction — pure parsing of the ControlDocs markdown.
2
+
3
+ Mirrors the app's `ControlDocLoader` frontmatter parser (Swift) so the MCP and the
4
+ device agree on the field schema for every control. Deliberately does NOT use a real
5
+ YAML parser: the docs carry unquoted `#hex` defaults (e.g. `default: #667eea`) which
6
+ YAML would treat as comments, and some values omit quotes inconsistently. The
7
+ hand-rolled parser tolerates both.
8
+
9
+ Public surface (all pure; `docs_dir` is injected for testability):
10
+ parse_doc(content, node_id) -> dict | None one doc's frontmatter + body + examples
11
+ parse_all(docs_dir) -> {node_id: doc}
12
+ build_catalog(docs_dir, types) -> {type: compact} placeable-control schema
13
+ get_examples(docs_dir, control) -> [{"name", "json"}]
14
+ find_example(docs_dir, control, name) -> {"name", "json"} | None
15
+ resolve_doc(docs_dir, control) -> doc | None accepts node_id OR control type
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+
25
+ # ─── Frontmatter parsing (mirrors ControlDocLoader.swift) ────────────────────
26
+
27
+
28
+ def _parse_str_array(value: str) -> list[str]:
29
+ inner = value.strip().strip("[]")
30
+ out = []
31
+ for part in inner.split(","):
32
+ s = part.strip().strip("\"'")
33
+ if s:
34
+ out.append(s)
35
+ return out
36
+
37
+
38
+ def _parse_int_array(value: str) -> Optional[list[int]]:
39
+ inner = value.strip().strip("[]")
40
+ parts = []
41
+ for part in inner.split(","):
42
+ part = part.strip()
43
+ if not part:
44
+ continue
45
+ try:
46
+ parts.append(int(part))
47
+ except ValueError:
48
+ return None
49
+ return parts or None
50
+
51
+
52
+ def _make_field(raw: dict[str, str]) -> dict:
53
+ field: dict = {"name": raw.get("name", ""), "type": raw.get("type", "string")}
54
+ if "values" in raw:
55
+ field["values"] = _parse_str_array(raw["values"])
56
+ if "default" in raw:
57
+ field["default"] = raw["default"]
58
+ if raw.get("description"):
59
+ field["description"] = raw["description"]
60
+ return field
61
+
62
+
63
+ def parse_doc(content: str, node_id: str) -> Optional[dict]:
64
+ """Parse one control doc into {node_id,type,label,icon,category,defaultSpan,
65
+ fields,themeFields,body,examples}. Returns None if it lacks a type+label
66
+ (matches the Swift loader, which skips non-control prose-only docs)."""
67
+ lines = content.split("\n")
68
+ if not lines or lines[0].strip() != "---":
69
+ return None
70
+ end = None
71
+ for i in range(1, len(lines)):
72
+ if lines[i].strip() == "---":
73
+ end = i
74
+ break
75
+ if end is None:
76
+ return None
77
+
78
+ front = lines[1:end]
79
+ body = "\n".join(lines[end + 1:]).strip()
80
+
81
+ meta: dict = {
82
+ "node_id": node_id,
83
+ "type": "",
84
+ "label": "",
85
+ "icon": "",
86
+ "category": "",
87
+ "defaultSpan": None,
88
+ "fields": [],
89
+ "themeFields": [],
90
+ }
91
+
92
+ in_fields = False
93
+ current_list: Optional[list] = None
94
+ current: dict[str, str] = {}
95
+
96
+ def flush():
97
+ nonlocal current
98
+ if in_fields and current and current_list is not None:
99
+ current_list.append(_make_field(current))
100
+ current = {}
101
+
102
+ for line in front:
103
+ if not line.strip():
104
+ continue
105
+ top_level = not line.startswith((" ", "\t")) and ":" in line
106
+ if top_level:
107
+ flush()
108
+ in_fields = False
109
+ current_list = None
110
+ key, _, value = line.partition(":")
111
+ key, value = key.strip(), value.strip()
112
+ if key == "type":
113
+ meta["type"] = value
114
+ elif key == "label":
115
+ meta["label"] = value
116
+ elif key == "icon":
117
+ meta["icon"] = value
118
+ elif key == "category":
119
+ meta["category"] = value
120
+ elif key == "defaultSpan":
121
+ meta["defaultSpan"] = _parse_int_array(value)
122
+ elif key == "fields":
123
+ in_fields = True
124
+ current_list = meta["fields"]
125
+ elif key == "themeFields":
126
+ in_fields = True
127
+ current_list = meta["themeFields"]
128
+ elif in_fields:
129
+ trimmed = line.strip()
130
+ if trimmed.startswith("- name:"):
131
+ flush()
132
+ current = {"name": trimmed[len("- name:"):].strip().strip("\"'")}
133
+ elif ":" in trimmed:
134
+ k, _, v = trimmed.partition(":")
135
+ current[k.strip()] = v.strip().strip('"')
136
+ flush()
137
+
138
+ if not meta["type"] or not meta["label"]:
139
+ return None
140
+ meta["body"] = body
141
+ meta["examples"] = extract_examples(body)
142
+ return meta
143
+
144
+
145
+ # ─── Example extraction (the `## Examples` section) ──────────────────────────
146
+
147
+
148
+ def extract_examples(body: str) -> list[dict]:
149
+ """Pull named JSON snippets from a doc's `## Examples` section: each `### Title`
150
+ followed by a ```json fenced block becomes {"name", "json"}. Only the Examples
151
+ section is scanned, so unrelated json blocks (e.g. a "## Segments" sample) are
152
+ ignored."""
153
+ lines = body.split("\n")
154
+ start = None
155
+ for i, l in enumerate(lines):
156
+ if l.strip().lower() == "## examples":
157
+ start = i + 1
158
+ break
159
+ if start is None:
160
+ return []
161
+ end = len(lines)
162
+ for i in range(start, len(lines)):
163
+ if lines[i].startswith("## "):
164
+ end = i
165
+ break
166
+ section = lines[start:end]
167
+
168
+ examples: list[dict] = []
169
+ title: Optional[str] = None
170
+ seen: dict[str, int] = {}
171
+ i = 0
172
+ while i < len(section):
173
+ l = section[i]
174
+ if l.startswith("### "):
175
+ title = l[4:].strip().strip("`")
176
+ i += 1
177
+ continue
178
+ if l.strip().startswith("```json"):
179
+ j = i + 1
180
+ buf = []
181
+ while j < len(section) and not section[j].strip().startswith("```"):
182
+ buf.append(section[j])
183
+ j += 1
184
+ name = title or f"Example {len(examples) + 1}"
185
+ if name in seen:
186
+ seen[name] += 1
187
+ name = f"{name} ({seen[name]})"
188
+ else:
189
+ seen[name] = 1
190
+ examples.append({"name": name, "json": "\n".join(buf).strip()})
191
+ i = j + 1
192
+ continue
193
+ i += 1
194
+ return examples
195
+
196
+
197
+ # ─── Catalog assembly ────────────────────────────────────────────────────────
198
+
199
+ # Categories whose docs describe controls that can be placed in a layout grid.
200
+ PLACEABLE_CATEGORIES = {"controls", "display"}
201
+
202
+
203
+ def parse_all(docs_dir) -> dict[str, dict]:
204
+ """Parse every `*.md` doc in the directory. Keyed by node_id (filename stem)."""
205
+ out: dict[str, dict] = {}
206
+ for f in sorted(Path(docs_dir).glob("*.md")):
207
+ doc = parse_doc(f.read_text(), f.stem)
208
+ if doc:
209
+ out[doc["node_id"]] = doc
210
+ return out
211
+
212
+
213
+ def _compact(doc: dict, include_theme: bool = False) -> dict:
214
+ out: dict = {
215
+ "type": doc["type"],
216
+ "node_id": doc["node_id"],
217
+ "label": doc["label"],
218
+ "category": doc["category"],
219
+ }
220
+ if doc.get("defaultSpan"):
221
+ out["defaultSpan"] = doc["defaultSpan"]
222
+ if doc.get("fields"):
223
+ out["fields"] = doc["fields"]
224
+ if include_theme and doc.get("themeFields"):
225
+ out["themeFields"] = doc["themeFields"]
226
+ if doc.get("examples"):
227
+ out["examples"] = [e["name"] for e in doc["examples"]]
228
+ return out
229
+
230
+
231
+ def build_catalog(docs_dir, types: Optional[list[str]] = None,
232
+ include_theme: bool = False) -> dict[str, dict]:
233
+ """Compact, machine-readable schema for placeable controls, keyed by control
234
+ `type` (the value used in a layout). `types` filters to specific control types;
235
+ `include_theme` adds the per-control theme override fields."""
236
+ docs = parse_all(docs_dir)
237
+ catalog: dict[str, dict] = {}
238
+ wanted = set(types) if types else None
239
+ for doc in docs.values():
240
+ t = doc["type"]
241
+ if wanted is not None:
242
+ if t not in wanted and doc["node_id"] not in wanted:
243
+ continue
244
+ elif doc["category"] not in PLACEABLE_CATEGORIES:
245
+ continue
246
+ catalog[t] = _compact(doc, include_theme=include_theme)
247
+ return catalog
248
+
249
+
250
+ def resolve_doc(docs_dir, control: str) -> Optional[dict]:
251
+ """Find a doc by node_id (e.g. 'color-picker') or control type (e.g. 'colorPicker')."""
252
+ docs = parse_all(docs_dir)
253
+ if control in docs:
254
+ return docs[control]
255
+ for doc in docs.values():
256
+ if doc["type"] == control:
257
+ return doc
258
+ return None
259
+
260
+
261
+ def get_examples(docs_dir, control: str) -> list[dict]:
262
+ doc = resolve_doc(docs_dir, control)
263
+ return doc["examples"] if doc else []
264
+
265
+
266
+ def find_example(docs_dir, control: str, name: str) -> Optional[dict]:
267
+ """Match an example by exact or case-insensitive-prefix name."""
268
+ examples = get_examples(docs_dir, control)
269
+ for ex in examples:
270
+ if ex["name"] == name:
271
+ return ex
272
+ low = name.lower()
273
+ for ex in examples:
274
+ if ex["name"].lower().startswith(low):
275
+ return ex
276
+ return None
277
+
278
+
279
+ def example_as_obj(example: dict) -> Optional[dict]:
280
+ """Parse an example's JSON snippet into a dict (None if it doesn't parse)."""
281
+ try:
282
+ obj = json.loads(example["json"])
283
+ return obj if isinstance(obj, dict) else None
284
+ except (json.JSONDecodeError, TypeError):
285
+ return None