Agentic-GenUI-Tools 0.2.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.
@@ -0,0 +1,1891 @@
1
+ """Forgiving element descriptions -> deterministic, styled Adaptive Cards 1.5.
2
+
3
+ Pipeline: load (lenient JSON) -> document (unwrap, root fields) -> canonical
4
+ nodes (type resolution/inference, field aliases) -> merge adjacent singles ->
5
+ render with the theme -> width estimate. Every repair is recorded in metadata.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from . import theme
15
+ from .catalog import (CONTENT_KEYS, FIELD_ALIASES, SPECS, STYLE_KEYS, TYPE_KEYS, infer_type, map_fields, resolve_type)
16
+ from .normalize import (WRAPPER_KEYS, BuilderConfig, CardError, Context, as_bool, as_date, as_id, as_number,
17
+ as_time, as_url, clean_text, closest, display_text, format_number, is_slug_like,
18
+ load_request, parse_json_text, parse_number, slug, submit_text, token)
19
+
20
+
21
+ @dataclass
22
+ class Node:
23
+ type: str
24
+ f: dict
25
+ path: str
26
+ hints: dict = field(default_factory=dict)
27
+ extras: list = field(default_factory=list)
28
+ unknown: list = field(default_factory=list)
29
+ source: str = ""
30
+
31
+
32
+ def _keys(*names: str) -> set[str]:
33
+ return {token(n) for group in names for n in group.split()}
34
+
35
+
36
+ def pick(obj: dict, keys: set[str]):
37
+ """First (key, value) whose key token is in keys."""
38
+ for k, v in obj.items():
39
+ if token(k) in keys and v is not None:
40
+ return k, v
41
+ return None, None
42
+
43
+
44
+ def humanize(key: str) -> str:
45
+ if " " in key.strip() or not re.search(r"[_\-]|[a-z][A-Z]", key):
46
+ return key[:1].upper() + key[1:] if key[:1].islower() and " " not in key else key
47
+ words = re.sub(r"([a-z])([A-Z])", r"\1 \2", key).replace("_", " ").replace("-", " ").split()
48
+ return " ".join(words)[:1].upper() + " ".join(words)[1:].lower() if words else key
49
+
50
+
51
+ ROOT_FIELDS = {
52
+ "title": _keys("title heading header card_title cardtitle headline"),
53
+ "subtitle": _keys("subtitle subheading subheader tagline byline"),
54
+ "status": _keys("status badge state"),
55
+ "elements": _keys("elements body items components questions fields children blocks sections widgets cards contents"),
56
+ "submit_label": _keys("submit_label submit_text submit_title submit_button button_text button_label submit"),
57
+ "buttons": _keys("buttons actions links ctas"),
58
+ }
59
+ ROOT_ALIAS = {t: name for name, tokens in ROOT_FIELDS.items() for t in tokens}
60
+ LABEL_KEYS = _keys("title label text name display display_name caption option choice heading key fact property field attribute term metric kpi stat")
61
+ VALUE_KEYS = _keys("value val id code data answer detail details content definition amount number figure count total score current current_value result y size")
62
+ POINT_LABEL_KEYS = _keys("label name x category key title legend month date period year day week quarter group item time region product")
63
+ SERIES_NAME_KEYS = _keys("name label legend title series key")
64
+ SERIES_VALUE_KEYS = _keys("values data points y items")
65
+ METRIC_MAP = {t: name for name, words in {
66
+ "label": "label title name metric kpi stat key caption heading",
67
+ "value": "value amount number figure count total score current current_value val result",
68
+ "change": "change delta diff difference trend growth variance pct_change percent_change movement change_percent",
69
+ "note": "note period context subtext secondary comparison target description subtitle footnote",
70
+ "lower_is_better": "lower_is_better good_direction inverse invert", "icon": "icon emoji"}.items() for t in _keys(words)}
71
+ BUTTON_MAP = {t: name for name, words in {
72
+ "label": "label title text name caption", "url": "url href link uri target open_url openurl website destination",
73
+ "data": "data payload submit_data params parameters body value", "action": "action type kind on_click onclick click behavior action_type",
74
+ "style": "style tone variant color appearance intent importance priority", "icon": "icon emoji iconurl"}.items() for t in _keys(words)}
75
+ TONE_WORDS = {
76
+ "danger": "danger error red critical bad negative failed fail failure rejected blocked overdue urgent offline down outage cancelled canceled lost expired declined denied breach destructive alert attention offtrack",
77
+ "warning": "warning warn yellow orange amber caution pending review inreview awaiting waiting atrisk risk degraded delayed partial expiring due onhold hold behind",
78
+ "success": "success successful good green positive ok okay done complete completed approved active passed pass paid resolved shipped delivered live healthy online available enabled won confirmed accepted ontrack ahead",
79
+ "info": "info information informative blue accent primary new inprogress progress open running scheduled planned processing started ongoing",
80
+ "neutral": "neutral default gray grey secondary muted subtle closed archived draft inactive paused none unknown notstarted",
81
+ }
82
+ TONE_LOOKUP = {w: tone for tone, words in TONE_WORDS.items() for w in words.split()}
83
+ TONE_NAMES = _keys("success successful good warning warn danger error info information informative neutral default red green "
84
+ "yellow orange amber blue gray grey accent attention positive negative critical primary secondary muted subtle")
85
+ SUBMIT_WORDS = _keys("submit actionsubmit execute actionexecute postback imback messageback send save")
86
+ LINK_WORDS = _keys("openurl actionopenurl link url navigate open href redirect website")
87
+ NUMERIC_CELL = re.compile(r"[(~+\-−]?\s*[$€£¥₹]?\s*\d[\d.,' ]*\s*(%|[kKmMbB]n?|[A-Za-z]{1,3})?\)?")
88
+ MIME = {".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
89
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".aac": "audio/aac"}
90
+
91
+
92
+ def tone_of(value, fallback_text: str = "") -> str:
93
+ for candidate in (value, fallback_text):
94
+ if isinstance(candidate, str) and candidate.strip():
95
+ t = token(candidate)
96
+ if t in TONE_LOOKUP:
97
+ return TONE_LOOKUP[t]
98
+ words = [token(w) for w in re.findall(r"[A-Za-z]+", candidate)]
99
+ for tone in ("danger", "warning", "success", "info"):
100
+ if any(TONE_LOOKUP.get(w) == tone for w in words):
101
+ return tone
102
+ return "neutral"
103
+
104
+
105
+ def _is_input(card_element: dict) -> bool:
106
+ return str(card_element.get("type", "")).startswith("Input.")
107
+
108
+
109
+ class CardBuilder:
110
+ def __init__(self, config: BuilderConfig):
111
+ self.config = config
112
+ self.ctx = Context(config)
113
+ self.input_ids: list[str] = []
114
+ self.used_ids: set[str] = set()
115
+ self.reserved_ids: set[str] = set()
116
+ self.choice_values: dict[str, list[dict]] = {}
117
+ self.element_count = 0
118
+ self.column_depth = 0
119
+ self.min_width, self.floor_percent, self.force_overflow = 320, 50, False
120
+ self.width_reasons: list[str] = []
121
+
122
+ # ------------------------------------------------------------ helpers
123
+
124
+ @property
125
+ def native(self) -> bool:
126
+ return self.config.profile == "microsoft"
127
+
128
+ def native_ok(self, name: str) -> bool:
129
+ return self.native and (self.config.native_types is None or name in self.config.native_types)
130
+
131
+ def extension(self, native: dict, fallback: dict, path: str) -> dict:
132
+ if self.native_ok(native["type"]):
133
+ return {**native, "fallback": fallback}
134
+ return fallback
135
+
136
+ def need(self, pixels: int, percent: int, reason: str, *, overflow=False):
137
+ self.min_width = max(self.min_width, pixels)
138
+ self.floor_percent = max(self.floor_percent, percent)
139
+ self.force_overflow |= overflow
140
+ if reason not in self.width_reasons:
141
+ self.width_reasons.append(reason)
142
+
143
+ def title_prefix(self, node: Node) -> list[dict]:
144
+ title = clean_text(node.f.get("title"))
145
+ return [theme.group_title(self.with_icon(title, node))] if title else []
146
+
147
+ def with_icon(self, text: str, node: Node) -> str:
148
+ icon = node.hints.get("icon")
149
+ if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.strip().isascii():
150
+ return f"{icon.strip()} {text}"
151
+ return text
152
+
153
+ # ------------------------------------------------------------ document
154
+
155
+ def unwrap(self, raw, path="$"):
156
+ for _ in range(5):
157
+ if isinstance(raw, str) and raw.strip()[:1] in "[{":
158
+ raw = parse_json_text(raw, self.ctx, path)
159
+ continue
160
+ if isinstance(raw, dict):
161
+ keys = {token(k): k for k in raw}
162
+ carrier = next((keys[t] for t in ("arguments", "args", "parameters", "params", "input") if t in keys), None)
163
+ if carrier and set(keys) - {token(carrier)} <= {"name", "tool", "function", "id", "toolname", "type"}:
164
+ raw = raw[carrier]
165
+ self.ctx.fixed(path, "unwrapped_tool_call", key=carrier)
166
+ continue
167
+ if len(raw) == 1:
168
+ (k, v), = raw.items()
169
+ if token(k) in WRAPPER_KEYS and (isinstance(v, (dict, list)) or isinstance(v, str) and v.strip()[:1] in "[{"):
170
+ raw = v
171
+ self.ctx.fixed(path, "unwrapped", key=k)
172
+ continue
173
+ break
174
+ return raw
175
+
176
+ def document(self, raw) -> dict:
177
+ raw = self.unwrap(raw)
178
+ if isinstance(raw, list):
179
+ return {"elements": raw}
180
+ if not isinstance(raw, dict):
181
+ return {"elements": [raw]}
182
+ type_key = next((k for k in raw if token(k) in TYPE_KEYS and isinstance(raw[k], str)), None)
183
+ if type_key is not None:
184
+ resolved = resolve_type(raw[type_key], Context(self.config), "$." + type_key)
185
+ if resolved and resolved[0] in {"document", "section"}:
186
+ self.ctx.fixed("$." + type_key, "type_alias", source=raw[type_key], target="document")
187
+ return self.root_fields({k: v for k, v in raw.items() if k != type_key})
188
+ if resolved or not any(token(k) in ROOT_FIELDS["elements"] for k in raw):
189
+ return self.single_element(raw, resolved[0] if resolved else None)
190
+ elements_key = next((k for k, v in raw.items() if token(k) in ROOT_FIELDS["elements"] and isinstance(v, (list, dict)) and v), None)
191
+ if elements_key and not (token(elements_key) == "items" and isinstance(raw[elements_key], list)
192
+ and all(isinstance(i, (str, int, float)) for i in raw[elements_key])):
193
+ return self.root_fields(raw)
194
+ inferred = infer_type(raw)
195
+ if inferred and inferred[0] not in {"text", "heading", "section"}:
196
+ # Only a single element if every key belongs to it (or to the card header).
197
+ own = FIELD_ALIASES[inferred[0]]
198
+ if all(token(k) in own or token(k) in ROOT_ALIAS and ROOT_ALIAS[token(k)] != "elements" or token(k) in STYLE_KEYS for k in raw):
199
+ return self.single_element(raw, inferred[0])
200
+ return self.root_fields(raw)
201
+
202
+ def single_element(self, raw: dict, canonical: str | None) -> dict:
203
+ """A lone element sent as the whole request; card-level keys move to the card."""
204
+ doc = {"elements": []}
205
+ element = {}
206
+ spec = SPECS.get(canonical) if canonical else None
207
+ label_like = sum(1 for k in raw if token(k) in _keys("label question prompt text"))
208
+ for k, v in raw.items():
209
+ field = ROOT_ALIAS.get(token(k))
210
+ if field in {"subtitle", "submit_label"} or (field == "status" and canonical != "badge"):
211
+ doc[field] = v
212
+ elif token(k) == "title" and spec and "title" not in doc and (
213
+ spec.kind == "display" and canonical not in {"text", "heading", "note", "badge"}
214
+ or spec.kind == "input" and label_like):
215
+ doc["title"] = v
216
+ else:
217
+ element[k] = v
218
+ doc["elements"].append(element)
219
+ return doc
220
+
221
+ def root_fields(self, raw: dict) -> dict:
222
+ doc = {"elements": []}
223
+ pending_facts: dict = {}
224
+
225
+ def flush():
226
+ if pending_facts:
227
+ doc["elements"].append({"type": "facts", "facts": dict(pending_facts)})
228
+ pending_facts.clear()
229
+
230
+ for key, value in raw.items():
231
+ t = token(key)
232
+ field = ROOT_ALIAS.get(t)
233
+ if field is None and t not in STYLE_KEYS and t not in {"schema", "version", "type"}:
234
+ near = closest(t, ROOT_ALIAS, self.ctx)
235
+ field = ROOT_ALIAS.get(near) if near and len(t) >= 6 else None
236
+ if field == "buttons" and value == []:
237
+ doc.setdefault("buttons", [])
238
+ continue
239
+ if field is not None and field != "elements" and value in (None, "", [], {}):
240
+ continue
241
+ if field == "elements":
242
+ flush()
243
+ if isinstance(value, dict):
244
+ value = list(value.values()) if all(isinstance(v, dict) for v in value.values()) else [value]
245
+ doc["elements"].extend(value if isinstance(value, list) else [value])
246
+ elif field == "buttons" or (field == "submit_label" and isinstance(value, (dict, list))):
247
+ doc.setdefault("buttons", []).extend(value if isinstance(value, list) else [value])
248
+ elif field in {"title", "subtitle", "status", "submit_label"}:
249
+ if field in doc:
250
+ doc["elements"].append({"type": "text", "text": display_text(value)})
251
+ else:
252
+ doc[field] = value
253
+ elif t in STYLE_KEYS or t in {"schema", "version", "type", "id"}:
254
+ self.ctx.fixed("$." + key, "ignored_style_key")
255
+ else:
256
+ # A root key named after an element: {"chart": {...}}, {"footer": "..."}, {"logo": "https://..."}.
257
+ resolved = resolve_type(key, Context(self.config), "$." + key)
258
+ scalar = isinstance(value, (str, int, float, bool))
259
+ if resolved and resolved[0] not in {"document", "column"} and not (scalar and SPECS[resolved[0]].kind == "input"):
260
+ flush()
261
+ canonical = resolved[0]
262
+ flat = isinstance(value, dict) and all(not isinstance(v, (dict, list)) for v in value.values())
263
+ if isinstance(value, dict) and any(token(k) in TYPE_KEYS for k in value):
264
+ element = value
265
+ elif isinstance(value, dict) and not (flat and canonical in {"facts", "chart", "metrics", "table"}):
266
+ element = {"type": key, **value}
267
+ else:
268
+ element = {"type": key, SPECS[canonical].main or "text": value}
269
+ doc["elements"].append(element)
270
+ self.ctx.fixed("$." + key, "root_key_as_element", target=canonical)
271
+ elif scalar or (isinstance(value, list) and all(isinstance(v, (str, int, float)) for v in value)):
272
+ pending_facts[humanize(key)] = value
273
+ self.ctx.fixed("$." + key, "root_value_as_fact")
274
+ elif isinstance(value, (dict, list)) and value:
275
+ flush()
276
+ doc["elements"].append(value if isinstance(value, dict) else {"type": "section", "elements": value})
277
+ self.ctx.fixed("$." + key, "root_key_as_element")
278
+ flush()
279
+ return doc
280
+
281
+ # ------------------------------------------------------------ canonical nodes
282
+
283
+ def canonicalize(self, raw, path: str) -> list[Node]:
284
+ if raw is None or raw == "" or raw == {} or raw == []:
285
+ return []
286
+ if isinstance(raw, (str, int, float, bool)):
287
+ return self.markdown_nodes(display_text(raw), path)
288
+ if isinstance(raw, list):
289
+ return [Node("section", {"elements": raw}, path)]
290
+ type_keys = [k for k in raw if token(k) in TYPE_KEYS and isinstance(raw[k], str)]
291
+ type_keys.sort(key=lambda k: token(k) != "type")
292
+ resolved, source = None, ""
293
+ for k in type_keys:
294
+ resolved = resolve_type(raw[k], self.ctx, f"{path}.{k}")
295
+ source = token(raw[k])
296
+ if resolved:
297
+ break
298
+ if not resolved:
299
+ resolved = infer_type({k: v for k, v in raw.items() if k not in type_keys})
300
+ if resolved and type_keys:
301
+ self.ctx.warn(path, "unknown_type", f"Unknown type '{raw[type_keys[0]]}'; shown as {resolved[0]} based on its fields.")
302
+ elif resolved:
303
+ self.ctx.fixed(path, "type_inferred", target=resolved[0])
304
+ if not resolved:
305
+ texts = [clean_text(v) for k, v in raw.items()
306
+ if k not in type_keys and (token(k) in CONTENT_KEYS or token(k) in {"title", "label", "name", "value"})]
307
+ texts = [t for t in texts if t]
308
+ if texts:
309
+ self.ctx.warn(path, "unknown_element", "Could not tell what this element is; its text is shown as a paragraph.")
310
+ return [Node("text", {"text": "\n\n".join(texts)}, path)]
311
+ self.ctx.warn(path, "dropped_element", "Could not tell what this element is, so it was left out.")
312
+ return []
313
+ canonical, implied = resolved
314
+ canonical, implied = self.refine(canonical, dict(implied), raw, source)
315
+ if canonical == "document":
316
+ inner = self.root_fields({k: v for k, v in raw.items() if k not in type_keys})
317
+ title = inner.get("title")
318
+ return [Node("section", {"title": title, "elements": inner["elements"] + ([{"type": "buttons", "buttons": inner["buttons"]}] if inner.get("buttons") else [])}, path)]
319
+ fields = map_fields(canonical, raw, path, self.ctx, skip=type_keys)
320
+ values = {**implied, **fields.values}
321
+ return [Node(canonical, values, path, fields.hints, fields.extras, fields.unknown, source)]
322
+
323
+ def refine(self, canonical: str, implied: dict, raw: dict, source: str) -> tuple[str, dict]:
324
+ """Correct a type using the fields that are actually present."""
325
+ keys = {token(k) for k in raw}
326
+ kind = SPECS[canonical].kind
327
+ if kind == "input" and canonical != "choice" and keys & {"options", "choices"}:
328
+ return "choice", ({"multiple": True} if canonical == "checkbox" else {})
329
+ if canonical in {"text", "note", "heading"}:
330
+ has_text = keys & {"text", "content", "body", "value", "message", "paragraph", "markdown"}
331
+ if not has_text and keys & {"placeholder", "required", "isrequired", "maxlength", "multiline", "ismultiline"}:
332
+ return "text_input", {}
333
+ if not has_text and "items" in keys:
334
+ return "list", implied
335
+ if canonical == "chart" and not keys & {"data", "series", "datasets", "values", "value", "points"} and keys & {"rows", "records"}:
336
+ return "table", {}
337
+ if canonical in {"columns", "list", "facts"} and "rows" in keys and canonical != "facts":
338
+ return "table", {}
339
+ if canonical == "table" and "columns" in keys and not keys & {"rows", "data", "records", "items", "entries"}:
340
+ return "columns", {}
341
+ if canonical == "image" and keys & {"images", "urls", "gallery"}:
342
+ return "images", {}
343
+ if canonical == "section" and "columns" in keys and not keys & {"elements", "items", "body", "children"}:
344
+ return "columns", {}
345
+ if canonical == "chart" and source == "line" and not keys & {"data", "series", "datasets", "values", "points", "value"}:
346
+ return "divider", {}
347
+ return canonical, implied
348
+
349
+ def markdown_nodes(self, text: str, path: str) -> list[Node]:
350
+ """Split markdown-ish text into headings, lists, tables and paragraphs."""
351
+ if "\n" not in text and not re.match(r"\s*#{1,6}\s", text):
352
+ return [Node("text", {"text": text}, path)]
353
+ nodes, para, bullets, numbered, table_lines = [], [], [], False, []
354
+
355
+ def flush_para():
356
+ if para:
357
+ joined = "\n".join(para).strip()
358
+ if joined:
359
+ nodes.append(Node("text", {"text": joined}, path))
360
+ para.clear()
361
+
362
+ def flush_list():
363
+ nonlocal numbered
364
+ if bullets:
365
+ nodes.append(Node("list", {"items": list(bullets), "numbered": numbered}, path))
366
+ bullets.clear()
367
+ numbered = False
368
+
369
+ def flush_table():
370
+ if table_lines:
371
+ rows = [[c.strip() for c in line.strip().strip("|").split("|")] for line in table_lines
372
+ if not re.fullmatch(r"\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*", line)]
373
+ if len(rows) >= 2:
374
+ nodes.append(Node("table", {"columns": rows[0], "rows": rows[1:]}, path))
375
+ else:
376
+ para.extend(table_lines)
377
+ table_lines.clear()
378
+
379
+ for line in text.splitlines():
380
+ heading = re.match(r"\s*#{1,6}\s+(.*)", line)
381
+ bullet = re.match(r"\s*[-*•]\s+(.*)", line)
382
+ number = re.match(r"\s*\d{1,3}[.)]\s+(.*)", line)
383
+ if line.strip().startswith("|") and line.count("|") >= 2:
384
+ flush_para(), flush_list()
385
+ table_lines.append(line)
386
+ continue
387
+ flush_table()
388
+ if heading:
389
+ flush_para(), flush_list()
390
+ nodes.append(Node("heading", {"text": heading.group(1).strip().strip("#").strip()}, path))
391
+ elif bullet or number:
392
+ flush_para()
393
+ if bullets and bool(number) != numbered:
394
+ flush_list()
395
+ numbered = bool(number)
396
+ bullets.append((bullet or number).group(1))
397
+ elif not line.strip():
398
+ flush_para(), flush_list()
399
+ else:
400
+ flush_list()
401
+ para.append(line)
402
+ flush_table(), flush_para(), flush_list()
403
+ if len(nodes) > 1 or (nodes and nodes[0].type != "text"):
404
+ self.ctx.fixed(path, "markdown_structure")
405
+ return nodes
406
+
407
+ def merge(self, nodes: list[Node]) -> list[Node]:
408
+ """Combine adjacent single metrics, buttons, badges and columns into one row."""
409
+ out: list[Node] = []
410
+ for node in nodes:
411
+ prev = out[-1] if out else None
412
+ if node.type == "metrics" and "items" not in node.f:
413
+ single = dict(node.f)
414
+ if "label" not in single and "title" in single:
415
+ single["label"] = single.pop("title")
416
+ single.pop("title", None)
417
+ if "icon" in node.hints:
418
+ single["icon"] = node.hints["icon"]
419
+ node.f = {"items": [single], "_single": True}
420
+ if node.type == "column":
421
+ node = Node("columns", {"columns": [{"title": node.f.get("title"), "elements": node.f.get("elements", [])}], "_single": True}, node.path)
422
+ if prev and prev.type == node.type and prev.f.get("_single") and node.f.get("_single") and "title" not in node.f:
423
+ if node.type == "metrics":
424
+ prev.f["items"].extend(node.f["items"])
425
+ continue
426
+ if node.type == "columns":
427
+ prev.f["columns"].extend(node.f["columns"])
428
+ continue
429
+ if prev and node.type == "buttons" and prev.type == "buttons" and not node.extras and not prev.extras:
430
+ prev.f.setdefault("_merged", []).append(node)
431
+ continue
432
+ if prev and node.type == "badge" and prev.type == "badge" and not node.extras and not prev.extras:
433
+ prev.f.setdefault("_merged", []).append(node)
434
+ continue
435
+ out.append(node)
436
+ for node in out:
437
+ if node.type == "columns" and node.f.get("_single") and len(node.f["columns"]) == 1:
438
+ node.type, node.f = "section", {"title": node.f["columns"][0].get("title"), "elements": node.f["columns"][0].get("elements", [])}
439
+ return out
440
+
441
+ # ------------------------------------------------------------ rendering
442
+
443
+ def render_list(self, raw_items, path: str, depth: int) -> list[dict]:
444
+ if depth > self.config.max_depth:
445
+ raise CardError(path, "Nesting limit exceeded.", "limit_exceeded")
446
+ if isinstance(raw_items, dict):
447
+ raw_items = [raw_items]
448
+ elif not isinstance(raw_items, list):
449
+ raw_items = [raw_items]
450
+ nodes = []
451
+ for i, item in enumerate(raw_items):
452
+ nodes.extend(self.canonicalize(item, f"{path}[{i}]"))
453
+ out: list[dict] = []
454
+ divider = False
455
+ for node in self.merge(nodes):
456
+ if node.type == "divider":
457
+ divider = bool(out)
458
+ continue
459
+ rendered = self.render(node, depth)
460
+ if rendered and divider:
461
+ rendered[0] = {**rendered[0], "separator": True, "spacing": "Large"}
462
+ divider = divider and not rendered
463
+ out.extend(rendered)
464
+ return out
465
+
466
+ def render(self, node: Node, depth: int) -> list[dict]:
467
+ self.element_count += 1
468
+ if self.element_count > self.config.max_elements:
469
+ raise CardError(node.path, f"More than {self.config.max_elements} elements; split the content into several cards.", "limit_exceeded")
470
+ out = getattr(self, "r_" + node.type)(node, depth)
471
+ if out:
472
+ out.extend(self.extra_blocks(node))
473
+ for key, _ in node.unknown:
474
+ self.ctx.warn(f"{node.path}.{key}", "ignored_field", f"Field '{key}' is not used by {node.type} and was left out.")
475
+ return out
476
+
477
+ def extra_blocks(self, node: Node) -> list[dict]:
478
+ blocks = []
479
+ for key, value in node.extras:
480
+ content = clean_text(value)
481
+ if not content:
482
+ continue
483
+ long_form = token(key) in _keys("description desc body content message text details detail summary explanation context instructions")
484
+ blocks.append(theme.body(content) if long_form or len(content) > 160 else theme.note(content))
485
+ self.ctx.fixed(f"{node.path}.{key}", "shown_as_text")
486
+ if blocks and node.type in {"heading"}:
487
+ blocks[0] = {**blocks[0], "spacing": "None"}
488
+ return blocks
489
+
490
+ def text_of(self, node: Node, *fallbacks: str) -> str:
491
+ for name in ("text", *fallbacks):
492
+ value = node.f.get(name)
493
+ if isinstance(value, list) and value and all(isinstance(v, dict) for v in value):
494
+ # RichTextBlock inlines / TextRuns.
495
+ runs = []
496
+ for run in value:
497
+ _, piece = pick(run, _keys("text content value"))
498
+ piece = display_text(piece)
499
+ bold = token(run.get("weight", "")) == "bolder" and piece.strip()
500
+ runs.append(f"**{piece}**" if bold else piece)
501
+ value = "".join(runs)
502
+ value = clean_text(value)
503
+ if value:
504
+ return value
505
+ if node.extras:
506
+ key, value = node.extras.pop(0)
507
+ return clean_text(value)
508
+ return ""
509
+
510
+ def r_heading(self, node: Node, depth: int) -> list[dict]:
511
+ content = self.text_of(node, "title", "label")
512
+ if not content:
513
+ self.ctx.warn(node.path, "empty_element", "Heading had no text and was left out.")
514
+ return []
515
+ return [theme.spaced(theme.heading(self.with_icon(content, node)), "Large")]
516
+
517
+ def r_text(self, node: Node, depth: int) -> list[dict]:
518
+ title = clean_text(node.f.get("title"))
519
+ content = self.text_of(node, "label")
520
+ if not content:
521
+ if title:
522
+ return [theme.body(title)]
523
+ self.ctx.warn(node.path, "empty_element", "Text element had no text and was left out.")
524
+ return []
525
+ prefix = [theme.group_title(title)] if title else []
526
+ size, weight = token(node.hints.get("size", "")), token(node.hints.get("weight", ""))
527
+ if "\n" in content and re.search(r"(^|\n)\s*(#{1,6}\s|\|.*\|)", content):
528
+ parts = self.markdown_nodes(content, node.path)
529
+ return prefix + [b for part in parts for b in self.render(part, depth)]
530
+ if (size in {"large", "extralarge", "xl", "xxl", "big", "huge", "medium"} or weight in {"bolder", "bold"}
531
+ or node.hints.get("style") == "heading" or node.source in {"h1", "h2", "h3"}) and len(content) <= 120 and "\n" not in content:
532
+ self.ctx.fixed(node.path, "styled_text_as_heading")
533
+ return prefix + [theme.spaced(theme.heading(self.with_icon(content, node)), "Large")]
534
+ if size in {"small", "tiny", "xs"} or as_bool(node.hints.get("issubtle"), False) or token(node.hints.get("color", "")) in {"gray", "grey", "muted", "subtle", "secondary"}:
535
+ return prefix + [theme.note(content)]
536
+ self.need(min(640, max(320, len(content) * 4)), 50, "Text wraps within the card.")
537
+ return prefix + [theme.body(self.with_icon(content, node))]
538
+
539
+ def r_note(self, node: Node, depth: int) -> list[dict]:
540
+ content = self.text_of(node, "title", "label")
541
+ return [theme.note(content)] if content else []
542
+
543
+ # -- lists, facts, tables
544
+
545
+ def list_items(self, raw) -> list[str]:
546
+ if isinstance(raw, str):
547
+ raw = [line for line in raw.splitlines() if line.strip()] if "\n" in raw else [raw]
548
+ if isinstance(raw, dict):
549
+ return [f"**{k}**: {display_text(v)}" for k, v in raw.items()]
550
+ items = []
551
+ for item in raw if isinstance(raw, list) else [raw]:
552
+ if isinstance(item, dict):
553
+ _, head = pick(item, _keys("title label name heading text"))
554
+ _, detail = pick(item, _keys("description detail details text body value summary"))
555
+ if head is not None and detail is not None and head is not detail:
556
+ items.append(f"**{display_text(head)}**: {display_text(detail)}")
557
+ continue
558
+ item = head if head is not None else detail if detail is not None else item
559
+ text = clean_text(item)
560
+ if text:
561
+ items.append(text)
562
+ return items
563
+
564
+ def r_list(self, node: Node, depth: int) -> list[dict]:
565
+ items = self.list_items(node.f.get("items", []))
566
+ if not items:
567
+ self.ctx.warn(node.path, "empty_element", "List had no items and was left out.")
568
+ return []
569
+ numbered = as_bool(node.f.get("numbered"), False)
570
+ stripped = [re.sub(r"^\s*(?:[-*•]|\d{1,3}[.)])\s+", "", i) for i in items]
571
+ if stripped != items:
572
+ numbered = numbered or all(re.match(r"^\s*\d{1,3}[.)]\s", i) for i in items)
573
+ self.ctx.fixed(node.path, "removed_list_markers")
574
+ width = 12 if not numbered else 20 if len(stripped) < 10 else 28
575
+ rows = [theme.bullet_row(f"{i}." if numbered else "•", item, width) for i, item in enumerate(stripped, 1)]
576
+ rows[0] = {**rows[0], "spacing": "Default"}
577
+ self.need(min(680, max(320, max(len(i) for i in stripped) * 4)), 50, "Text wraps within the card.")
578
+ return self.title_prefix(node) + rows
579
+
580
+ def fact_pairs(self, raw, path: str) -> list[tuple[str, str]] | None:
581
+ """Label/value pairs from many shapes. None means 'this is really a table'."""
582
+ if isinstance(raw, str):
583
+ raw = [line for line in raw.splitlines() if line.strip()]
584
+ pairs = []
585
+ if isinstance(raw, dict):
586
+ for k, v in raw.items():
587
+ pairs.append((display_text(k), clean_text(v)))
588
+ return pairs
589
+ if not isinstance(raw, list):
590
+ return [("", clean_text(raw))]
591
+ dict_items = [i for i in raw if isinstance(i, dict)]
592
+ if dict_items and len(dict_items) == len(raw) and len(raw) > 1:
593
+ shared = set(dict_items[0])
594
+ if len(shared) >= 2 and all(set(d) == shared for d in dict_items) and not {token(k) for k in shared} & LABEL_KEYS:
595
+ return None
596
+ for item in raw:
597
+ if isinstance(item, dict):
598
+ if len(item) == 1:
599
+ (k, v), = item.items()
600
+ pairs.append((display_text(k), clean_text(v)))
601
+ continue
602
+ lk, label = pick(item, LABEL_KEYS)
603
+ rest = {k: v for k, v in item.items() if k != lk}
604
+ vk, value = pick(rest, VALUE_KEYS)
605
+ if label is not None and vk is not None:
606
+ pairs.append((display_text(label), clean_text(value)))
607
+ else:
608
+ pairs.extend((humanize(k), clean_text(v)) for k, v in item.items())
609
+ elif isinstance(item, list) and len(item) == 2:
610
+ pairs.append((display_text(item[0]), clean_text(item[1])))
611
+ else:
612
+ text = clean_text(item)
613
+ m = re.match(r"\s*([^:=\t]{1,80}?)\s*(?::|=|\t| - | – )\s*(.+)", text, re.S)
614
+ pairs.append((m.group(1), m.group(2)) if m else ("", text))
615
+ return pairs
616
+
617
+ def r_facts(self, node: Node, depth: int) -> list[dict]:
618
+ raw = node.f.get("facts")
619
+ inline = [(humanize(k), clean_text(v)) for k, v in node.unknown if not isinstance(v, (dict, list))]
620
+ if inline:
621
+ # {"type": "facts", "Owner": "Dana"}: extra scalar keys on a facts element are facts.
622
+ node.unknown = [(k, v) for k, v in node.unknown if isinstance(v, (dict, list))]
623
+ self.ctx.fixed(node.path, "fields_as_facts")
624
+ pairs = self.fact_pairs(raw, node.path) if raw is not None else []
625
+ if pairs is not None:
626
+ pairs = pairs + inline
627
+ if pairs is None:
628
+ self.ctx.fixed(node.path, "facts_as_table")
629
+ return self.r_table(Node("table", {"rows": raw, "title": node.f.get("title")}, node.path), depth)
630
+ if pairs and all(not label for label, _ in pairs):
631
+ return self.r_list(Node("list", {"items": [v for _, v in pairs], "title": node.f.get("title")}, node.path), depth)
632
+ pairs = [(label, value or "—") for label, value in pairs if label or value]
633
+ if not pairs:
634
+ self.ctx.warn(node.path, "empty_element", "Facts had no entries and were left out.")
635
+ return []
636
+ longest = max(len(a) + len(b) for a, b in pairs)
637
+ self.need(min(720, max(400, longest * 7)), 50, "Fact labels and values share a row.")
638
+ return self.title_prefix(node) + [theme.spaced({"type": "FactSet", "facts": [{"title": a, "value": b} for a, b in pairs]}, "Small")]
639
+
640
+ def cell_text(self, cell) -> str:
641
+ if isinstance(cell, dict):
642
+ items = next((v for k, v in cell.items() if token(k) in {"items", "elements", "content"}), None)
643
+ if isinstance(items, list):
644
+ return " ".join(clean_text(self.cell_text(i)) for i in items).strip() or "—"
645
+ _, value = pick(cell, _keys("text value title label"))
646
+ if value is not None:
647
+ return clean_text(value) or "—"
648
+ return clean_text(cell) or "—"
649
+
650
+ def table_data(self, node: Node):
651
+ rows = node.f.get("rows")
652
+ columns = node.f.get("columns")
653
+ header_labels = {}
654
+ if isinstance(columns, str):
655
+ columns = [c.strip() for c in re.split(r"[,|]", columns) if c.strip()]
656
+ if isinstance(columns, list):
657
+ normalized = []
658
+ for c in columns:
659
+ if isinstance(c, dict):
660
+ kk, key = pick(c, _keys("key field id name accessor datakey data_index"))
661
+ _, label = pick(c, _keys("title label header heading text display"))
662
+ key = display_text(key if key is not None else label)
663
+ header_labels[key] = display_text(label if label is not None else key)
664
+ normalized.append(key)
665
+ else:
666
+ normalized.append(display_text(c))
667
+ columns = normalized
668
+ elif columns is not None:
669
+ columns = None
670
+ if isinstance(rows, str):
671
+ rows = [[c.strip() for c in re.split(r"\t|\|", line) if c.strip()] for line in rows.splitlines() if line.strip()]
672
+ if isinstance(rows, dict):
673
+ values = list(rows.values())
674
+ if values and all(isinstance(v, list) for v in values) and len({len(v) for v in values}) == 1:
675
+ columns = columns or list(rows)
676
+ rows = [dict(zip(rows, r)) for r in zip(*values)]
677
+ self.ctx.fixed(node.path, "columnar_table_transposed")
678
+ elif values and all(isinstance(v, dict) for v in values):
679
+ rows = [{"Item": k, **v} for k, v in rows.items()]
680
+ self.ctx.fixed(node.path, "keyed_rows")
681
+ else:
682
+ rows = [{"Item": k, "Value": v} for k, v in rows.items()]
683
+ if not isinstance(rows, list) or not rows:
684
+ return None, None
685
+ if all(isinstance(r, dict) and any(token(k) == "cells" for k in r) for r in rows):
686
+ rows = [[self.cell_text(c) for c in next(v for k, v in r.items() if token(k) == "cells")] for r in rows]
687
+ if columns is None and len(rows) > 1 and as_bool(node.hints.get("firstrowasheader"), True):
688
+ columns, rows = rows[0], rows[1:]
689
+ if all(isinstance(r, dict) for r in rows):
690
+ keys = list(dict.fromkeys(k for r in rows for k in r))
691
+ headers = list(columns or []) + [k for k in keys if k not in (columns or [])]
692
+ data = [[self.cell_text(r.get(h)) for h in headers] for r in rows]
693
+ elif all(isinstance(r, list) for r in rows):
694
+ width = max(len(r) for r in rows)
695
+ if columns is None:
696
+ if len(rows) > 1 and all(isinstance(v, str) for v in rows[0]) and len(rows[0]) == width:
697
+ columns, rows = rows[0], rows[1:]
698
+ self.ctx.fixed(node.path, "first_row_as_header")
699
+ else:
700
+ columns = []
701
+ headers = list(columns) + [f"Column {i + 1}" for i in range(len(columns), width)]
702
+ data = [[self.cell_text(v) for v in r] + ["—"] * (len(headers) - len(r)) for r in rows]
703
+ if any(len(r) != width for r in rows):
704
+ self.ctx.fixed(node.path, "padded_short_rows")
705
+ else:
706
+ if all(not isinstance(r, (dict, list)) for r in rows):
707
+ return "list", rows
708
+ headers = list(columns or ["Value"])
709
+ data = [[self.cell_text(r)] if not isinstance(r, list) else [self.cell_text(v) for v in r] for r in rows]
710
+ width = max(len(r) for r in data)
711
+ headers += [f"Column {i + 1}" for i in range(len(headers), width)]
712
+ data = [r + ["—"] * (len(headers) - len(r)) for r in data]
713
+ headers = [header_labels.get(h, humanize(display_text(h)) if isinstance(h, str) and "_" in h else display_text(h)) or "—" for h in headers]
714
+ return headers, data
715
+
716
+ def r_table(self, node: Node, depth: int) -> list[dict]:
717
+ headers, data = self.table_data(node)
718
+ if headers == "list":
719
+ return self.r_list(Node("list", {"items": data, "title": node.f.get("title")}, node.path), depth)
720
+ if not headers or not data:
721
+ self.ctx.warn(node.path, "empty_element", "Table had no rows and was left out.")
722
+ return []
723
+ numeric = [all(NUMERIC_CELL.fullmatch(r[i].strip()) for r in data if r[i] != "—") and any(r[i] != "—" for r in data)
724
+ for i in range(len(headers))]
725
+ lengths = [max(len(h), *(len(r[i]) for r in data)) for i, h in enumerate(headers)]
726
+ weights = [max(1, min(4, math.ceil(n / 12))) for n in lengths]
727
+ sizes = [max(96, min(240, n * 7 + 24)) for n in lengths]
728
+ self.need(sum(sizes) + 32, 50 if len(headers) <= 3 else 80 if len(headers) <= 5 else 100,
729
+ "Tables need enough width for each column; cell text wraps.")
730
+ return self.title_prefix(node) + [theme.spaced(theme.table(headers, data, numeric, weights), "Small")]
731
+
732
+ # -- metrics, badges, progress, ratings
733
+
734
+ def metric_items(self, raw) -> list[dict]:
735
+ items = []
736
+ if isinstance(raw, dict):
737
+ raw = [{"label": k, **(v if isinstance(v, dict) else {"value": v})} for k, v in raw.items()]
738
+ for item in raw if isinstance(raw, list) else [raw]:
739
+ if isinstance(item, str):
740
+ m = re.match(r"\s*(.+?)\s*[:=]\s*(.+)", item)
741
+ item = {"label": m.group(1), "value": m.group(2)} if m else {"value": item}
742
+ if not isinstance(item, dict):
743
+ item = {"value": item}
744
+ mapped = {}
745
+ for k, v in item.items():
746
+ name = METRIC_MAP.get(token(k))
747
+ if name and name not in mapped:
748
+ mapped[name] = v
749
+ elif not name and len(item) == 1:
750
+ mapped = {"label": k, "value": v}
751
+ items.append(mapped)
752
+ return items
753
+
754
+ def r_metrics(self, node: Node, depth: int) -> list[dict]:
755
+ items = self.metric_items(node.f.get("items", []))
756
+ tiles = []
757
+ for i, item in enumerate(items):
758
+ value = clean_text(item.get("value"))
759
+ label = clean_text(item.get("label"))
760
+ if not value and not label:
761
+ continue
762
+ change = clean_text(item.get("change"))
763
+ color = "Default"
764
+ if change:
765
+ direction = self.change_direction(change)
766
+ if direction:
767
+ lower_better = item.get("lower_is_better")
768
+ good = direction > 0
769
+ if as_bool(lower_better, False) or token(lower_better or "") in {"down", "lower", "decrease"}:
770
+ good = not good
771
+ color = "Good" if good else "Attention"
772
+ if not re.match(r"\s*[▲▼↑↓⬆⬇]", change):
773
+ change = f"{'▲' if direction > 0 else '▼'} {change.strip()}"
774
+ icon = item.get("icon")
775
+ if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.isascii():
776
+ label = f"{icon.strip()} {label}"
777
+ tiles.append(theme.metric_tile(label or " ", value or "—", change or None, color, clean_text(item.get("note")) or None))
778
+ if not tiles:
779
+ self.ctx.warn(node.path, "empty_element", "Metrics had no values and were left out.")
780
+ return []
781
+ per_row = min(4, len(tiles))
782
+ rows_needed = math.ceil(len(tiles) / per_row)
783
+ per_row = math.ceil(len(tiles) / rows_needed)
784
+ rows = [theme.row(*tiles[i:i + per_row], spacing="Medium") for i in range(0, len(tiles), per_row)]
785
+ self.need(min(1100, 200 * per_row + 40), 50 if per_row <= 2 else 60 if per_row == 3 else 80, "Metric tiles sit side by side.")
786
+ return self.title_prefix(node) + rows
787
+
788
+ @staticmethod
789
+ def change_direction(change: str) -> int:
790
+ t = change.strip()
791
+ if re.match(r"[+▲↑⬆]|up\b|increase|rise|higher|grew|gain", t, re.I):
792
+ return 1
793
+ if re.match(r"[-−–▼↓⬇]|down\b|decrease|drop|fall|lower|declin|loss", t, re.I):
794
+ return -1
795
+ number = as_number(t)
796
+ return 0 if not number else (1 if number > 0 else -1)
797
+
798
+ def badge_element(self, text: str, tone: str, path: str) -> dict:
799
+ _, style, _ = theme.TONES[tone]
800
+ return self.extension({"type": "Badge", "text": text, "style": style, "appearance": "tint"}, theme.badge(text, tone), path)
801
+
802
+ def r_badge(self, node: Node, depth: int) -> list[dict]:
803
+ badges = []
804
+ for item in [node, *node.f.get("_merged", [])]:
805
+ text = self.text_of(item, "title", "label")
806
+ tone_raw = item.f.get("tone")
807
+ if not text and isinstance(tone_raw, str) and token(tone_raw) not in TONE_NAMES:
808
+ # {"status": "Shipped"}: the status word is the badge text.
809
+ text = clean_text(tone_raw)
810
+ if not text:
811
+ continue
812
+ badges.append(self.badge_element(self.with_icon(text, item), tone_of(tone_raw, text), item.path))
813
+ if not badges:
814
+ return []
815
+ return [badges[0]] if len(badges) == 1 else [theme.badge_row(badges)]
816
+
817
+ def r_progress(self, node: Node, depth: int) -> list[dict]:
818
+ label = clean_text(node.f.get("label")) or clean_text(node.f.get("title")) or "Progress"
819
+ maximum = as_number(node.f.get("max"))
820
+ parsed = parse_number(node.f.get("value"))
821
+ if maximum is not None and maximum <= 0:
822
+ self.ctx.warn(node.path + ".max", "invalid_value", "max must be positive; 100 was used.")
823
+ maximum = None
824
+ value = None
825
+ if parsed is not None:
826
+ value = parsed.value
827
+ if parsed.denominator and maximum is None:
828
+ maximum = parsed.denominator
829
+ elif parsed.percent:
830
+ value = value / 100 * (maximum or 100)
831
+ elif maximum is None and 0 < value < 1 and not float(value).is_integer():
832
+ value *= 100
833
+ self.ctx.fixed(node.path + ".value", "fraction_as_percent")
834
+ elif node.f.get("value") not in (None, ""):
835
+ self.ctx.warn(node.path + ".value", "invalid_value", "Progress value was not a number; shown as in progress.")
836
+ maximum = maximum or 100
837
+ if value is None:
838
+ items = theme.progress(label, "In progress", None)
839
+ return [self.extension({"type": "ProgressBar", "max": maximum}, items[0], node.path)] if self.native_ok("ProgressBar") else items
840
+ fraction = max(0.0, value / maximum)
841
+ value_text = f"{format_number(round(value, 2))}%" if maximum == 100 else f"{format_number(value)} / {format_number(maximum)} ({fraction:.0%})"
842
+ color = "Good" if fraction >= 1 else "Accent"
843
+ items = theme.progress(label, value_text, min(1.0, fraction), color)
844
+ self.need(420, 50, "Progress displays need room for a label and indicator.")
845
+ if self.native_ok("ProgressBar"):
846
+ return [items[0], self.extension({"type": "ProgressBar", "value": min(max(value, 0), maximum), "max": maximum}, items[1], node.path)]
847
+ return items
848
+
849
+ def r_spinner(self, node: Node, depth: int) -> list[dict]:
850
+ label = clean_text(node.f.get("label")) or "Loading"
851
+ return [self.extension({"type": "ProgressRing", "label": label}, theme.note(label + "…"), node.path)]
852
+
853
+ def r_rating(self, node: Node, depth: int) -> list[dict]:
854
+ parsed = parse_number(node.f.get("value"))
855
+ if parsed is None:
856
+ self.ctx.warn(node.path + ".value", "invalid_value", "Rating value was not a number; rating left out.")
857
+ return []
858
+ value = parsed.value
859
+ maximum = as_number(node.f.get("max")) or parsed.denominator or (100 if parsed.percent else 5)
860
+ maximum = int(maximum) if float(maximum).is_integer() else math.ceil(maximum)
861
+ if value < 0 or value > maximum or maximum <= 0:
862
+ self.ctx.warn(node.path + ".value", "invalid_value", f"Rating {format_number(value)} is outside 0–{maximum}; shown as text.")
863
+ return self.title_prefix(node) + [theme.body(f"Rating: {display_text(node.f.get('value'))}")]
864
+ count = as_number(node.f.get("count"))
865
+ caption = f"{format_number(value)} / {maximum}"
866
+ if count is not None:
867
+ caption += f" · {format_number(count)} rating{'s' if count != 1 else ''}"
868
+ if maximum > 10:
869
+ fallback = theme.body(f"Rating: {caption}")
870
+ return self.title_prefix(node) + [fallback]
871
+ fallback = theme.stars(value, maximum, caption)
872
+ native = {"type": "Rating", "value": value, "max": maximum}
873
+ if count is not None and float(count).is_integer():
874
+ native["count"] = int(count)
875
+ content = self.extension(native, fallback, node.path) if maximum <= 5 else fallback
876
+ return self.title_prefix(node) + [content]
877
+
878
+ # -- media
879
+
880
+ def image_size(self, node: Node) -> str:
881
+ hint = token(node.f.get("size") or node.hints.get("size") or node.hints.get("width") or "")
882
+ if node.source in {"avatar", "icon"} or hint in {"small", "icon", "thumbnail", "avatar", "xs", "tiny", "sm"}:
883
+ return "Small"
884
+ if node.source == "logo" or hint in {"medium", "md", "logo"}:
885
+ return "Medium"
886
+ if node.source in {"hero", "banner"} or hint in {"stretch", "full", "fullwidth", "hero", "banner", "cover", "wide", "100", "100%"}:
887
+ return "Stretch"
888
+ if hint in {"large", "lg", "big", "xl"}:
889
+ return "Large"
890
+ if hint == "auto":
891
+ return "Auto"
892
+ return "Stretch" if self.column_depth else "Large"
893
+
894
+ def r_image(self, node: Node, depth: int) -> list[dict]:
895
+ raw = node.f.get("url")
896
+ link = as_url(raw)
897
+ caption = clean_text(node.f.get("caption"))
898
+ if not link:
899
+ if raw not in (None, ""):
900
+ self.ctx.warn(node.path + ".url", "invalid_url", "Image URL was not an absolute http(s) link; the image was left out.")
901
+ else:
902
+ self.ctx.warn(node.path, "missing_url", "Image had no URL and was left out.")
903
+ return self.title_prefix(node) + ([theme.note(caption)] if caption else [])
904
+ if raw != link:
905
+ self.ctx.fixed(node.path + ".url", "url_normalized")
906
+ image = {"type": "Image", "url": link, "size": self.image_size(node)}
907
+ alt = clean_text(node.f.get("alt")) or caption or clean_text(node.f.get("title"))
908
+ if alt:
909
+ image["altText"] = alt
910
+ shape = token(node.f.get("shape") or "")
911
+ if node.source == "avatar" or shape in {"person", "circle", "round", "rounded", "avatar", "profile", "circular"}:
912
+ image["style"] = "person"
913
+ self.need(420, 50, "Images use a fixed template size.")
914
+ return self.title_prefix(node) + [image] + ([theme.spaced(theme.note(caption), "Small")] if caption else [])
915
+
916
+ def r_images(self, node: Node, depth: int) -> list[dict]:
917
+ raw = node.f.get("images")
918
+ raw = raw if isinstance(raw, list) else [raw]
919
+ images = []
920
+ for i, item in enumerate(raw):
921
+ link = as_url(item)
922
+ if not link:
923
+ self.ctx.warn(f"{node.path}.images[{i}]", "invalid_url", "Image URL was not an absolute http(s) link; it was left out.")
924
+ continue
925
+ image = {"type": "Image", "url": link}
926
+ if isinstance(item, dict):
927
+ _, alt = pick(item, _keys("alt alt_text caption title description"))
928
+ if alt:
929
+ image["altText"] = clean_text(alt)
930
+ images.append(image)
931
+ if not images:
932
+ return []
933
+ self.need(min(960, len(images) * 180), 50 if len(images) < 3 else 80, "Image sets wrap to the available width.")
934
+ return self.title_prefix(node) + [{"type": "ImageSet", "imageSize": "Medium", "images": images}]
935
+
936
+ def r_media(self, node: Node, depth: int) -> list[dict]:
937
+ raw = node.f.get("sources")
938
+ raw = raw if isinstance(raw, list) else [raw]
939
+ sources, links = [], []
940
+ for i, item in enumerate(raw):
941
+ link = as_url(item)
942
+ if not link:
943
+ self.ctx.warn(f"{node.path}.sources[{i}]", "invalid_url", "Media URL was not an absolute http(s) link; it was left out.")
944
+ continue
945
+ links.append(link)
946
+ mime = None
947
+ if isinstance(item, dict):
948
+ _, mime = pick(item, _keys("mime_type mimetype mime content_type type"))
949
+ suffix = "." + link.split("?")[0].split("#")[0].rsplit(".", 1)[-1].lower() if "." in link.rsplit("/", 1)[-1] else ""
950
+ mime = mime if isinstance(mime, str) and re.fullmatch(r"(?:audio|video)/[a-z0-9.+-]+", mime.strip().lower()) else MIME.get(suffix)
951
+ if mime:
952
+ sources.append({"url": link, "mimeType": mime.strip().lower()})
953
+ if not links:
954
+ return []
955
+ title = clean_text(node.f.get("title"))
956
+ open_button = {"type": "ActionSet", "actions": [{"type": "Action.OpenUrl", "title": f"Open {'media' if not title else title}", "url": links[0]}]}
957
+ if not sources:
958
+ self.ctx.fixed(node.path, "media_as_link")
959
+ return self.title_prefix(node) + [open_button]
960
+ media = {"type": "Media", "sources": sources, "fallback": open_button}
961
+ poster = as_url(node.f.get("poster"))
962
+ if poster:
963
+ media["poster"] = poster
964
+ alt = clean_text(node.f.get("alt")) or title
965
+ if alt:
966
+ media["altText"] = alt
967
+ self.need(640, 60, "Media playback needs a larger viewport.")
968
+ return self.title_prefix(node) + [media]
969
+
970
+ # -- charts
971
+
972
+ def points(self, raw, labels=None) -> list[tuple[object, object]] | None:
973
+ """(label, raw value) pairs from a mapping, pair list, {label,value} list or value list."""
974
+ if isinstance(raw, dict):
975
+ return list(raw.items())
976
+ if not isinstance(raw, list):
977
+ return None
978
+ if all(not isinstance(v, (dict, list)) for v in raw):
979
+ if labels is not None and len(labels) == len(raw):
980
+ return list(zip(labels, raw))
981
+ if labels is None and all(as_number(v) is not None for v in raw):
982
+ return [(i + 1, v) for i, v in enumerate(raw)]
983
+ return None
984
+ out = []
985
+ for item in raw:
986
+ if isinstance(item, list) and len(item) == 2:
987
+ out.append((item[0], item[1]))
988
+ elif isinstance(item, dict):
989
+ if len(item) == 1:
990
+ out.append(next(iter(item.items())))
991
+ continue
992
+ lk, label = pick(item, POINT_LABEL_KEYS)
993
+ rest = {k: v for k, v in item.items() if k != lk}
994
+ _, value = pick(rest, _keys("value y count amount total number size score"))
995
+ if value is None and len(rest) == 1:
996
+ value = next(iter(rest.values()))
997
+ if label is None or value is None:
998
+ return None
999
+ out.append((label, value))
1000
+ else:
1001
+ return None
1002
+ return out
1003
+
1004
+ def chart_data(self, node: Node):
1005
+ """Returns (categories, [(series name, [raw values])]) or None."""
1006
+ f = node.f
1007
+ labels = f.get("labels")
1008
+ labels = labels if isinstance(labels, list) else None
1009
+ raw_series = f.get("series")
1010
+ data = f.get("data")
1011
+ if isinstance(data, dict) and any(token(k) == "datasets" for k in data):
1012
+ labels = next((v for k, v in data.items() if token(k) == "labels"), labels)
1013
+ raw_series, data = next(v for k, v in data.items() if token(k) == "datasets"), None
1014
+ if raw_series is None and isinstance(data, dict) and data and all(isinstance(v, (dict, list)) for v in data.values()) \
1015
+ and not all(isinstance(v, list) and len(v) == 2 for v in data.values()):
1016
+ raw_series, data = [{"name": k, "values": v} for k, v in data.items()], None
1017
+ if raw_series is None and isinstance(data, list) and data and all(isinstance(d, dict) for d in data):
1018
+ if all(any(token(k) in SERIES_VALUE_KEYS for k in d) and isinstance(next(v for k, v in d.items() if token(k) in SERIES_VALUE_KEYS), (list, dict)) for d in data):
1019
+ raw_series, data = data, None
1020
+ elif self.points(data) is None or any(len(d) > 2 for d in data):
1021
+ # Records: one label field and one or more numeric fields.
1022
+ keys = list(dict.fromkeys(k for d in data for k in d))
1023
+ numeric = [k for k in keys if all(as_number(d.get(k)) is not None for d in data)]
1024
+ label_keys = [k for k in keys if k not in numeric]
1025
+ if numeric and len(label_keys) <= 1:
1026
+ lk = label_keys[0] if label_keys else None
1027
+ labels = [d.get(lk, i + 1) if lk else i + 1 for i, d in enumerate(data)]
1028
+ raw_series = [{"name": humanize(k), "values": dict(zip(map(display_text, labels), (d[k] for d in data)))} for k in numeric]
1029
+ data = None
1030
+ if not f.get("x_label") and lk:
1031
+ f["x_label"] = humanize(lk)
1032
+ self.ctx.fixed(node.path, "records_as_chart_series")
1033
+ if raw_series is not None:
1034
+ if isinstance(raw_series, dict):
1035
+ raw_series = [{"name": k, "values": v} for k, v in raw_series.items()]
1036
+ series = []
1037
+ for i, s in enumerate(raw_series if isinstance(raw_series, list) else []):
1038
+ if not isinstance(s, dict):
1039
+ continue
1040
+ _, name = pick(s, SERIES_NAME_KEYS)
1041
+ _, values = pick(s, SERIES_VALUE_KEYS)
1042
+ pts = self.points(values, labels)
1043
+ if pts is None:
1044
+ return None
1045
+ series.append((clean_text(name) or f"Series {i + 1}", pts))
1046
+ else:
1047
+ pts = self.points(data if data is not None else f.get("values"), labels)
1048
+ if pts is None:
1049
+ return None
1050
+ series = [(clean_text(f.get("y_label")) or clean_text(f.get("title")) or "Value", pts)]
1051
+ if not series or not any(pts for _, pts in series):
1052
+ return None
1053
+ categories = list(dict.fromkeys(display_text(x) if not isinstance(x, (int, float)) else x for _, pts in series for x, _ in pts))
1054
+ table = []
1055
+ for name, pts in series:
1056
+ lookup = {}
1057
+ for x, y in pts:
1058
+ lookup.setdefault(display_text(x) if not isinstance(x, (int, float)) else x, y)
1059
+ table.append((name, [lookup.get(c) for c in categories]))
1060
+ return categories, table
1061
+
1062
+ def chart_kind(self, node: Node) -> str | None:
1063
+ t = token(node.f.get("chart_type") or "")
1064
+ hints = node.hints
1065
+ if as_bool(hints.get("stacked"), False):
1066
+ t += "stacked"
1067
+ if as_bool(hints.get("horizontal"), False) or token(hints.get("orientation", "")) == "horizontal":
1068
+ t += "horizontal"
1069
+ for words, kind in ((("gauge", "meter", "dial", "speedo"), "gauge"), (("donut", "doughnut", "ring"), "donut"), (("pie",), "pie"),
1070
+ (("line", "area", "trend", "time", "spark", "scatter"), "line")):
1071
+ if any(w in t for w in words):
1072
+ return kind
1073
+ if "stack" in t:
1074
+ return "stacked_horizontal_bar" if "horizontal" in t else "stacked_bar"
1075
+ if any(w in t for w in ("group", "cluster", "multi", "sidebyside")):
1076
+ return "grouped_bar"
1077
+ if "horizontal" in t or t in {"hbar", "barh"}:
1078
+ return "horizontal_bar"
1079
+ if any(w in t for w in ("bar", "column", "histogram", "vertical")):
1080
+ return "bar"
1081
+ if t:
1082
+ self.ctx.warn(node.path + ".chart_type", "unknown_chart_type", f"Chart type '{node.f.get('chart_type')}' is not supported; a suitable type was chosen.")
1083
+ return None
1084
+
1085
+ def r_chart(self, node: Node, depth: int) -> list[dict]:
1086
+ kind = self.chart_kind(node)
1087
+ if kind == "gauge" or (kind is None and node.f.get("value") is not None and node.f.get("data") is None and node.f.get("series") is None):
1088
+ return self.gauge(node)
1089
+ parsed = self.chart_data(node)
1090
+ if parsed is None:
1091
+ if node.f.get("value") is not None:
1092
+ return self.gauge(node)
1093
+ self.ctx.warn(node.path, "invalid_chart_data", "Chart data could not be read; use \"data\": {\"Label\": number}.")
1094
+ return self.title_prefix(node)
1095
+ categories, series = parsed
1096
+ numbers = [[parse_number(v) for v in values] for _, values in series]
1097
+ unreadable = any(v is not None and n is None for (_, values), nums in zip(series, numbers) for v, n in zip(values, nums))
1098
+ gaps = any(v is None for _, values in series for v in values)
1099
+ values = [[n.value if n else None for n in nums] for nums in numbers]
1100
+ multi = len(series) > 1
1101
+ if kind is None:
1102
+ dates = all(as_date(c)[0] for c in categories if isinstance(c, str)) and all(isinstance(c, str) for c in categories)
1103
+ kind = "line" if dates and len(categories) >= 3 else ("grouped_bar" if multi else "bar")
1104
+ if multi and kind in {"bar", "horizontal_bar", "pie", "donut"}:
1105
+ if kind in {"pie", "donut"}:
1106
+ self.ctx.warn(node.path, "chart_type_changed", "Pie/donut charts show one series; several series are shown as grouped bars.")
1107
+ kind = "grouped_bar"
1108
+ if not multi and kind in {"grouped_bar", "stacked_bar", "stacked_horizontal_bar"}:
1109
+ kind = "bar"
1110
+ flat = [v for row in values for v in row if v is not None]
1111
+ if kind in {"pie", "donut"} and (any(v < 0 for v in flat) or sum(flat) <= 0):
1112
+ self.ctx.warn(node.path, "chart_type_changed", "Pie/donut values must be positive; shown as a bar chart.")
1113
+ kind = "bar"
1114
+ if "stacked" in kind and any(v < 0 for v in flat):
1115
+ kind = "grouped_bar"
1116
+ x_values = categories
1117
+ if kind == "line":
1118
+ dates = [as_date(c)[0] if isinstance(c, str) else None for c in categories]
1119
+ nums = [as_number(c) for c in categories]
1120
+ if all(dates):
1121
+ x_values = dates
1122
+ elif all(n is not None for n in nums):
1123
+ x_values = nums
1124
+ else:
1125
+ x_values = None
1126
+ if x_values is not None and len(set(x_values)) == len(x_values) and x_values != sorted(x_values):
1127
+ order = sorted(range(len(x_values)), key=lambda i: x_values[i])
1128
+ x_values = [x_values[i] for i in order]
1129
+ categories = [categories[i] for i in order]
1130
+ values = [[row[i] for i in order] for row in values]
1131
+ series = [(name, [vals[i] for i in order]) for name, vals in series]
1132
+ self.ctx.fixed(node.path, "line_points_sorted_by_x")
1133
+ if x_values is None or len(set(x_values)) != len(x_values):
1134
+ if self.native:
1135
+ self.ctx.fixed(node.path, "line_with_categories_as_bars")
1136
+ kind = "grouped_bar" if multi else "bar"
1137
+ x_values = categories
1138
+ if unreadable:
1139
+ self.ctx.warn(node.path, "chart_values_not_numeric", "Some chart values were not numbers, so the data is shown as a table.")
1140
+ fallback = self.chart_fallback(node, kind, categories, series, values, force_table=unreadable)
1141
+ self.need(640, 60 if not multi else 80, "Charts need room for data and axis labels.")
1142
+ prefix = self.title_prefix(node)
1143
+ if unreadable or gaps or not self.native_ok(NATIVE_CHARTS[kind]):
1144
+ if gaps and self.native_ok(NATIVE_CHARTS[kind]):
1145
+ self.ctx.warn(node.path, "chart_missing_values", "Series have different categories; shown as a table with blanks rather than inventing zeros.")
1146
+ return prefix + fallback
1147
+ native = {"type": NATIVE_CHARTS[kind]}
1148
+ if clean_text(node.f.get("title")):
1149
+ native.update(title=clean_text(node.f["title"]), showTitle=False)
1150
+ for key, prop in (("x_label", "xAxisTitle"), ("y_label", "yAxisTitle")):
1151
+ if clean_text(node.f.get(key)) and kind not in {"pie", "donut"}:
1152
+ native[prop] = clean_text(node.f[key])
1153
+ cats = [display_text(c) for c in categories]
1154
+ if kind in {"pie", "donut"}:
1155
+ native["data"] = [{"legend": c, "value": v} for c, v in zip(cats, values[0])]
1156
+ elif kind in {"bar", "horizontal_bar"}:
1157
+ native["data"] = [{"x": c, "y": v} for c, v in zip(cats, values[0])]
1158
+ elif kind == "stacked_horizontal_bar":
1159
+ native["data"] = [{"title": c, "data": [{"legend": name, "value": vals[i]} for (name, _), vals in zip(series, values)]} for i, c in enumerate(cats)]
1160
+ else:
1161
+ xs = x_values if kind == "line" else cats
1162
+ native["data"] = [{"legend": name, "values": [{"x": x, "y": v} for x, v in zip(xs, vals)]} for (name, _), vals in zip(series, values)]
1163
+ if kind == "stacked_bar":
1164
+ native["stacked"] = True
1165
+ wrapped = fallback[0] if len(fallback) == 1 else {"type": "Container", "items": fallback}
1166
+ return prefix + [self.extension(native, wrapped, node.path)]
1167
+
1168
+ def chart_fallback(self, node, kind, categories, series, values, *, force_table=False) -> list[dict]:
1169
+ labels = [display_text(c) for c in categories]
1170
+ y_label = clean_text(node.f.get("y_label"))
1171
+ if not force_table and len(series) == 1 and kind in {"bar", "horizontal_bar", "pie", "donut", "line"} and all(v is not None and v >= 0 for v in values[0]):
1172
+ top = max(values[0]) or 1
1173
+ total = sum(values[0])
1174
+ label_px = max(56, min(180, max(len(l) for l in labels) * 7 + 8))
1175
+ rows = []
1176
+ for label, raw, v in zip(labels, series[0][1], values[0]):
1177
+ text = display_text(raw) if isinstance(raw, str) else format_number(v)
1178
+ if kind in {"pie", "donut"} and total:
1179
+ text += f" ({v / total:.0%})"
1180
+ rows.append(theme.bar_row(label, v / top, text, label_px))
1181
+ return ([theme.note(y_label)] if y_label else []) + rows
1182
+ x_label = clean_text(node.f.get("x_label")) or ("Date" if kind == "line" else "Category")
1183
+ headers = [x_label, *[name for name, _ in series]]
1184
+ rows = [[label, *[display_text(vals[i]) if vals[i] is not None else "—" for _, vals in series]] for i, label in enumerate(labels)]
1185
+ table_node = Node("table", {"columns": headers, "rows": rows}, node.path)
1186
+ return ([theme.note(y_label)] if y_label else []) + self.r_table(table_node, 0)
1187
+
1188
+ def gauge(self, node: Node) -> list[dict]:
1189
+ parsed = parse_number(node.f.get("value"))
1190
+ if parsed is None:
1191
+ self.ctx.warn(node.path + ".value", "invalid_value", "Gauge value was not a number; gauge left out.")
1192
+ return self.title_prefix(node)
1193
+ low = as_number(node.f.get("min"))
1194
+ high = as_number(node.f.get("max"))
1195
+ low = 0 if low is None else low
1196
+ high = parsed.denominator or (100 if high is None else high)
1197
+ if high <= low:
1198
+ low, high = min(low, high), max(low, high) if high != low else low + 100
1199
+ self.ctx.warn(node.path, "invalid_range", "Gauge max must exceed min; the range was corrected.")
1200
+ value = parsed.value
1201
+ segments = []
1202
+ raw_segments = node.f.get("segments")
1203
+ pts = self.points(raw_segments) if raw_segments is not None else None
1204
+ if pts:
1205
+ sizes = [(display_text(k), as_number(v)) for k, v in pts]
1206
+ if all(s is not None and s > 0 for _, s in sizes):
1207
+ total = sum(s for _, s in sizes)
1208
+ if math.isclose(total, high - low, rel_tol=1e-9):
1209
+ segments = sizes
1210
+ elif all(a < b for (_, a), (_, b) in zip(sizes, sizes[1:])) and math.isclose(sizes[-1][1], high):
1211
+ ends = [s for _, s in sizes]
1212
+ segments = [(n, e - s) for (n, e), s in zip(sizes, [low, *ends[:-1]])]
1213
+ self.ctx.fixed(node.path + ".segments", "segment_endpoints_to_sizes")
1214
+ if not segments:
1215
+ self.ctx.warn(node.path + ".segments", "invalid_segments", "Gauge segments did not cover min–max and were left out.")
1216
+ label = clean_text(node.f.get("title")) or clean_text(node.f.get("label")) or "Value"
1217
+ fraction = (value - low) / (high - low)
1218
+ value_text = f"{format_number(value)} / {format_number(high)}" if low == 0 else f"{format_number(value)} (range {format_number(low)}–{format_number(high)})"
1219
+ items = theme.progress(label, value_text, max(0.0, min(1.0, fraction)))
1220
+ if segments:
1221
+ ranges, start = [], low
1222
+ for name, size in segments:
1223
+ ranges.append(f"{name} {format_number(start)}–{format_number(start + size)}")
1224
+ start += size
1225
+ items.append(theme.spaced(theme.note(" · ".join(ranges)), "None"))
1226
+ self.need(420, 50, "Gauges need room for a label and indicator.")
1227
+ native = {"type": "Chart.Gauge", "min": low, "max": high, "value": min(max(value, low), high), "valueFormat": "fraction",
1228
+ "segments": [{"legend": n, "size": s} for n, s in (segments or [("Range", high - low)])]}
1229
+ if node.f.get("title"):
1230
+ native.update(title=label, showTitle=False)
1231
+ if self.native_ok("Chart.Gauge"):
1232
+ return [self.extension(native, {"type": "Container", "items": items}, node.path)]
1233
+ return items
1234
+
1235
+ # -- layout
1236
+
1237
+ def r_section(self, node: Node, depth: int) -> list[dict]:
1238
+ title = clean_text(node.f.get("title"))
1239
+ children = self.render_list(node.f.get("elements", []), node.path + ".elements", depth + 1)
1240
+ if not children:
1241
+ return [theme.spaced(theme.heading(title), "Large")] if title else []
1242
+ if not title and len(children) == 1:
1243
+ return children
1244
+ items = ([theme.group_title(self.with_icon(title, node))] if title else []) + children
1245
+ return [theme.spaced({"type": "Container", "items": items}, "Large" if depth == 0 else "Medium", separator=depth == 0 and bool(title))]
1246
+
1247
+ def column_record(self, record: dict, path: str) -> dict:
1248
+ """A flat product/plan record -> {title, elements}."""
1249
+ title_key, title = pick(record, _keys("title name label heading product plan tier header"))
1250
+ elements = []
1251
+ for key, value in record.items():
1252
+ t = token(key)
1253
+ if key == title_key or value in (None, "", [], {}):
1254
+ continue
1255
+ if t in _keys("image img photo picture logo thumbnail image_url imageurl") and as_url(value):
1256
+ elements.insert(0, {"type": "image", "url": value, "alt": display_text(title or "")})
1257
+ elif t in _keys("badge tag label ribbon highlight status"):
1258
+ elements.append({"type": "badge", "text": value})
1259
+ elif t in _keys("description summary details detail text body content blurb about overview"):
1260
+ elements.append({"type": "text", "text": value})
1261
+ elif t in _keys("rating stars score") and as_number(value) is not None:
1262
+ elements.append({"type": "rating", "value": value})
1263
+ elif t in _keys("url link href website cta") and as_url(value):
1264
+ elements.append({"type": "buttons", "buttons": [{"label": f"View {display_text(title)}" if title else "View details", "url": value}]})
1265
+ elif isinstance(value, list) and all(isinstance(v, str) for v in value):
1266
+ elements.append({"type": "list", "title": humanize(key), "items": value})
1267
+ elif isinstance(value, dict):
1268
+ elements.append({"type": "facts", "title": humanize(key), "facts": value})
1269
+ else:
1270
+ if elements and elements[-1].get("type") == "facts" and elements[-1].get("_auto"):
1271
+ elements[-1]["facts"][humanize(key)] = value
1272
+ else:
1273
+ elements.append({"type": "facts", "facts": {humanize(key): value}, "_auto": True})
1274
+ for e in elements:
1275
+ e.pop("_auto", None)
1276
+ self.ctx.fixed(path, "record_as_column")
1277
+ return {"title": title, "elements": elements}
1278
+
1279
+ def r_columns(self, node: Node, depth: int) -> list[dict]:
1280
+ raw_columns = node.f.get("columns")
1281
+ if isinstance(raw_columns, dict):
1282
+ raw_columns = [{"title": k, "elements": v if isinstance(v, list) else [v]} for k, v in raw_columns.items()]
1283
+ if not isinstance(raw_columns, list) or not raw_columns:
1284
+ self.ctx.warn(node.path, "empty_element", "Columns had no content and were left out.")
1285
+ return []
1286
+ specs = []
1287
+ for i, raw in enumerate(raw_columns):
1288
+ p = f"{node.path}.columns[{i}]"
1289
+ if isinstance(raw, list):
1290
+ specs.append((None, raw))
1291
+ elif isinstance(raw, dict):
1292
+ keys = {token(k): k for k in raw}
1293
+ element_key = next((keys[t] for t in _keys("elements items body children content components blocks widgets") if t in keys
1294
+ and isinstance(raw[keys[t]], list)), None)
1295
+ if element_key:
1296
+ _, title = pick({k: v for k, v in raw.items() if k != element_key}, _keys("title heading header label name"))
1297
+ specs.append((title, raw[element_key]))
1298
+ elif any(t in TYPE_KEYS for t in keys) and token(raw[next(keys[t] for t in keys if t in TYPE_KEYS)]) not in {"column", "col"}:
1299
+ specs.append((None, [raw]))
1300
+ else:
1301
+ record = self.column_record({k: v for k, v in raw.items() if token(k) not in TYPE_KEYS}, p)
1302
+ specs.append((record["title"], record["elements"]))
1303
+ elif raw not in (None, ""):
1304
+ specs.append((None, [raw]))
1305
+ if len(specs) == 1:
1306
+ title, elements = specs[0]
1307
+ return self.r_section(Node("section", {"title": title, "elements": elements}, node.path), depth)
1308
+ outer = (self.min_width, self.floor_percent, self.force_overflow)
1309
+ rendered, widths = [], []
1310
+ self.column_depth += 1
1311
+ for i, (title, elements) in enumerate(specs):
1312
+ self.min_width, self.floor_percent, self.force_overflow = 160, 0, False
1313
+ items = [theme.group_title(clean_text(title))] if clean_text(title) else []
1314
+ items += self.render_list(elements, f"{node.path}.columns[{i}].elements", depth + 1)
1315
+ if not items:
1316
+ continue
1317
+ widths.append(self.column_minimum(items))
1318
+ only_inputs = all(_is_input(item) for item in items if item.get("type") != "TextBlock")
1319
+ rendered.append(theme.column(items, **({} if only_inputs else {"style": "emphasis"})))
1320
+ self.column_depth -= 1
1321
+ self.min_width, self.floor_percent, self.force_overflow = outer
1322
+ if not rendered:
1323
+ return []
1324
+ count = len(rendered)
1325
+ minimum = max(widths) * count + 16 * (count - 1) + 32
1326
+ floor = 50 if count == 1 else 60 if count == 2 else 80 if count <= 4 else 100
1327
+ self.need(minimum, floor, f"{count} side-by-side columns preserve all their content.", overflow=count > 6)
1328
+ return [theme.spaced(theme.row(*rendered), "Medium")]
1329
+
1330
+ def column_minimum(self, items: list[dict]) -> int:
1331
+ minimum = 160
1332
+ for item in items:
1333
+ kind = item.get("type", "")
1334
+ if kind.startswith("Input."):
1335
+ minimum = max(minimum, 240)
1336
+ elif kind == "Media" or kind.startswith("Chart."):
1337
+ minimum = max(minimum, 420)
1338
+ elif kind == "Table":
1339
+ minimum = max(minimum, 96 * len(item["columns"]) + 16)
1340
+ elif kind == "Container":
1341
+ minimum = max(minimum, self.column_minimum(item["items"]))
1342
+ elif kind == "ColumnSet":
1343
+ widths = [self.column_minimum(c["items"]) for c in item["columns"]]
1344
+ minimum = max(minimum, sum(widths) + 8 * (len(widths) - 1) if len(widths) > 2 else max(widths) * len(widths))
1345
+ elif kind == "ActionSet":
1346
+ minimum = max(minimum, max((min(320, len(a["title"]) * 7 + 32) for a in item["actions"]), default=160))
1347
+ return minimum
1348
+
1349
+ # -- buttons
1350
+
1351
+ def button(self, raw, path: str, primary_available: bool) -> dict | None:
1352
+ if isinstance(raw, str):
1353
+ raw = {"label": raw}
1354
+ if not isinstance(raw, dict):
1355
+ return None
1356
+ b = {}
1357
+ for k, v in raw.items():
1358
+ name = BUTTON_MAP.get(token(k))
1359
+ if name and name not in b and v is not None:
1360
+ b[name] = v
1361
+ label = clean_text(b.get("label"))
1362
+ action = token(b.get("action") or "")
1363
+ link_raw = b.get("url")
1364
+ if action and action not in SUBMIT_WORDS and action not in LINK_WORDS and as_url(b.get("action")):
1365
+ link_raw = link_raw or b.get("action")
1366
+ action = "openurl"
1367
+ wants_link = action in LINK_WORDS or (link_raw is not None and action not in SUBMIT_WORDS)
1368
+ if action in _keys("showcard actionshowcard togglevisibility actiontogglevisibility"):
1369
+ self.ctx.warn(path, "unsupported_action", "Show-card and toggle actions are not supported; shown as a regular button.")
1370
+ icon = b.get("icon")
1371
+ if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.isascii() and label:
1372
+ label = f"{icon.strip()} {label}"
1373
+ style_word = token(b.get("style") or "")
1374
+ explicit = ("positive" if style_word in _keys("primary positive success green confirm cta main") else
1375
+ "destructive" if style_word in _keys("danger destructive red negative delete critical warning") else
1376
+ "default" if style_word else None)
1377
+ if wants_link:
1378
+ link = as_url(link_raw)
1379
+ if not link:
1380
+ self.ctx.warn(path, "invalid_url", f"Button '{label or 'link'}' had no valid http(s) URL and was left out.")
1381
+ return None
1382
+ result = {"type": "Action.OpenUrl", "title": label or "Open link", "url": link}
1383
+ else:
1384
+ label = label or "Submit"
1385
+ data = b.get("data")
1386
+ if data is None and action and action not in SUBMIT_WORDS:
1387
+ data = b.get("action")
1388
+ if isinstance(data, str) and data.strip()[:1] == "{":
1389
+ try:
1390
+ data = json.loads(data)
1391
+ except ValueError:
1392
+ pass
1393
+ if data is None:
1394
+ data = {"action": slug(label, "action")}
1395
+ self.ctx.fixed(path, "generated_submit_data")
1396
+ elif not isinstance(data, dict):
1397
+ data = {"action": submit_text(data)}
1398
+ result = {"type": "Action.Submit", "title": label, "data": data}
1399
+ style = theme.button_style(result["title"], explicit, primary_available)
1400
+ if style:
1401
+ result["style"] = style
1402
+ return result
1403
+
1404
+ def buttons(self, raw_list, path: str) -> list[dict]:
1405
+ raw_list = raw_list if isinstance(raw_list, list) else [raw_list]
1406
+ out, primary = [], True
1407
+ for i, raw in enumerate(raw_list):
1408
+ action = self.button(raw, f"{path}[{i}]", primary)
1409
+ if action:
1410
+ primary = primary and action.get("style") != "positive"
1411
+ out.append(action)
1412
+ return out
1413
+
1414
+ def r_buttons(self, node: Node, depth: int) -> list[dict]:
1415
+ actions = []
1416
+ for item in [node, *node.f.get("_merged", [])]:
1417
+ raw = item.f.get("buttons")
1418
+ if raw is None:
1419
+ single = {k: v for k, v in item.f.items() if k in {"label", "url", "data", "action", "style"}}
1420
+ raw = [single] if single else []
1421
+ actions += self.buttons(raw, item.path + ".buttons")
1422
+ if not actions:
1423
+ return []
1424
+ return [theme.spaced({"type": "ActionSet", "actions": actions[i:i + 5]}, "Medium") for i in range(0, len(actions), 5)]
1425
+
1426
+ # -- inputs
1427
+
1428
+ def generated_id(self, base: str) -> str:
1429
+ result, n = base, 2
1430
+ while result in self.used_ids or result in self.reserved_ids:
1431
+ result, n = f"{base}_{n}", n + 1
1432
+ return result
1433
+
1434
+ def input_base(self, node: Node, default_label: str) -> dict:
1435
+ f = node.f
1436
+ label = clean_text(f.get("label"))
1437
+ explicit = f.get("id")
1438
+ ident = as_id(explicit) if explicit is not None else None
1439
+ if explicit is not None and ident is None:
1440
+ self.ctx.warn(node.path + ".id", "invalid_id", "The id could not be used; an id was generated from the label.")
1441
+ if ident and ident != explicit:
1442
+ self.ctx.fixed(node.path + ".id", "id_sanitized", source=display_text(explicit), target=ident)
1443
+ if ident and ident in self.used_ids:
1444
+ new = self.generated_id(ident)
1445
+ self.ctx.warn(node.path + ".id", "duplicate_id", f"Input id '{ident}' was already used; this input submits as '{new}'.")
1446
+ ident = new
1447
+ if not ident:
1448
+ ident = self.generated_id(slug(label or default_label))
1449
+ self.used_ids.add(ident)
1450
+ self.input_ids.append(ident)
1451
+ if not label:
1452
+ label = humanize(display_text(explicit)) if explicit is not None and is_slug_like(explicit) else default_label
1453
+ required = as_bool(f.get("required"), None)
1454
+ if required is None and "optional" in node.hints:
1455
+ required = not as_bool(node.hints["optional"], False)
1456
+ if f.get("required") is not None and as_bool(f.get("required"), None) is None:
1457
+ self.ctx.fixed(node.path + ".required", "unreadable_boolean_default_false")
1458
+ out = {"id": ident, "label": label}
1459
+ if required:
1460
+ out["isRequired"] = True
1461
+ out["errorMessage"] = clean_text(f.get("error_message")) or f"Please complete {label}."
1462
+ elif clean_text(f.get("error_message")):
1463
+ out["errorMessage"] = clean_text(f["error_message"])
1464
+ self.need(480, 50, "Form inputs need readable labels and entry space.")
1465
+ return out
1466
+
1467
+ def input_spacing(self, element: dict) -> dict:
1468
+ return theme.spaced(element, "Medium")
1469
+
1470
+ def r_text_input(self, node: Node, depth: int) -> list[dict]:
1471
+ f = node.f
1472
+ out = self.input_base(node, "Your answer")
1473
+ out = {"type": "Input.Text", **out}
1474
+ hint = token(f.get("format") or "")
1475
+ fmt = next((name for name, words in (("email", ("email", "mail")), ("tel", ("tel", "phone", "mobile")),
1476
+ ("url", ("url", "link", "website", "uri")), ("password", ("password", "secret")))
1477
+ if any(w in hint for w in words)), None)
1478
+ if fmt is None and not hint:
1479
+ probe = token(out["label"] + " " + out["id"])
1480
+ fmt = next((name for name, words in (("email", ("email",)), ("tel", ("phone", "mobile", "telephone")),
1481
+ ("url", ("website", "url")), ("password", ("password",))) if any(w in probe for w in words)), None)
1482
+ if fmt:
1483
+ self.ctx.fixed(node.path, "format_inferred_from_label", target=fmt)
1484
+ if fmt:
1485
+ out["style"] = fmt
1486
+ placeholder = clean_text(f.get("placeholder"))
1487
+ if placeholder:
1488
+ out["placeholder"] = placeholder
1489
+ multiline = as_bool(f.get("multiline"), None)
1490
+ if multiline is None and isinstance(f.get("multiline"), (int, float)):
1491
+ multiline = f["multiline"] > 1
1492
+ if multiline is None and not fmt:
1493
+ multiline = any(w in out["label"].casefold() for w in ("comment", "feedback", "description", "notes", "message", "explain", "reason", "justification", "details"))
1494
+ if multiline:
1495
+ out["isMultiline"] = True
1496
+ self.need(560, 60, "Multiline text entry benefits from more horizontal space.")
1497
+ if f.get("value") is not None:
1498
+ out["value"] = display_text(f["value"])
1499
+ if f.get("max_length") is not None:
1500
+ limit = as_number(f["max_length"])
1501
+ if limit is None or limit < 1:
1502
+ self.ctx.warn(node.path + ".max_length", "invalid_value", "max_length was not a positive number and was left out.")
1503
+ elif len(out.get("value", "").encode("utf-16-le")) // 2 > limit:
1504
+ self.ctx.warn(node.path + ".max_length", "invalid_value", "The initial text is longer than max_length, so the limit was left out.")
1505
+ else:
1506
+ out["maxLength"] = int(limit)
1507
+ if isinstance(f.get("regex"), str) and f["regex"]:
1508
+ out["regex"] = f["regex"]
1509
+ if f.get("_unsupported"):
1510
+ self.ctx.warn(node.path, "unsupported_input", f"{f['_unsupported'].capitalize()} is not available in Adaptive Cards; a URL text box is shown instead.")
1511
+ return [self.input_spacing(out)]
1512
+
1513
+ def r_number_input(self, node: Node, depth: int) -> list[dict]:
1514
+ f = node.f
1515
+ out = {"type": "Input.Number", **self.input_base(node, "Number")}
1516
+ unit = clean_text(f.get("unit"))
1517
+ if unit and unit.casefold() not in out["label"].casefold():
1518
+ out["label"] = f"{out['label']} ({unit})"
1519
+ bounds = {k: as_number(f.get(k)) for k in ("min", "max") if f.get(k) is not None}
1520
+ for k in ("min", "max"):
1521
+ if f.get(k) is not None and bounds.get(k) is None:
1522
+ self.ctx.warn(f"{node.path}.{k}", "invalid_value", f"{k} was not a number and was left out.")
1523
+ bounds.pop(k, None)
1524
+ if "min" in bounds and "max" in bounds and bounds["min"] > bounds["max"]:
1525
+ bounds["min"], bounds["max"] = bounds["max"], bounds["min"]
1526
+ self.ctx.fixed(node.path, "swapped_min_max")
1527
+ out.update(bounds)
1528
+ if f.get("value") is not None:
1529
+ value = as_number(f["value"])
1530
+ if value is None or ("min" in bounds and value < bounds["min"]) or ("max" in bounds and value > bounds["max"]):
1531
+ self.ctx.warn(node.path + ".value", "invalid_value", "Initial value was not a number within min–max and was left out.")
1532
+ else:
1533
+ out["value"] = value
1534
+ if clean_text(f.get("placeholder")):
1535
+ out["placeholder"] = clean_text(f["placeholder"])
1536
+ return [self.input_spacing(out)]
1537
+
1538
+ def temporal(self, node: Node, kind: str) -> dict:
1539
+ f = node.f
1540
+ out = {"type": "Input.Date" if kind == "date" else "Input.Time", **self.input_base(node, "Date" if kind == "date" else "Time")}
1541
+ parse = (lambda v: as_date(v, self.config.day_first)) if kind == "date" else (lambda v: (as_time(v), False))
1542
+ for key in ("min", "max", "value"):
1543
+ raw = f.get(key)
1544
+ if raw is None or raw == "":
1545
+ continue
1546
+ value, ambiguous = parse(raw)
1547
+ if value is None:
1548
+ self.ctx.warn(f"{node.path}.{key}", "invalid_value", f"{key} '{display_text(raw)}' is not a {kind} that can be read exactly; it was left out.")
1549
+ continue
1550
+ if ambiguous:
1551
+ self.ctx.warn(f"{node.path}.{key}", "ambiguous_date", f"'{raw}' read as {value} ({'day' if self.config.day_first else 'month'} first).")
1552
+ elif value != raw:
1553
+ self.ctx.fixed(f"{node.path}.{key}", f"{kind}_normalized", source=display_text(raw), target=value)
1554
+ out[key] = value
1555
+ if "min" in out and "max" in out and out["min"] > out["max"]:
1556
+ out["min"], out["max"] = out["max"], out["min"]
1557
+ self.ctx.fixed(node.path, "swapped_min_max")
1558
+ if "value" in out and (("min" in out and out["value"] < out["min"]) or ("max" in out and out["value"] > out["max"])):
1559
+ self.ctx.warn(node.path + ".value", "invalid_value", "Initial value was outside min–max and was left out.")
1560
+ del out["value"]
1561
+ return out
1562
+
1563
+ def r_date_input(self, node: Node, depth: int) -> list[dict]:
1564
+ return [self.input_spacing(self.temporal(node, "date"))]
1565
+
1566
+ def r_time_input(self, node: Node, depth: int) -> list[dict]:
1567
+ return [self.input_spacing(self.temporal(node, "time"))]
1568
+
1569
+ def r_datetime_input(self, node: Node, depth: int) -> list[dict]:
1570
+ f = dict(node.f)
1571
+ date_part = time_part = None
1572
+ raw = f.pop("value", None)
1573
+ if isinstance(raw, str):
1574
+ m = re.match(r"(.*?)[T\s,]+(\d{1,2}(?::\d{2})?(?::\d{2})?\s*(?:[ap]\.?m\.?)?)\s*(?:Z|[+-]\d{2}:?\d{2})?$", raw.strip(), re.I)
1575
+ date_part, time_part = (m.group(1), m.group(2)) if m else (raw, None)
1576
+ label = clean_text(f.get("label")) or "Date"
1577
+ base_id = as_id(f.get("id")) or slug(label)
1578
+ date_node = Node("date_input", {**f, "id": base_id + "_date", "label": label, "value": date_part,
1579
+ "min": f.get("min"), "max": f.get("max")}, node.path)
1580
+ time_node = Node("time_input", {"id": base_id + "_time", "label": "Time", "value": time_part,
1581
+ "required": f.get("required")}, node.path)
1582
+ date_input, time_input = self.temporal(date_node, "date"), self.temporal(time_node, "time")
1583
+ self.ctx.fixed(node.path, "datetime_split", ids=[date_input["id"], time_input["id"]])
1584
+ return [self.input_spacing(theme.row(theme.column([date_input]), theme.column([time_input])))]
1585
+
1586
+ def choice_options(self, raw, node: Node) -> list[tuple[str, str | None]]:
1587
+ """(title, explicit value or None)."""
1588
+ if isinstance(raw, str):
1589
+ parts = raw.splitlines() if "\n" in raw else re.split(r"\s*[;|]\s*", raw) if re.search(r"[;|]", raw) else re.split(r"\s*,\s*", raw)
1590
+ raw = [p for p in parts if p.strip()]
1591
+ self.ctx.fixed(node.path + ".options", "options_split_from_text")
1592
+ if isinstance(raw, dict):
1593
+ items = list(raw.items())
1594
+ keys_slug = all(re.fullmatch(r"[a-z0-9_\-]+", str(k)) for k, _ in items)
1595
+ values_display = any(isinstance(v, str) and (" " in v or v[:1].isupper()) for _, v in items)
1596
+ if keys_slug and values_display:
1597
+ self.ctx.fixed(node.path + ".options", "options_value_title_order")
1598
+ return [(display_text(v), display_text(k)) for k, v in items]
1599
+ return [(display_text(k), submit_text(v) if not isinstance(v, dict) else None) for k, v in items]
1600
+ options = []
1601
+ for item in raw if isinstance(raw, list) else [raw]:
1602
+ if isinstance(item, dict):
1603
+ if len(item) == 1 and token(next(iter(item))) not in LABEL_KEYS | VALUE_KEYS:
1604
+ (k, v), = item.items()
1605
+ options.append((display_text(k), submit_text(v)))
1606
+ continue
1607
+ tk, title = pick(item, _keys("title label text name display display_name displayname caption option choice"))
1608
+ _, value = pick({k: v for k, v in item.items() if k != tk}, _keys("value id key code val data"))
1609
+ if title is None and value is None:
1610
+ continue
1611
+ options.append((display_text(title if title is not None else value), submit_text(value) if value is not None else None))
1612
+ elif isinstance(item, list) and len(item) == 2:
1613
+ options.append((display_text(item[0]), submit_text(item[1])))
1614
+ elif item is not None and clean_text(item):
1615
+ options.append((clean_text(item), None))
1616
+ return options
1617
+
1618
+ def r_choice(self, node: Node, depth: int) -> list[dict]:
1619
+ f = node.f
1620
+ options = self.choice_options(f.get("options", []), node)
1621
+ if not options:
1622
+ label = clean_text(f.get("label"))
1623
+ if re.match(r"(do|does|did|is|are|was|were|will|would|can|could|should|have|has|may)\b.*\?\s*$", label, re.I):
1624
+ options = [("Yes", None), ("No", None)]
1625
+ self.ctx.fixed(node.path, "yes_no_options_inferred")
1626
+ else:
1627
+ self.ctx.warn(node.path + ".options", "missing_options", "Choice had no options; a text box is shown instead.")
1628
+ return self.r_text_input(Node("text_input", {k: v for k, v in f.items() if k in SPECS["text_input"].fields}, node.path, node.hints), depth)
1629
+ multiple = as_bool(f.get("multiple"), False)
1630
+ out = {"type": "Input.ChoiceSet", **self.input_base(node, "Choose an option")}
1631
+ seen_titles, choices = set(), []
1632
+ for title, value in options:
1633
+ if title in seen_titles and value is None:
1634
+ self.ctx.fixed(node.path + ".options", "duplicate_option_removed", option=title)
1635
+ continue
1636
+ seen_titles.add(title)
1637
+ choices.append([title, value])
1638
+ used = set()
1639
+ for pair in choices:
1640
+ title, value = pair
1641
+ if value is None or value.strip() == "":
1642
+ value = title.strip()
1643
+ if multiple and "," in value:
1644
+ value = re.sub(r"\s*,\s*", " ", value)
1645
+ elif multiple and "," in value:
1646
+ value = value.replace(",", "_")
1647
+ self.ctx.warn(node.path + ".options", "value_changed", f"Option value '{pair[1]}' contained a comma, which breaks multi-select; submitted as '{value}'.")
1648
+ base, n = value or "option", 2
1649
+ while value in used or not value:
1650
+ value, n = f"{base}_{n}", n + 1
1651
+ used.add(value)
1652
+ pair[1] = value
1653
+ out["choices"] = [{"title": t, "value": v} for t, v in choices]
1654
+ self.choice_values[out["id"]] = out["choices"]
1655
+ display = token(f.get("display") or "")
1656
+ if display in {"radio", "expanded", "buttons", "list", "checkbox", "checkboxes", "check", "radiobuttons", "radiogroup", "segmented"}:
1657
+ expanded = True
1658
+ elif display in {"dropdown", "compact", "select", "menu", "combo", "combobox", "filtered", "picker"}:
1659
+ expanded = multiple and len(choices) <= 12
1660
+ else:
1661
+ expanded = (multiple and len(choices) <= 12) or (len(choices) <= 4 and max(len(t) for t, _ in choices) <= 60)
1662
+ out.update(isMultiSelect=multiple, style="expanded" if expanded else "compact", wrap=True)
1663
+ placeholder = clean_text(f.get("placeholder"))
1664
+ if placeholder and not expanded:
1665
+ out["placeholder"] = placeholder
1666
+ if f.get("value") not in (None, "", []):
1667
+ selected = self.match_choices(f["value"], choices, multiple, node.path)
1668
+ if selected:
1669
+ out["value"] = ",".join(selected) if multiple else selected[0]
1670
+ return [self.input_spacing(out)]
1671
+
1672
+ def match_choices(self, raw, choices, multiple: bool, path: str) -> list[str]:
1673
+ def find(wanted) -> str | None:
1674
+ text = display_text(wanted).strip()
1675
+ for test in (lambda t, v: v == text, lambda t, v: t == text,
1676
+ lambda t, v: v.casefold() == text.casefold() or t.casefold() == text.casefold(),
1677
+ lambda t, v: token(v) == token(text) or token(t) == token(text)):
1678
+ hits = [v for t, v in choices if test(t, v)]
1679
+ if len(hits) == 1:
1680
+ return hits[0]
1681
+ hits = [v for t, v in choices if token(text) and (token(t).startswith(token(text)) or token(text) in token(t))]
1682
+ return hits[0] if len(hits) == 1 else None
1683
+
1684
+ if isinstance(raw, list):
1685
+ wanted = raw
1686
+ elif multiple and isinstance(raw, str) and find(raw) is None:
1687
+ wanted = [p for p in re.split(r"\s*[,;|]\s*", raw) if p]
1688
+ else:
1689
+ wanted = [raw]
1690
+ found = []
1691
+ for w in wanted:
1692
+ value = find(w)
1693
+ if value is None:
1694
+ self.ctx.warn(path + ".value", "unmatched_default", f"Default '{display_text(w)}' does not match any option and was left out.")
1695
+ elif value not in found:
1696
+ found.append(value)
1697
+ if value != display_text(w):
1698
+ self.ctx.fixed(path + ".value", "default_matched_to_option", source=display_text(w), target=value)
1699
+ return found if multiple else found[:1]
1700
+
1701
+ def r_checkbox(self, node: Node, depth: int) -> list[dict]:
1702
+ f = node.f
1703
+ base = self.input_base(node, "Yes")
1704
+ on = submit_text(f["on_value"]) if f.get("on_value") not in (None, "") else "true"
1705
+ off = submit_text(f["off_value"]) if f.get("off_value") not in (None, "") else "false"
1706
+ if on == off:
1707
+ self.ctx.warn(node.path, "invalid_value", "on_value and off_value were equal; true/false are submitted instead.")
1708
+ on, off = "true", "false"
1709
+ out = {"type": "Input.Toggle", "id": base["id"], "title": base["label"], "valueOn": on, "valueOff": off, "wrap": True}
1710
+ for key in ("isRequired", "errorMessage"):
1711
+ if key in base:
1712
+ out[key] = base[key]
1713
+ if f.get("value") is not None:
1714
+ raw = f["value"]
1715
+ checked = raw == on if isinstance(raw, str) and raw in (on, off) else as_bool(raw, None)
1716
+ if checked is None:
1717
+ self.ctx.warn(node.path + ".value", "invalid_value", "Checkbox default was not true/false and was left out.")
1718
+ else:
1719
+ out["value"] = on if checked else off
1720
+ return [self.input_spacing(out)]
1721
+
1722
+ def r_rating_input(self, node: Node, depth: int) -> list[dict]:
1723
+ f = node.f
1724
+ out = self.input_base(node, "Rating")
1725
+ maximum = as_number(f.get("max")) or 5
1726
+ maximum = int(max(1, min(10, round(maximum))))
1727
+ half = as_bool(f.get("half_steps"), False)
1728
+ step = 0.5 if half else 1
1729
+ value = as_number(f.get("value")) if f.get("value") is not None else None
1730
+ if value is not None:
1731
+ snapped = round(value / step) * step
1732
+ if not 0 < snapped <= maximum:
1733
+ self.ctx.warn(node.path + ".value", "invalid_value", "Initial rating was outside the allowed range and was left out.")
1734
+ value = None
1735
+ elif snapped != value:
1736
+ self.ctx.fixed(node.path + ".value", "rating_snapped_to_step")
1737
+ value = snapped
1738
+ count = int(maximum / step)
1739
+ titles = [f"{'★' * int(i * step)}{'☆' * (maximum - int(i * step))} {i * step:g}" if not half else f"{i * step:g} / {maximum}"
1740
+ for i in range(1, count + 1)]
1741
+ fallback = {"type": "Input.ChoiceSet", **out, "style": "compact",
1742
+ "choices": [{"title": t, "value": f"{i * step:g}"} for i, t in zip(range(1, count + 1), titles)]}
1743
+ if value is not None:
1744
+ fallback["value"] = f"{value:g}"
1745
+ self.choice_values[out["id"]] = fallback["choices"]
1746
+ if maximum <= 5:
1747
+ native = {"type": "Input.Rating", **out, "max": maximum, "allowHalfSteps": half}
1748
+ if value is not None:
1749
+ native["value"] = value
1750
+ element = self.extension(native, fallback, node.path)
1751
+ else:
1752
+ element = fallback
1753
+ if element is not fallback and "fallback" in element:
1754
+ self.ctx.warn(node.path, "rating_submission_type", "Native rating submits a number; the fallback submits the same rating as a string.")
1755
+ return [self.input_spacing(element)]
1756
+
1757
+ def r_divider(self, node: Node, depth: int) -> list[dict]:
1758
+ return []
1759
+
1760
+ def r_column(self, node: Node, depth: int) -> list[dict]:
1761
+ return self.r_section(node, depth)
1762
+
1763
+ def r_document(self, node: Node, depth: int) -> list[dict]:
1764
+ return self.r_section(node, depth)
1765
+
1766
+ # ------------------------------------------------------------ assembly
1767
+
1768
+ def reserve(self, value, depth=0):
1769
+ """Reserve explicit IDs anywhere in the request so generated IDs never take them."""
1770
+ if depth > self.config.max_depth + 8:
1771
+ return
1772
+ if isinstance(value, dict):
1773
+ for k, v in value.items():
1774
+ if token(k) in {"id", "inputid", "fieldid"} and isinstance(v, str) and as_id(v):
1775
+ self.reserved_ids.add(as_id(v))
1776
+ self.reserve(v, depth + 1)
1777
+ elif isinstance(value, list):
1778
+ for v in value:
1779
+ self.reserve(v, depth + 1)
1780
+
1781
+ def header(self, doc: dict) -> list[dict]:
1782
+ title = clean_text(doc.get("title"))
1783
+ subtitle = clean_text(doc.get("subtitle"))
1784
+ status = doc.get("status")
1785
+ badge = None
1786
+ if status not in (None, ""):
1787
+ if isinstance(status, dict):
1788
+ node = self.canonicalize({"type": "badge", **status}, "$.status")
1789
+ badge = self.r_badge(node[0], 0) if node else None
1790
+ badge = badge[0] if badge else None
1791
+ elif clean_text(status):
1792
+ badge = self.badge_element(clean_text(status), tone_of(status), "$.status")
1793
+ blocks = ([theme.card_title(title)] if title else []) + ([theme.subtitle(subtitle)] if subtitle else [])
1794
+ if badge and blocks:
1795
+ return [theme.row(theme.column(blocks), theme.column([badge], "auto", verticalContentAlignment="Top"))]
1796
+ return blocks + ([badge] if badge else [])
1797
+
1798
+ def has_submit(self, node) -> bool:
1799
+ if isinstance(node, dict):
1800
+ return node.get("type") == "Action.Submit" or any(self.has_submit(v) for k, v in node.items() if k not in {"data", "fallback"})
1801
+ return isinstance(node, list) and any(self.has_submit(v) for v in node)
1802
+
1803
+ def clean_submit_data(self, node, path="$card"):
1804
+ if isinstance(node, dict):
1805
+ if node.get("type") == "Action.Submit" and isinstance(node.get("data"), dict):
1806
+ clash = set(node["data"]) & self.used_ids
1807
+ if clash:
1808
+ node["data"] = {k: v for k, v in node["data"].items() if k not in clash}
1809
+ self.ctx.warn(path, "submit_data_collision", f"Button data keys {sorted(clash)} match input ids and were removed so they cannot overwrite user answers.")
1810
+ for k, v in node.items():
1811
+ if k != "data":
1812
+ self.clean_submit_data(v, f"{path}.{k}")
1813
+ elif isinstance(node, list):
1814
+ for i, v in enumerate(node):
1815
+ self.clean_submit_data(v, f"{path}[{i}]")
1816
+
1817
+ def width(self) -> dict:
1818
+ target = self.config.available_width_px
1819
+ raw = max(self.floor_percent, math.ceil(self.min_width / target * 100))
1820
+ overflow = self.force_overflow or raw > 100
1821
+ percent = max(110, math.ceil(raw / 10) * 10) if overflow else next(n for n in (50, 60, 80, 100) if n >= raw)
1822
+ return {"suggestedWidth": "100%+" if overflow else f"{percent}%", "widthPercent": percent,
1823
+ "estimatedMinWidthPx": self.min_width, "referenceWidthPx": target,
1824
+ "overflow": "scroll" if overflow else "none", "isEstimate": True, "reasons": self.width_reasons}
1825
+
1826
+ def build(self, request) -> dict:
1827
+ raw = load_request(request, self.ctx)
1828
+ doc = self.document(raw)
1829
+ self.reserve(doc["elements"])
1830
+ body = self.header(doc)
1831
+ content = self.render_list(doc["elements"], "$.elements", 0)
1832
+ if body and content:
1833
+ content[0] = {**content[0], "spacing": "Medium"}
1834
+ body += content
1835
+ explicit_buttons = "buttons" in doc
1836
+ actions = self.buttons(doc.get("buttons") or [], "$.buttons")
1837
+ submit_label = clean_text(doc.get("submit_label"))
1838
+ if submit_label:
1839
+ defaults = [a for a in actions if a["type"] == "Action.Submit" and a["title"] == "Submit"]
1840
+ if len(defaults) == 1:
1841
+ defaults[0]["title"] = submit_label
1842
+ elif not any(a["type"] == "Action.Submit" for a in actions) and not self.has_submit(body):
1843
+ actions.insert(0, {"type": "Action.Submit", "title": submit_label, "style": "positive"})
1844
+ else:
1845
+ self.ctx.fixed("$.submit_label", "submit_label_ignored_buttons_given")
1846
+ elif self.input_ids and not explicit_buttons and not self.has_submit(body) and not any(a["type"] == "Action.Submit" for a in actions):
1847
+ actions.insert(0, {"type": "Action.Submit", "title": "Submit", "style": "positive"})
1848
+ if not body:
1849
+ raise CardError("$.elements", 'Nothing to display. Send elements such as '
1850
+ '{"title": "Summary", "elements": [{"type": "text", "text": "..."}]}.', "empty_card")
1851
+ card = {"type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.5", "body": body}
1852
+ if len(actions) > 5:
1853
+ body.extend(theme.spaced({"type": "ActionSet", "actions": actions[i:i + 5]}, "Medium") for i in range(0, len(actions), 5))
1854
+ elif actions:
1855
+ card["actions"] = actions
1856
+ self.clean_submit_data(card)
1857
+ if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > self.config.max_output_bytes:
1858
+ raise CardError("$", "Generated card exceeds the output size limit; split the content into several cards.", "limit_exceeded")
1859
+ layout = self.width()
1860
+ warnings = self.ctx.warnings
1861
+ message = "Card built and ready to display. Do not repeat its contents or call the tool again for the same card."
1862
+ if warnings:
1863
+ notes = "; ".join(f"{w['path']}: {w['message']}" for w in warnings[:3])
1864
+ message = (f"Card built and ready to display, with {len(warnings)} note(s): {notes}"
1865
+ " The card is usable as is; call again only if important content was left out.")
1866
+ return {"status": "successful", "suggestedWidth": layout.pop("suggestedWidth"), "response": card, "message": message,
1867
+ "metadata": {"profile": self.config.profile, "schemaVersion": "1.5", "inputIds": self.input_ids,
1868
+ "choiceValues": self.choice_values, "layout": layout, "warnings": warnings,
1869
+ "normalizations": self.ctx.normalizations}}
1870
+
1871
+
1872
+ NATIVE_CHARTS = {"pie": "Chart.Pie", "donut": "Chart.Donut", "bar": "Chart.VerticalBar", "horizontal_bar": "Chart.HorizontalBar",
1873
+ "grouped_bar": "Chart.VerticalBar.Grouped", "stacked_bar": "Chart.VerticalBar.Grouped",
1874
+ "stacked_horizontal_bar": "Chart.HorizontalBar.Stacked", "line": "Chart.Line", "gauge": "Chart.Gauge"}
1875
+
1876
+
1877
+ def render_card(request, *, config: BuilderConfig | None = None) -> dict:
1878
+ """Accept an element, list, document, or JSON string and return the tool envelope.
1879
+
1880
+ Invalid or unusual input is repaired where it can be done without inventing
1881
+ facts; the repairs are listed in metadata. status="error" is returned only
1882
+ for unreadable requests, empty results and safety limits.
1883
+ """
1884
+ builder = CardBuilder(config or BuilderConfig())
1885
+ try:
1886
+ return builder.build(request)
1887
+ except CardError as exc:
1888
+ detail = exc.detail
1889
+ message = f"{detail['path']}: {detail['message']}"
1890
+ return {"status": "error", "suggestedWidth": None, "response": message, "message": message + " Fix this and call the tool once more.",
1891
+ "errors": [detail], "metadata": {"warnings": builder.ctx.warnings, "normalizations": builder.ctx.normalizations}}