@morit/cli 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sdk_version": "1.7.12",
2
+ "sdk_version": "1.7.14",
3
3
  "manifest_schema_versions": [1, 2],
4
4
  "recommended_schema_version": 2,
5
5
  "package_content_type": "application/vnd.morit.plugin+zip",
@@ -198,6 +198,46 @@
198
198
  "empty"
199
199
  ],
200
200
  "ui_runtime": {
201
+ "common_props": ["alignment", "aspect_ratio", "background_color", "border_color", "border_radius", "border_width", "clip", "disabled_background_color", "elevation", "enabled", "exclude_semantics", "foreground_color", "gradient_colors", "height", "margin", "max_height", "max_width", "min_height", "min_width", "opacity", "padding", "selected", "selected_background_color", "selected_foreground_color", "semantic_label", "tooltip", "width"],
202
+ "common_props_excluded_components": ["positioned"],
203
+ "component_props": {
204
+ "column": ["cross_axis_alignment", "main_axis_alignment", "main_axis_size", "spacing"],
205
+ "row": ["cross_axis_alignment", "main_axis_alignment", "main_axis_size", "spacing", "stack_at"],
206
+ "wrap": ["spacing"],
207
+ "grid": ["columns", "empty_text", "item_height", "item_key", "limit", "min_item_width", "page_size", "page_state_key", "source", "spacing"],
208
+ "stack": [],
209
+ "positioned": ["bottom", "height", "left", "padding", "right", "tooltip", "top", "width"],
210
+ "scroll": ["scroll_direction"],
211
+ "padding": [],
212
+ "center": [],
213
+ "expanded": ["flex"],
214
+ "surface": ["color", "pressed_color", "spacing"],
215
+ "card": ["color", "icon", "pressed_color", "spacing", "subtitle", "title", "tone"],
216
+ "section": ["icon", "spacing", "subtitle", "title"],
217
+ "text": ["align", "color", "font_size", "font_weight", "line_height", "max_lines", "style", "text"],
218
+ "icon": ["color", "icon", "size", "tone"],
219
+ "image": ["asset", "fit", "url"],
220
+ "avatar": ["asset", "fit", "size", "url"],
221
+ "badge": ["label"],
222
+ "divider": [],
223
+ "spacer": ["size"],
224
+ "button": ["full_width", "icon", "label", "style"],
225
+ "chip": ["icon", "label"],
226
+ "metric": ["icon", "label", "supporting", "tone", "value"],
227
+ "progress": ["label", "value"],
228
+ "list": ["dense", "empty_text", "item_key", "limit", "min_item_width", "page_size", "page_state_key", "scroll_direction", "source", "spacing"],
229
+ "timeline": ["dense", "empty_text", "item_key", "limit", "source"],
230
+ "calendar": ["date_key", "persist", "source", "state_key", "title", "title_key"],
231
+ "chart": ["chart_type", "show_legend", "source", "title", "x_key", "y_key"],
232
+ "table": ["columns", "source", "title"],
233
+ "form": ["spacing", "submit_label"],
234
+ "dialog": ["icon", "label", "title"],
235
+ "sheet": ["icon", "label", "title"],
236
+ "field": ["error_text", "input_type", "label", "max_length", "max_lines", "maximum", "min_length", "minimum", "persist", "placeholder", "required", "state_key", "suggestions", "suggestions_source"],
237
+ "select": ["error_text", "label", "options", "options_source", "persist", "required", "searchable", "state_key"],
238
+ "switch": ["error_text", "label", "persist", "required", "state_key", "supporting"],
239
+ "empty": ["icon", "supporting", "text", "title"]
240
+ },
201
241
  "schema_version": 2,
202
242
  "config_fields": [
203
243
  "ui_schema",
@@ -210,7 +250,8 @@
210
250
  "initial_state",
211
251
  "data_sources",
212
252
  "a2ui",
213
- "view"
253
+ "view",
254
+ "bottom_bar"
214
255
  ],
215
256
  "node_fields": ["id", "type", "props", "children", "action", "visible_when"],
216
257
  "prop_fields": [
@@ -293,7 +334,11 @@
293
334
  ,"suggestions_source"
294
335
  ,"searchable"
295
336
  ,"options_source"
296
- ,"submit_label"
337
+ ,"submit_label",
338
+ "gradient_colors", "item_key", "font_size", "font_weight", "line_height", "selected_background_color", "selected_foreground_color", "disabled_background_color", "pressed_color",
339
+ "page_size",
340
+ "page_state_key",
341
+ "item_height"
297
342
  ],
298
343
  "app_bar_fields": ["title", "subtitle", "center_title", "pinned", "leading", "actions"],
299
344
  "navigation_fields": ["type", "items", "selected_state_key", "persist", "label_behavior", "rail_breakpoint"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morit/cli",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "description": "Official Morit Developer CLI for Cloud Projects, validation, builds, and deployments",
5
5
  "type": "module",
6
6
  "bin": {
package/src/preview.js CHANGED
@@ -99,6 +99,9 @@ function nodeHtml(node) {
99
99
  }
100
100
  if (type === "switch") return '<label class="ui-switch"' + attributes + '><input type="checkbox" data-state-key="' + escape(props.state_key) + '"' + (props.required ? " required" : "") + "> " + label + "</label>";
101
101
  if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
102
+ if (["list", "grid"].includes(type) && props.source) {
103
+ return '<section class="ui-collection" data-collection="' + jsonAttribute(node) + '"><div class="collection-items"></div><nav class="collection-pages"></nav></section>';
104
+ }
102
105
  if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
103
106
  return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
104
107
  }
@@ -198,6 +201,35 @@ function previewRuntime() {
198
201
  }
199
202
 
200
203
  function update() {
204
+ for (const collection of article.querySelectorAll('[data-collection]')) {
205
+ const node = JSON.parse(collection.dataset.collection);
206
+ const props = node.props || {};
207
+ const values = resolvePreviewPath(roots(), props.source);
208
+ const rows = Array.isArray(values) ? values.slice(0, props.page_size ? 10000 : (props.limit || 20)) : [];
209
+ const size = props.page_size || Math.max(rows.length, 1);
210
+ const pages = Math.max(1, Math.ceil(rows.length / size));
211
+ const page = Math.max(0, Math.min(pages - 1, Number(state[props.page_state_key]) || 0));
212
+ const content = collection.querySelector('.collection-items');
213
+ const pageRows = rows.slice(page * size, (page + 1) * size);
214
+ const signature = JSON.stringify([pageRows, state]);
215
+ if (collection._signature !== signature) {
216
+ collection._signature = signature;
217
+ content.innerHTML = pageRows.length ? pageRows.map(item => nodeHtml(resolvePreviewValue(node.children[0], { ...roots(), item }))).join('') : '<p>' + escape(props.empty_text || '표시할 내용이 없어요.') + '</p>';
218
+ }
219
+ content.style.display = 'grid'; content.style.gap = (props.spacing || 12) + 'px';
220
+ content.style.gridTemplateColumns = node.type === 'grid' ? 'repeat(auto-fit,minmax(min(100%,' + (props.min_item_width || 148) + 'px),1fr))' : '1fr';
221
+ if (props.height) { content.style.maxHeight = props.height + 'px'; content.style.overflow = 'auto'; }
222
+ const nav = collection.querySelector('.collection-pages');
223
+ nav.replaceChildren();
224
+ if (props.page_size && pages > 1) {
225
+ for (const [label, next, disabled] of [['이전 페이지', page - 1, page === 0], ['다음 페이지', page + 1, page + 1 >= pages]]) {
226
+ const button = document.createElement('button'); button.textContent = label; button.disabled = disabled;
227
+ button.onclick = () => { state[props.page_state_key] = next; update(); };
228
+ nav.append(button);
229
+ if (next < page) nav.append(Object.assign(document.createElement('span'), { textContent: (page + 1) + ' / ' + pages }));
230
+ }
231
+ }
232
+ }
201
233
  for (const element of article.querySelectorAll("[data-visible]")) {
202
234
  element.hidden = !evaluatePreviewCondition(parse(element, "visible"), roots());
203
235
  }
@@ -328,6 +360,10 @@ function previewRuntime() {
328
360
  }
329
361
 
330
362
  const RUNTIME_SCRIPT = [
363
+ escape.toString(),
364
+ jsonAttribute.toString(),
365
+ nodeAttributes.toString(),
366
+ nodeHtml.toString(),
331
367
  resolvePreviewPath.toString(),
332
368
  previewValuesEqual.toString(),
333
369
  resolvePreviewValue.toString(),
@@ -345,6 +381,7 @@ const CSS = [
345
381
  ".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
346
382
  ".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
347
383
  "h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
384
+ ".bottom-bar{position:sticky;bottom:0;z-index:2;padding:12px var(--space);background:var(--surface);border-top:1px solid var(--outline-variant)}.collection-pages{display:flex;align-items:center;justify-content:center;gap:12px}.collection-pages button{min-height:48px}.collection-items>*{content-visibility:auto;contain-intrinsic-size:auto 80px}",
348
385
  "[hidden]{display:none!important}[data-action]{cursor:pointer}.runtime-inspector{margin:0 var(--space) var(--space);border-top:1px solid var(--outline-variant);padding-top:8px}.runtime-inspector pre{max-height:260px;overflow:auto;white-space:pre-wrap;font:12px/1.4 ui-monospace,monospace;color:var(--on-surface-variant)}",
349
386
  ].join("");
350
387
 
@@ -356,7 +393,7 @@ export function renderPreviewHtml(manifest) {
356
393
  const safeConfig = JSON.stringify(config).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
357
394
  return '<article class="screen" data-extension="' + escape(extension.id) + '" style="' + themeVariables(config) + '">' +
358
395
  '<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
359
- chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + '</main><details class="runtime-inspector"><summary>Runtime Inspector</summary><pre></pre></details><script type="application/json" class="runtime-config">' + safeConfig + "</script></article>";
396
+ chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + '</main>' + (config.bottom_bar ? '<footer class="bottom-bar">' + nodeHtml(config.bottom_bar) + '</footer>' : '') + '<details class="runtime-inspector"><summary>Runtime Inspector</summary><pre></pre></details><script type="application/json" class="runtime-config">' + safeConfig + "</script></article>";
360
397
  }).join("")
361
398
  : '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
362
399
  const title = escape(manifest.name);
package/src/workspace.js CHANGED
@@ -152,7 +152,7 @@ export class LocalWorkspace {
152
152
  version: "1.0.0",
153
153
  cloud_project_id: null,
154
154
  required_secrets: [],
155
- min_morit_version: "1.7.12",
155
+ min_morit_version: "1.7.14",
156
156
  max_morit_version: "1.999.999",
157
157
  permissions: [],
158
158
  capabilities: [],
@@ -738,6 +738,20 @@ function validateMetadata(pluginId, name, publisher, description) {
738
738
  if (typeof description !== "string" || description.length > 4000) throw new Error("description must not exceed 4000 characters");
739
739
  }
740
740
 
741
+ function usesUi1714(node) {
742
+ if (!node || typeof node !== "object") return false;
743
+ const props = node.props || {};
744
+ return ["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key)) || (node.type === "grid" && props.source !== undefined) || (node.children || []).some(usesUi1714);
745
+ }
746
+
747
+ function usesUi1713(node) {
748
+ if (!node || typeof node !== "object") return false;
749
+ const props = node.props || {};
750
+ return ["gradient_colors", "item_key", "font_size", "font_weight", "line_height", "selected_background_color", "selected_foreground_color", "disabled_background_color", "pressed_color"].some(key => Object.hasOwn(props, key))
751
+ || (node.type === "list" && props.scroll_direction === "horizontal")
752
+ || (Array.isArray(node.children) && node.children.some(usesUi1713));
753
+ }
754
+
741
755
  function validateManifest(manifest, files) {
742
756
  assertObject(manifest, "manifest");
743
757
  rejectUnknownFields(manifest, MANIFEST_FIELDS, "manifest");
@@ -782,6 +796,9 @@ function validateManifest(manifest, files) {
782
796
  if (!["purge", "retain"].includes(manifest.data_policy)) throw new Error("manifest.data_policy must be purge or retain");
783
797
  const capabilities = objectCollection(manifest.capabilities, "manifest.capabilities", 64);
784
798
  const uiExtensions = objectCollection(manifest.ui_extensions, "manifest.ui_extensions", 64);
799
+ const minimum = manifest.min_morit_version.split(".").map(Number);
800
+ if ((minimum[0] < 1 || (minimum[0] === 1 && (minimum[1] < 7 || (minimum[1] === 7 && minimum[2] < 14)))) && uiExtensions.some(extension => extension.config?.bottom_bar != null || usesUi1714(extension.config?.view))) throw new Error("pagination and bottom_bar require min_morit_version 1.7.14 or newer");
801
+ if ((minimum[0] < 1 || (minimum[0] === 1 && (minimum[1] < 7 || (minimum[1] === 7 && minimum[2] < 13)))) && uiExtensions.some(extension => usesUi1713(extension.config?.view))) throw new Error("new UI properties require min_morit_version 1.7.13 or newer");
785
802
  const credentials = objectCollection(manifest.credentials === undefined ? [] : manifest.credentials, "manifest.credentials", 16);
786
803
  const slashCommands = objectCollection(manifest.slash_commands === undefined ? [] : manifest.slash_commands, "manifest.slash_commands", 32);
787
804
  const connectors = objectCollection(manifest.connectors === undefined ? [] : manifest.connectors, "manifest.connectors", 32);
@@ -1310,7 +1327,7 @@ function validateUiConfig(extension, capabilities, routeIds, files, hasStorage =
1310
1327
  if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1311
1328
  validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
1312
1329
  validateResponseComponent(config, extension.point === "response");
1313
- for (const asset of uiRuntimeAssetPaths(config.view)) {
1330
+ for (const asset of [...uiRuntimeAssetPaths(config.view), ...uiRuntimeAssetPaths(config.bottom_bar)]) {
1314
1331
  if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
1315
1332
  }
1316
1333
  return;
@@ -1490,7 +1507,9 @@ function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new
1490
1507
  validateUiAppBar(config.app_bar, executable, routes, sourceIds, stateKeys);
1491
1508
  validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
1492
1509
  assertObject(config.view, "UI Runtime v2 view");
1493
- validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
1510
+ const nodeCounter = { value: 0 };
1511
+ validateUiNode(config.view, 0, nodeCounter, executable, routes, sourceIds, stateKeys, null);
1512
+ if (config.bottom_bar != null) validateUiNode(config.bottom_bar, 0, nodeCounter, executable, routes, sourceIds, stateKeys, null, "bottom_bar");
1494
1513
  validateUiCapabilityBindings(config, sources, capabilitySchemas);
1495
1514
  }
1496
1515
 
@@ -1569,6 +1588,13 @@ function validateThemeContrast(theme) {
1569
1588
  function collectThemeWarnings(manifest) {
1570
1589
  const warnings = [];
1571
1590
  for (const extension of manifest.ui_extensions || []) {
1591
+ function visit(node, path) {
1592
+ if (!node || typeof node !== "object") return;
1593
+ const allowed = contract.ui_runtime.component_props[node.type] || [];
1594
+ for (const key of Object.keys(node.props || {})) if (!( !contract.ui_runtime.common_props_excluded_components.includes(node.type) && contract.ui_runtime.common_props.includes(key)) && !allowed.includes(key)) warnings.push(`${path}.props.${key}: ignored by ${node.type}; remove or move to a supported component`);
1595
+ (node.children || []).forEach((child, index) => visit(child, `${path}.children[${index}]`));
1596
+ }
1597
+ for (const field of ["view", "bottom_bar"]) visit(extension.config?.[field], `${extension.id}.${field}`);
1572
1598
  const theme = extension?.config?.theme;
1573
1599
  if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
1574
1600
  for (const [mode, variant] of [["base", theme], ["light", theme.light], ["dark", theme.dark]]) {
@@ -1653,7 +1679,16 @@ function uiRuntimeAssetPaths(view) {
1653
1679
  return result;
1654
1680
  }
1655
1681
 
1656
- function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType) {
1682
+ function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType, path = "view") {
1683
+ try { validateUiNodeAt(node, depth, counter, executable, routes, sources, stateKeys, parentType, path); }
1684
+ catch (error) {
1685
+ if (/^(view[.:]|bottom_bar)/.test(error.message)) throw error;
1686
+ const key = Object.keys(node?.props || {}).find(key => !contract.ui_runtime.prop_fields.includes(key) || error.message.split(" ").includes(key));
1687
+ throw new Error(`${key ? `${path}.props.${key}` : path}: ${error.message}`);
1688
+ }
1689
+ }
1690
+
1691
+ function validateUiNodeAt(node, depth, counter, executable, routes, sources, stateKeys, parentType, path) {
1657
1692
  const nodeKeys = new Set(contract.ui_runtime.node_fields);
1658
1693
  rejectUnknownFields(node, nodeKeys, "UI component");
1659
1694
  counter.value += 1;
@@ -1662,6 +1697,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
1662
1697
  if (node.id != null && (typeof node.id !== "string" || !UI_IDENTIFIER.test(node.id))) throw new Error("UI component id is invalid");
1663
1698
  const props = node.props === undefined ? {} : node.props;
1664
1699
  validateUiProps(node.type, props, sources, stateKeys);
1700
+ if (node.type === "grid" && props.source !== undefined && node.children?.length !== 1) throw new Error("data grid requires one item template");
1665
1701
  validateUiCondition(node.visible_when, stateKeys, sources);
1666
1702
  if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
1667
1703
  throw new Error("UI action requires an interactive component");
@@ -1681,18 +1717,26 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
1681
1717
  }
1682
1718
  if (node.type === "positioned" && parentType !== "stack") throw new Error("positioned UI component requires a stack parent");
1683
1719
  if (node.type === "expanded" && !["row", "column"].includes(parentType)) throw new Error("expanded UI component requires a row or column parent");
1684
- for (const child of children) {
1720
+ for (const [index, child] of children.entries()) {
1685
1721
  assertObject(child, "UI component child");
1686
- validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type);
1722
+ validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type, `${path}.children[${index}]`);
1687
1723
  }
1688
1724
  }
1689
1725
 
1690
1726
  function validateUiProps(nodeType, props, sources, stateKeys) {
1727
+ if (["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key)) && !["list", "grid"].includes(nodeType)) throw new Error("pagination requires a list or grid");
1728
+ if (props.item_height !== undefined && nodeType !== "grid") throw new Error("item_height requires a data grid");
1729
+ if (nodeType === "grid" && props.source === undefined && ["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key))) throw new Error("grid pagination requires source");
1730
+ if (Object.hasOwn(props, "page_size") !== Object.hasOwn(props, "page_state_key")) throw new Error("page_size and page_state_key must be supplied together");
1731
+ if (props.page_size !== undefined && (!Number.isInteger(props.page_size) || props.page_size < 1 || props.page_size > 100)) throw new Error("UI page_size is invalid");
1732
+ if (props.page_state_key !== undefined && !stateKeys.has(props.page_state_key)) throw new Error("UI page_state_key references unknown state");
1733
+ if (props.item_height !== undefined) validateUiNumber(props.item_height, "UI item_height", 48, 4096);
1691
1734
  const allowed = new Set(contract.ui_runtime.prop_fields);
1692
1735
  rejectUnknownFields(props, allowed, "UI component props");
1693
1736
  const surfaceProps = new Set([
1737
+ "selected",
1694
1738
  "spacing", "padding", "margin", "width", "height", "min_width", "max_width", "min_height", "max_height",
1695
- "alignment", "color", "background_color", "foreground_color", "border_color", "border_width",
1739
+ "alignment", "color", "gradient_colors", "selected_background_color", "selected_foreground_color", "disabled_background_color", "pressed_color", "background_color", "foreground_color", "border_color", "border_width",
1696
1740
  "border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
1697
1741
  ]);
1698
1742
  if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
@@ -1726,7 +1770,15 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1726
1770
  if (props.main_axis_alignment !== undefined && !["start", "end", "center", "space_between", "space_around", "space_evenly"].includes(props.main_axis_alignment)) throw new Error("UI main-axis alignment is invalid");
1727
1771
  if (props.cross_axis_alignment !== undefined && !["start", "end", "center", "stretch", "baseline"].includes(props.cross_axis_alignment)) throw new Error("UI cross-axis alignment is invalid");
1728
1772
  if (props.main_axis_size !== undefined && !["min", "max"].includes(props.main_axis_size)) throw new Error("UI main-axis size is invalid");
1729
- for (const key of ["color", "background_color", "foreground_color", "border_color"]) if (props[key] !== undefined) validateUiColor(props[key], `UI ${key}`);
1773
+ if (props.font_size !== undefined) validateUiNumber(props.font_size, "UI font_size", 10, 64);
1774
+ if (props.line_height !== undefined) validateUiNumber(props.line_height, "UI line_height", 1, 2.5);
1775
+ if (props.font_weight !== undefined && (!Number.isInteger(props.font_weight) || props.font_weight < 100 || props.font_weight > 900 || props.font_weight % 100)) throw new Error("invalid UI font_weight");
1776
+ if (props.gradient_colors !== undefined) {
1777
+ if (!Array.isArray(props.gradient_colors) || props.gradient_colors.length < 2 || props.gradient_colors.length > 8) throw new Error("invalid UI gradient_colors: expected 2–8 colors");
1778
+ for (const color of props.gradient_colors) validateUiColor(color, "UI gradient_colors");
1779
+ }
1780
+ if (props.item_key !== undefined && (typeof props.item_key !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(props.item_key))) throw new Error("invalid UI item_key");
1781
+ for (const key of ["color", "background_color", "foreground_color", "border_color", "selected_background_color", "selected_foreground_color", "disabled_background_color", "pressed_color"]) if (props[key] !== undefined) validateUiColor(props[key], `UI ${key}`);
1730
1782
  if (props.border_width !== undefined) validateUiNumber(props.border_width, "UI border width", 0, 8);
1731
1783
  if (props.border_radius !== undefined) validateUiNumber(props.border_radius, "UI border radius", 0, 64);
1732
1784
  if (props.elevation !== undefined) validateUiNumber(props.elevation, "UI elevation", 0, 24);
@@ -1748,8 +1800,8 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1748
1800
  )) throw new Error("positioned UI component over-constrains an axis");
1749
1801
  if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
1750
1802
  if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
1751
- if (nodeType !== "scroll" && ["scroll_direction", "shrink_wrap"].some((key) => props[key] !== undefined)) throw new Error("scroll properties require a scroll UI component");
1752
- if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType)) {
1803
+ if ((nodeType !== "scroll" && props.shrink_wrap !== undefined) || (!["scroll", "list"].includes(nodeType) && props.scroll_direction !== undefined)) throw new Error("scroll properties require a scroll UI component");
1804
+ if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType) || (nodeType === "grid" && props.source !== undefined)) {
1753
1805
  if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
1754
1806
  }
1755
1807
  const chartType = props.chart_type === undefined ? "bar" : props.chart_type;