paseo-plugin-genui 0.0.0-stage → 0.1.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.
Files changed (375) hide show
  1. package/LICENSE +202 -0
  2. package/OVERVIEW.md +21 -0
  3. package/README.md +77 -2
  4. package/assets/fonts/Excalifont-OFL.txt +109 -0
  5. package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +1 -0
  8. package/assets/screenshot-chat.png +0 -0
  9. package/assets/screenshot-gallery.png +0 -0
  10. package/assets/screenshot-playground.png +0 -0
  11. package/client/components/content.tsx +468 -0
  12. package/client/components/data.tsx +967 -0
  13. package/client/components/dev.tsx +998 -0
  14. package/client/components/diagram.tsx +513 -0
  15. package/client/components/inputs.tsx +994 -0
  16. package/client/components/layout.tsx +239 -0
  17. package/client/components/media.tsx +428 -0
  18. package/client/components/project.tsx +641 -0
  19. package/client/components/widgets.tsx +836 -0
  20. package/client/gallery.tsx +449 -0
  21. package/client/live-ui.tsx +525 -0
  22. package/client/loaders.ts +342 -0
  23. package/client/markdown.tsx +172 -0
  24. package/client/node-renderer.tsx +149 -0
  25. package/client/render.tsx +186 -0
  26. package/client/runtime.ts +103 -0
  27. package/client/settings.tsx +57 -0
  28. package/client/web.ts +186 -0
  29. package/gallery/Acceptance-baseline.ui +1 -0
  30. package/gallery/Acceptance-checklist.ui +14 -0
  31. package/gallery/Acceptance-evidence.ui +9 -0
  32. package/gallery/Acceptance.ui +1 -0
  33. package/gallery/Badge.ui +3 -0
  34. package/gallery/Benchmark-context.ui +8 -0
  35. package/gallery/Benchmark-hyperfine.ui +1 -0
  36. package/gallery/Benchmark-runs-list.ui +9 -0
  37. package/gallery/Benchmark-runs.ui +1 -0
  38. package/gallery/Benchmark-vitest.ui +1 -0
  39. package/gallery/Benchmark.ui +9 -0
  40. package/gallery/Board-bb-tasks.ui +1 -0
  41. package/gallery/Board-gh-issues.ui +1 -0
  42. package/gallery/Board-gh-prs.ui +1 -0
  43. package/gallery/Board.ui +12 -0
  44. package/gallery/Button-destructive.ui +4 -0
  45. package/gallery/Button.ui +5 -0
  46. package/gallery/Calendar.ui +15 -0
  47. package/gallery/CalendarHeatmap-short.ui +11 -0
  48. package/gallery/CalendarHeatmap.ui +4 -0
  49. package/gallery/Callout.ui +4 -0
  50. package/gallery/Card.ui +4 -0
  51. package/gallery/Carousel.ui +8 -0
  52. package/gallery/Changes-paths.ui +1 -0
  53. package/gallery/Changes-uncommitted.ui +1 -0
  54. package/gallery/Changes.ui +1 -0
  55. package/gallery/Chart-area.ui +3 -0
  56. package/gallery/Chart-funnel.ui +3 -0
  57. package/gallery/Chart-heatmap.ui +11 -0
  58. package/gallery/Chart-pie.ui +3 -0
  59. package/gallery/Chart-radar.ui +3 -0
  60. package/gallery/Chart-scatter.ui +4 -0
  61. package/gallery/Chart-stacked.ui +5 -0
  62. package/gallery/Chart.ui +3 -0
  63. package/gallery/Checklist.ui +6 -0
  64. package/gallery/Choice.ui +8 -0
  65. package/gallery/Code.ui +3 -0
  66. package/gallery/Commits-limit.ui +1 -0
  67. package/gallery/Commits.ui +1 -0
  68. package/gallery/Compare-conditions.ui +4 -0
  69. package/gallery/Compare-three.ui +7 -0
  70. package/gallery/Compare.ui +4 -0
  71. package/gallery/Coverage-go.ui +1 -0
  72. package/gallery/Coverage-lcov.ui +1 -0
  73. package/gallery/Coverage-python.ui +1 -0
  74. package/gallery/Coverage.ui +1 -0
  75. package/gallery/DateInput.ui +5 -0
  76. package/gallery/Decision-percent.ui +10 -0
  77. package/gallery/Decision.ui +12 -0
  78. package/gallery/Dependencies-audit.ui +1 -0
  79. package/gallery/Dependencies-both.ui +1 -0
  80. package/gallery/Dependencies-list.ui +7 -0
  81. package/gallery/Dependencies-pip.ui +1 -0
  82. package/gallery/Dependencies-pnpm.ui +1 -0
  83. package/gallery/Dependencies.ui +1 -0
  84. package/gallery/Details.ui +4 -0
  85. package/gallery/Diagram-flow.ui +16 -0
  86. package/gallery/Diagram-sequence.ui +15 -0
  87. package/gallery/Diagram.ui +16 -0
  88. package/gallery/Diff.ui +3 -0
  89. package/gallery/Divider.ui +5 -0
  90. package/gallery/EditableTable.ui +9 -0
  91. package/gallery/Entity-list.ui +6 -0
  92. package/gallery/Entity.ui +8 -0
  93. package/gallery/Environments.ui +1 -0
  94. package/gallery/Estimate-points.ui +6 -0
  95. package/gallery/Estimate.ui +8 -0
  96. package/gallery/File.ui +4 -0
  97. package/gallery/Findings-biome.ui +1 -0
  98. package/gallery/Findings-eslint.ui +1 -0
  99. package/gallery/Findings-oxlint.ui +1 -0
  100. package/gallery/Findings-review.ui +1 -0
  101. package/gallery/Findings-ruff.ui +1 -0
  102. package/gallery/Findings-sarif.ui +1 -0
  103. package/gallery/Findings-ship.ui +11 -0
  104. package/gallery/Findings-tsc.ui +1 -0
  105. package/gallery/Findings.ui +7 -0
  106. package/gallery/Flashcards.ui +3 -0
  107. package/gallery/FollowUps.ui +4 -0
  108. package/gallery/Form.ui +8 -0
  109. package/gallery/Gallery-links.ui +8 -0
  110. package/gallery/Gallery-strip.ui +14 -0
  111. package/gallery/Gallery.ui +10 -0
  112. package/gallery/Gauge.ui +7 -0
  113. package/gallery/Grid.ui +8 -0
  114. package/gallery/Hotspots-photo.ui +8 -0
  115. package/gallery/Hotspots.ui +11 -0
  116. package/gallery/Icon.ui +5 -0
  117. package/gallery/Image.ui +4 -0
  118. package/gallery/ImageSearch-aspects.ui +6 -0
  119. package/gallery/ImageSearch.ui +4 -0
  120. package/gallery/Input.ui +5 -0
  121. package/gallery/Json-api.ui +1 -0
  122. package/gallery/Json.ui +8 -0
  123. package/gallery/KeyValue.ui +3 -0
  124. package/gallery/List-dots.ui +8 -0
  125. package/gallery/List.ui +8 -0
  126. package/gallery/ListEditor.ui +5 -0
  127. package/gallery/Logs-jsonl.ui +1 -0
  128. package/gallery/Logs-lines.ui +11 -0
  129. package/gallery/Logs-python.ui +1 -0
  130. package/gallery/Logs.ui +1 -0
  131. package/gallery/Map-city.ui +8 -0
  132. package/gallery/Map.ui +8 -0
  133. package/gallery/Metric.ui +3 -0
  134. package/gallery/OptionCards-multi.ui +10 -0
  135. package/gallery/OptionCards.ui +10 -0
  136. package/gallery/Pipeline-checks.ui +1 -0
  137. package/gallery/Pipeline-passed.ui +1 -0
  138. package/gallery/Pipeline-running.ui +1 -0
  139. package/gallery/Pipeline-stages.ui +13 -0
  140. package/gallery/Pipeline.ui +1 -0
  141. package/gallery/Plan.ui +9 -0
  142. package/gallery/Progress.ui +4 -0
  143. package/gallery/Provenance-deploy.ui +6 -0
  144. package/gallery/Provenance-no-receipt.ui +1 -0
  145. package/gallery/Provenance.ui +1 -0
  146. package/gallery/PullRequest.ui +1 -0
  147. package/gallery/Quiz.ui +6 -0
  148. package/gallery/Radio.ui +5 -0
  149. package/gallery/Rating.ui +5 -0
  150. package/gallery/Roadmap-sprint.ui +8 -0
  151. package/gallery/Roadmap-year.ui +9 -0
  152. package/gallery/Roadmap.ui +12 -0
  153. package/gallery/Row.ui +3 -0
  154. package/gallery/SegmentedBar-status.ui +8 -0
  155. package/gallery/SegmentedBar.ui +8 -0
  156. package/gallery/Select.ui +5 -0
  157. package/gallery/Slider.ui +7 -0
  158. package/gallery/Snippet.ui +3 -0
  159. package/gallery/Sources-chips.ui +4 -0
  160. package/gallery/Sources.ui +8 -0
  161. package/gallery/Sparkline.ui +6 -0
  162. package/gallery/Stack.ui +7 -0
  163. package/gallery/StackTrace-aggregate.ui +1 -0
  164. package/gallery/StackTrace-browser.ui +1 -0
  165. package/gallery/StackTrace-go.ui +1 -0
  166. package/gallery/StackTrace-java.ui +1 -0
  167. package/gallery/StackTrace-python.ui +1 -0
  168. package/gallery/StackTrace-rust.ui +1 -0
  169. package/gallery/StackTrace.ui +1 -0
  170. package/gallery/Status.ui +9 -0
  171. package/gallery/Stepper.ui +5 -0
  172. package/gallery/Steps.ui +8 -0
  173. package/gallery/Swatches-long-names.ui +4 -0
  174. package/gallery/Swatches-picker.ui +5 -0
  175. package/gallery/Swatches.ui +10 -0
  176. package/gallery/Table.ui +7 -0
  177. package/gallery/Tabs.ui +8 -0
  178. package/gallery/Tests-junit.ui +1 -0
  179. package/gallery/Tests-vitest-junit.ui +1 -0
  180. package/gallery/Tests-vitest.ui +1 -0
  181. package/gallery/Tests.ui +9 -0
  182. package/gallery/Text.ui +4 -0
  183. package/gallery/TextArea.ui +5 -0
  184. package/gallery/Threads-ids.ui +1 -0
  185. package/gallery/Threads.ui +1 -0
  186. package/gallery/Timeline.ui +8 -0
  187. package/gallery/Timer.ui +4 -0
  188. package/gallery/Toggle.ui +5 -0
  189. package/gallery/Transcript-text.ui +1 -0
  190. package/gallery/Transcript-untimed.ui +1 -0
  191. package/gallery/Transcript-whisper.ui +1 -0
  192. package/gallery/Transcript.ui +1 -0
  193. package/gallery/Tree.ui +3 -0
  194. package/gallery/Treemap-du.ui +1 -0
  195. package/gallery/Treemap-esbuild.ui +1 -0
  196. package/gallery/Treemap-tree.ui +7 -0
  197. package/gallery/Treemap.ui +18 -0
  198. package/gallery/Walkthrough.ui +9 -0
  199. package/gallery/Waterfall-har.ui +1 -0
  200. package/gallery/Waterfall-trace.ui +1 -0
  201. package/gallery/Waterfall.ui +11 -0
  202. package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
  203. package/gallery/data/acceptance/coil-gate.json +153 -0
  204. package/gallery/data/acceptance/orbit-baseline.json +163 -0
  205. package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
  206. package/gallery/data/acceptance/shots/gate-light.png +0 -0
  207. package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
  208. package/gallery/data/activity.json +1 -0
  209. package/gallery/data/bb-tasks-orb.json +420 -0
  210. package/gallery/data/biome.json +229 -0
  211. package/gallery/data/compare/status-after.png +0 -0
  212. package/gallery/data/compare/status-before.png +0 -0
  213. package/gallery/data/compare/status-dark-2x.png +0 -0
  214. package/gallery/data/compare/status-rerender.png +0 -0
  215. package/gallery/data/coverage-py.json +1 -0
  216. package/gallery/data/coverage-summary.json +1 -0
  217. package/gallery/data/coverage.out +23 -0
  218. package/gallery/data/deploys.json +1 -0
  219. package/gallery/data/device-runs.json +285 -0
  220. package/gallery/data/du-node-modules.txt +359 -0
  221. package/gallery/data/esbuild-meta.json +292 -0
  222. package/gallery/data/eslint.json +266 -0
  223. package/gallery/data/eslint.sarif +343 -0
  224. package/gallery/data/findings-review.json +76 -0
  225. package/gallery/data/gh-issues-nextjs.json +293 -0
  226. package/gallery/data/gh-pr-checks.json +272 -0
  227. package/gallery/data/gh-prs-nextjs.json +419 -0
  228. package/gallery/data/gh-run-failed.json +1 -0
  229. package/gallery/data/gh-run-passed.json +1 -0
  230. package/gallery/data/gh-run-running.json +1 -0
  231. package/gallery/data/github-release.json +57 -0
  232. package/gallery/data/hyperfine-startup.json +187 -0
  233. package/gallery/data/lcov.info +234 -0
  234. package/gallery/data/logs/media-sync.log +127 -0
  235. package/gallery/data/logs/thumbnailer.jsonl +17 -0
  236. package/gallery/data/logs/vitest-run.log +89 -0
  237. package/gallery/data/npm-audit.json +672 -0
  238. package/gallery/data/npm-outdated.json +179 -0
  239. package/gallery/data/oxlint.json +156 -0
  240. package/gallery/data/pip-outdated.json +68 -0
  241. package/gallery/data/pnpm-outdated.json +58 -0
  242. package/gallery/data/provenance/devicectl-install.json +35 -0
  243. package/gallery/data/provenance/install-timeout.log +4 -0
  244. package/gallery/data/provenance/iphone-install.json +72 -0
  245. package/gallery/data/provenance/iphone-no-receipt.json +57 -0
  246. package/gallery/data/provenance/live-home.jpg +0 -0
  247. package/gallery/data/provenance/live-probe.txt +10 -0
  248. package/gallery/data/provenance/xcodebuild-2811.log +19 -0
  249. package/gallery/data/provenance/xcodebuild-4712.log +19 -0
  250. package/gallery/data/pytest-junit.xml +64 -0
  251. package/gallery/data/python-org-trace.json +35 -0
  252. package/gallery/data/python-org.har +31 -0
  253. package/gallery/data/retry.ts +13 -0
  254. package/gallery/data/ruff.json +261 -0
  255. package/gallery/data/src/durations.py +63 -0
  256. package/gallery/data/src/json-logic.ts +156 -0
  257. package/gallery/data/src/mapMath.ts +135 -0
  258. package/gallery/data/src/semver/bump.go +73 -0
  259. package/gallery/data/traces/browser-error.json +5 -0
  260. package/gallery/data/traces/go/main.go +27 -0
  261. package/gallery/data/traces/go-panic.txt +16 -0
  262. package/gallery/data/traces/java-caused-by.txt +15 -0
  263. package/gallery/data/traces/node-aggregate.txt +30 -0
  264. package/gallery/data/traces/node-cause.txt +21 -0
  265. package/gallery/data/traces/py/deploy.json +5 -0
  266. package/gallery/data/traces/py/deploy.py +17 -0
  267. package/gallery/data/traces/py/settings.py +19 -0
  268. package/gallery/data/traces/python-chain.txt +30 -0
  269. package/gallery/data/traces/rust/main.rs +11 -0
  270. package/gallery/data/traces/rust-panic.txt +16 -0
  271. package/gallery/data/traces/shop/cart.mjs +19 -0
  272. package/gallery/data/traces/shop/pricing.mjs +12 -0
  273. package/gallery/data/traces/shop/status.mjs +9 -0
  274. package/gallery/data/traces/web/app.js +11 -0
  275. package/gallery/data/traces/web/index.html +1 -0
  276. package/gallery/data/traces/web/search.js +10 -0
  277. package/gallery/data/transcript/call-noisy.wav +0 -0
  278. package/gallery/data/transcript/call.json +362 -0
  279. package/gallery/data/transcript/call.wav +0 -0
  280. package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
  281. package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
  282. package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
  283. package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
  284. package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
  285. package/gallery/data/tsc.txt +4 -0
  286. package/gallery/data/vitest-bench.json +119 -0
  287. package/gallery/data/vitest-junit.xml +133 -0
  288. package/gallery/data/vitest-report.json +612 -0
  289. package/gallery/images/bell.svg +10 -0
  290. package/gallery/images/bike.svg +47 -0
  291. package/gallery/images/footer-planet.webp +0 -0
  292. package/gallery/images/front-light.svg +13 -0
  293. package/gallery/images/golden-hour.webp +0 -0
  294. package/gallery/images/option-flowers.webp +0 -0
  295. package/gallery/images/option-hilltop.webp +0 -0
  296. package/gallery/images/option-meadow.webp +0 -0
  297. package/gallery/images/option-sky.webp +0 -0
  298. package/gallery/images/u-lock.svg +10 -0
  299. package/index.client.tsx +164 -0
  300. package/index.server.ts +216 -0
  301. package/package.json +50 -4
  302. package/paseo-plugin.json +14 -0
  303. package/server/data-source.ts +407 -0
  304. package/server/project.ts +981 -0
  305. package/server/web/address.ts +57 -0
  306. package/server/web/cache.ts +85 -0
  307. package/server/web/commons.ts +126 -0
  308. package/server/web/geocode.ts +40 -0
  309. package/server/web/image.ts +35 -0
  310. package/server/web/lookups.ts +248 -0
  311. package/server/web/net.ts +210 -0
  312. package/shared/core/acceptance.ts +744 -0
  313. package/shared/core/ast.ts +76 -0
  314. package/shared/core/benchmark.ts +743 -0
  315. package/shared/core/catalog.ts +733 -0
  316. package/shared/core/compare.ts +225 -0
  317. package/shared/core/coverage.ts +456 -0
  318. package/shared/core/data.ts +334 -0
  319. package/shared/core/decisions.ts +527 -0
  320. package/shared/core/dependencies.ts +604 -0
  321. package/shared/core/diagnostics.ts +24 -0
  322. package/shared/core/environments.ts +212 -0
  323. package/shared/core/evaluate.ts +737 -0
  324. package/shared/core/fields.ts +66 -0
  325. package/shared/core/findings.ts +1172 -0
  326. package/shared/core/form.ts +69 -0
  327. package/shared/core/index.ts +44 -0
  328. package/shared/core/junit.ts +252 -0
  329. package/shared/core/lexer.ts +275 -0
  330. package/shared/core/logs.ts +496 -0
  331. package/shared/core/markdown.ts +2374 -0
  332. package/shared/core/parser.ts +355 -0
  333. package/shared/core/perf-units.ts +228 -0
  334. package/shared/core/pipeline.ts +679 -0
  335. package/shared/core/project.ts +472 -0
  336. package/shared/core/provenance.ts +725 -0
  337. package/shared/core/stack-trace.ts +718 -0
  338. package/shared/core/streaming.ts +136 -0
  339. package/shared/core/test-results.ts +562 -0
  340. package/shared/core/thread-activity.ts +203 -0
  341. package/shared/core/tracking.ts +887 -0
  342. package/shared/core/transcript.ts +1364 -0
  343. package/shared/core/treemap.ts +446 -0
  344. package/shared/core/types.ts +70 -0
  345. package/shared/core/validate.ts +574 -0
  346. package/shared/core/waterfall.ts +536 -0
  347. package/shared/diagram/flowchart.ts +205 -0
  348. package/shared/diagram/layout.ts +440 -0
  349. package/shared/diagram/markdown.ts +135 -0
  350. package/shared/diagram/sequence.ts +122 -0
  351. package/shared/diagram/zoom.ts +114 -0
  352. package/shared/fallback.ts +120 -0
  353. package/shared/genui.test.ts +122 -0
  354. package/shared/instructions.ts +144 -0
  355. package/shared/logic/calendarMath.ts +130 -0
  356. package/shared/logic/chartMath.ts +289 -0
  357. package/shared/logic/choose-logic.ts +196 -0
  358. package/shared/logic/compare-logic.ts +270 -0
  359. package/shared/logic/content-model.ts +227 -0
  360. package/shared/logic/environments-logic.ts +53 -0
  361. package/shared/logic/explain-logic.ts +315 -0
  362. package/shared/logic/fieldLogic.ts +138 -0
  363. package/shared/logic/gallery-search.ts +71 -0
  364. package/shared/logic/icons.ts +322 -0
  365. package/shared/logic/json-logic.ts +153 -0
  366. package/shared/logic/mapMath.ts +133 -0
  367. package/shared/logic/project-logic.ts +39 -0
  368. package/shared/logic/references-model.ts +201 -0
  369. package/shared/logic/sketch.ts +165 -0
  370. package/shared/logic/supersession.ts +77 -0
  371. package/shared/logic/values.ts +133 -0
  372. package/shared/logic/widget-logic.ts +163 -0
  373. package/shared/rpc.ts +252 -0
  374. package/shared/segment.ts +81 -0
  375. package/shared/settings.ts +12 -0
@@ -0,0 +1,994 @@
1
+ import { Icon, TextInput } from "@getpaseo/plugin/client/react-native";
2
+ import React, { useState } from "react";
3
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
4
+ import { formMessage } from "../../shared/core/form";
5
+ import type { Action, Scalar, UiNode, Value } from "../../shared/core/types";
6
+ import {
7
+ blankRow,
8
+ cardOptions,
9
+ followUpPrompts,
10
+ parseNumberInput,
11
+ sameColor,
12
+ setCell,
13
+ swatchColors,
14
+ tableColumns,
15
+ tableRows,
16
+ togglePick,
17
+ } from "../../shared/logic/choose-logic";
18
+ import {
19
+ addItem,
20
+ itemText,
21
+ radioOptions,
22
+ stepBy,
23
+ stepperValue,
24
+ stepRange,
25
+ } from "../../shared/logic/fieldLogic";
26
+ import { resolveIconName } from "../../shared/logic/icons";
27
+ import {
28
+ finiteNumber,
29
+ formatNumber,
30
+ isAction,
31
+ isBinding,
32
+ isScalar,
33
+ list,
34
+ nodes,
35
+ optionalText,
36
+ text,
37
+ type Props,
38
+ } from "../../shared/logic/values";
39
+ import { ErrorLine, RenderNodes, type GroupedButton } from "../node-renderer";
40
+ import { useUiRuntime } from "../runtime";
41
+ import { LoadedImageView } from "./content";
42
+
43
+ function FieldLabel({ label, value }: { label: string | null; value?: string }) {
44
+ const { theme } = useUiRuntime();
45
+ if (!label && !value) return null;
46
+ return (
47
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
48
+ {label ? (
49
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, fontWeight: "500" }}>{label}</Text>
50
+ ) : (
51
+ <View />
52
+ )}
53
+ {value !== undefined ? (
54
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontFamily: "monospace" }}>
55
+ {value}
56
+ </Text>
57
+ ) : null}
58
+ </View>
59
+ );
60
+ }
61
+
62
+ export function Slider({ props }: { props: Props }) {
63
+ const { theme, setValue } = useUiRuntime();
64
+ const binding = isBinding(props.value) ? props.value : null;
65
+ if (!binding) return <ErrorLine message="Slider expects a $state binding" />;
66
+ const min = finiteNumber(props.min) ?? 0;
67
+ const max = Math.max(min, finiteNumber(props.max) ?? 100);
68
+ const step = Math.max(0.0001, finiteNumber(props.step) ?? 1);
69
+ const label = optionalText(props.label);
70
+ const current = Math.min(max, Math.max(min, finiteNumber(binding.value) ?? min));
71
+ const span = Math.max(0.0001, max - min);
72
+ const pct = Math.min(100, Math.max(0, ((current - min) / span) * 100));
73
+ const range = stepRange(min, max, step);
74
+
75
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => {
76
+ const raw = min + t * span;
77
+ const snapped = Math.round((raw - min) / step) * step + min;
78
+ return Math.min(max, Math.max(min, Number(snapped.toFixed(6))));
79
+ });
80
+
81
+ return (
82
+ <View style={{ gap: 6 }}>
83
+ <FieldLabel label={label} value={formatNumber(current)} />
84
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
85
+ <Pressable
86
+ accessibilityRole="button"
87
+ onPress={() => setValue(binding.name, stepBy(current, -1, range))}
88
+ style={{
89
+ width: 26,
90
+ height: 26,
91
+ borderRadius: 6,
92
+ backgroundColor: theme.colors.surface2,
93
+ borderWidth: StyleSheet.hairlineWidth,
94
+ borderColor: theme.colors.border,
95
+ alignItems: "center",
96
+ justifyContent: "center",
97
+ }}
98
+ >
99
+ <Text style={{ color: theme.colors.foreground, fontSize: 14 }}>−</Text>
100
+ </Pressable>
101
+ <Pressable
102
+ style={{
103
+ flex: 1,
104
+ height: 10,
105
+ borderRadius: 5,
106
+ backgroundColor: theme.colors.surface2,
107
+ overflow: "hidden",
108
+ }}
109
+ onPress={(e) => {
110
+ const locX = (e.nativeEvent as { locationX?: number }).locationX;
111
+ if (typeof locX === "number" && locX >= 0) {
112
+ const ratio = Math.min(1, Math.max(0, locX / 200));
113
+ const next = Math.round(((min + ratio * span) - min) / step) * step + min;
114
+ setValue(binding.name, Math.min(max, Math.max(min, Number(next.toFixed(6)))));
115
+ }
116
+ }}
117
+ >
118
+ <View
119
+ style={{
120
+ width: `${pct}%`,
121
+ height: "100%",
122
+ backgroundColor: theme.colors.accent,
123
+ borderRadius: 5,
124
+ }}
125
+ />
126
+ </Pressable>
127
+ <Pressable
128
+ accessibilityRole="button"
129
+ onPress={() => setValue(binding.name, stepBy(current, 1, range))}
130
+ style={{
131
+ width: 26,
132
+ height: 26,
133
+ borderRadius: 6,
134
+ backgroundColor: theme.colors.surface2,
135
+ borderWidth: StyleSheet.hairlineWidth,
136
+ borderColor: theme.colors.border,
137
+ alignItems: "center",
138
+ justifyContent: "center",
139
+ }}
140
+ >
141
+ <Text style={{ color: theme.colors.foreground, fontSize: 14 }}>+</Text>
142
+ </Pressable>
143
+ </View>
144
+ <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
145
+ {ticks.map((val, idx) => (
146
+ <Pressable key={idx} onPress={() => setValue(binding.name, val)}>
147
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10.5 }}>
148
+ {formatNumber(val)}
149
+ </Text>
150
+ </Pressable>
151
+ ))}
152
+ </View>
153
+ </View>
154
+ );
155
+ }
156
+
157
+ export function Stepper({ props }: { props: Props }) {
158
+ const { theme, setValue } = useUiRuntime();
159
+ const binding = isBinding(props.value) ? props.value : null;
160
+ if (!binding) return <ErrorLine message="Stepper expects a $state binding" />;
161
+ const range = stepRange(props.min, props.max, props.step);
162
+ const current = stepperValue(binding.value, range);
163
+ const label = optionalText(props.label);
164
+
165
+ return (
166
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
167
+ {label ? (
168
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "500", flex: 1 }}>
169
+ {label}
170
+ </Text>
171
+ ) : null}
172
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
173
+ <Pressable
174
+ accessibilityRole="button"
175
+ onPress={() => setValue(binding.name, stepBy(current, -1, range))}
176
+ style={{
177
+ width: 28,
178
+ height: 28,
179
+ borderRadius: 6,
180
+ backgroundColor: theme.colors.surface2,
181
+ borderWidth: StyleSheet.hairlineWidth,
182
+ borderColor: theme.colors.border,
183
+ alignItems: "center",
184
+ justifyContent: "center",
185
+ }}
186
+ >
187
+ <Text style={{ color: theme.colors.foreground, fontSize: 15 }}>−</Text>
188
+ </Pressable>
189
+ <Text
190
+ style={{
191
+ minWidth: 36,
192
+ textAlign: "center",
193
+ color: theme.colors.foreground,
194
+ fontSize: 13.5,
195
+ fontWeight: "600",
196
+ fontFamily: "monospace",
197
+ }}
198
+ >
199
+ {formatNumber(current)}
200
+ </Text>
201
+ <Pressable
202
+ accessibilityRole="button"
203
+ onPress={() => setValue(binding.name, stepBy(current, 1, range))}
204
+ style={{
205
+ width: 28,
206
+ height: 28,
207
+ borderRadius: 6,
208
+ backgroundColor: theme.colors.surface2,
209
+ borderWidth: StyleSheet.hairlineWidth,
210
+ borderColor: theme.colors.border,
211
+ alignItems: "center",
212
+ justifyContent: "center",
213
+ }}
214
+ >
215
+ <Text style={{ color: theme.colors.foreground, fontSize: 15 }}>+</Text>
216
+ </Pressable>
217
+ </View>
218
+ </View>
219
+ );
220
+ }
221
+
222
+ export function Toggle({ props }: { props: Props }) {
223
+ const { theme, setValue } = useUiRuntime();
224
+ const binding = isBinding(props.value) ? props.value : null;
225
+ if (!binding) return <ErrorLine message="Toggle expects a $state binding" />;
226
+ const checked = Boolean(binding.value);
227
+ const label = text(props.label);
228
+
229
+ return (
230
+ <Pressable
231
+ accessibilityRole="switch"
232
+ onPress={() => setValue(binding.name, !checked)}
233
+ style={{
234
+ flexDirection: "row",
235
+ alignItems: "center",
236
+ justifyContent: "space-between",
237
+ gap: 12,
238
+ paddingVertical: 4,
239
+ }}
240
+ >
241
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, flex: 1 }}>{label}</Text>
242
+ <View
243
+ style={{
244
+ width: 40,
245
+ height: 22,
246
+ borderRadius: 11,
247
+ backgroundColor: checked ? theme.colors.accent : theme.colors.surface2,
248
+ borderWidth: StyleSheet.hairlineWidth,
249
+ borderColor: checked ? theme.colors.accent : theme.colors.border,
250
+ padding: 2,
251
+ justifyContent: "center",
252
+ alignItems: checked ? "flex-end" : "flex-start",
253
+ }}
254
+ >
255
+ <View
256
+ style={{
257
+ width: 16,
258
+ height: 16,
259
+ borderRadius: 8,
260
+ backgroundColor: checked ? theme.colors.accentForeground : theme.colors.foregroundMuted,
261
+ }}
262
+ />
263
+ </View>
264
+ </Pressable>
265
+ );
266
+ }
267
+
268
+ export function Choice({ props }: { props: Props }) {
269
+ const { theme, setValue } = useUiRuntime();
270
+ const binding = isBinding(props.value) ? props.value : null;
271
+ if (!binding) return <ErrorLine message="Choice expects a $state binding" />;
272
+ const options = list(props.options).filter(isScalar);
273
+ const label = optionalText(props.label);
274
+
275
+ return (
276
+ <View style={{ gap: 6 }}>
277
+ <FieldLabel label={label} />
278
+ <View
279
+ style={{
280
+ flexDirection: "row",
281
+ flexWrap: "wrap",
282
+ gap: 6,
283
+ backgroundColor: theme.colors.surface2,
284
+ padding: 4,
285
+ borderRadius: 8,
286
+ }}
287
+ >
288
+ {options.map((opt, idx) => {
289
+ const active = binding.value === opt;
290
+ return (
291
+ <Pressable
292
+ key={idx}
293
+ accessibilityRole="button"
294
+ onPress={() => setValue(binding.name, opt)}
295
+ style={{
296
+ flex: 1,
297
+ minWidth: 72,
298
+ paddingHorizontal: 10,
299
+ paddingVertical: 6,
300
+ borderRadius: 6,
301
+ backgroundColor: active ? theme.colors.accent : "transparent",
302
+ alignItems: "center",
303
+ }}
304
+ >
305
+ <Text
306
+ style={{
307
+ color: active ? theme.colors.accentForeground : theme.colors.foreground,
308
+ fontSize: 12.5,
309
+ fontWeight: active ? "600" : "400",
310
+ }}
311
+ >
312
+ {itemText(opt)}
313
+ </Text>
314
+ </Pressable>
315
+ );
316
+ })}
317
+ </View>
318
+ </View>
319
+ );
320
+ }
321
+
322
+ export function Radio({ props }: { props: Props }) {
323
+ const { theme, setValue } = useUiRuntime();
324
+ const binding = isBinding(props.value) ? props.value : null;
325
+ if (!binding) return <ErrorLine message="Radio expects a $state binding" />;
326
+ const options = radioOptions(props.options);
327
+ const label = optionalText(props.label);
328
+
329
+ return (
330
+ <View style={{ gap: 6 }}>
331
+ <FieldLabel label={label} />
332
+ <View style={{ gap: 6 }}>
333
+ {options.map((opt, idx) => {
334
+ const active = binding.value === opt.value;
335
+ return (
336
+ <Pressable
337
+ key={idx}
338
+ accessibilityRole="radio"
339
+ onPress={() => setValue(binding.name, opt.value)}
340
+ style={{
341
+ flexDirection: "row",
342
+ alignItems: "flex-start",
343
+ gap: 10,
344
+ padding: 10,
345
+ borderRadius: 8,
346
+ borderWidth: StyleSheet.hairlineWidth,
347
+ borderColor: active ? theme.colors.accent : theme.colors.border,
348
+ backgroundColor: theme.colors.surface2,
349
+ }}
350
+ >
351
+ <View
352
+ style={{
353
+ width: 16,
354
+ height: 16,
355
+ borderRadius: 8,
356
+ borderWidth: 1.5,
357
+ borderColor: active ? theme.colors.accent : theme.colors.foregroundMuted,
358
+ alignItems: "center",
359
+ justifyContent: "center",
360
+ marginTop: 2,
361
+ }}
362
+ >
363
+ {active ? (
364
+ <View
365
+ style={{
366
+ width: 8,
367
+ height: 8,
368
+ borderRadius: 4,
369
+ backgroundColor: theme.colors.accent,
370
+ }}
371
+ />
372
+ ) : null}
373
+ </View>
374
+ <View style={{ flex: 1, gap: 2 }}>
375
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "500" }}>
376
+ {itemText(opt.value)}
377
+ </Text>
378
+ {opt.detail ? (
379
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
380
+ {opt.detail}
381
+ </Text>
382
+ ) : null}
383
+ </View>
384
+ </Pressable>
385
+ );
386
+ })}
387
+ </View>
388
+ </View>
389
+ );
390
+ }
391
+
392
+ export function Select({ props }: { props: Props }) {
393
+ return <Choice props={props} />;
394
+ }
395
+
396
+ export function Checklist({ props }: { props: Props }) {
397
+ const { theme, setValue } = useUiRuntime();
398
+ const binding = isBinding(props.value) ? props.value : null;
399
+ if (!binding) return <ErrorLine message="Checklist expects a $state binding" />;
400
+ const picked = Array.isArray(binding.value) ? binding.value : [];
401
+ const options = list(props.options).filter(isScalar);
402
+ const label = optionalText(props.label);
403
+
404
+ return (
405
+ <View style={{ gap: 6 }}>
406
+ <FieldLabel label={label} />
407
+ <View style={{ gap: 6 }}>
408
+ {options.map((opt, idx) => {
409
+ const checked = picked.includes(opt);
410
+ return (
411
+ <Pressable
412
+ key={idx}
413
+ accessibilityRole="checkbox"
414
+ onPress={() => setValue(binding.name, togglePick(picked, opt, options))}
415
+ style={{
416
+ flexDirection: "row",
417
+ alignItems: "center",
418
+ gap: 10,
419
+ paddingHorizontal: 10,
420
+ paddingVertical: 8,
421
+ borderRadius: 8,
422
+ borderWidth: StyleSheet.hairlineWidth,
423
+ borderColor: checked ? theme.colors.accent : theme.colors.border,
424
+ backgroundColor: theme.colors.surface2,
425
+ }}
426
+ >
427
+ <View
428
+ style={{
429
+ width: 16,
430
+ height: 16,
431
+ borderRadius: 4,
432
+ borderWidth: 1.5,
433
+ borderColor: checked ? theme.colors.accent : theme.colors.foregroundMuted,
434
+ backgroundColor: checked ? theme.colors.accent : "transparent",
435
+ alignItems: "center",
436
+ justifyContent: "center",
437
+ }}
438
+ >
439
+ {checked ? <Icon name="Check" size={11} color={theme.colors.accentForeground} /> : null}
440
+ </View>
441
+ <Text style={{ color: theme.colors.foreground, fontSize: 13 }}>{itemText(opt)}</Text>
442
+ </Pressable>
443
+ );
444
+ })}
445
+ </View>
446
+ </View>
447
+ );
448
+ }
449
+
450
+ export function ListEditor({ props }: { props: Props }) {
451
+ const { theme, setValue } = useUiRuntime();
452
+ const binding = isBinding(props.value) ? props.value : null;
453
+ const [draft, setDraft] = useState("");
454
+ if (!binding) return <ErrorLine message="ListEditor expects a $state binding" />;
455
+ const items = Array.isArray(binding.value) ? binding.value : [];
456
+ const label = optionalText(props.label);
457
+ const placeholder = optionalText(props.placeholder) ?? "Add item…";
458
+
459
+ const add = () => {
460
+ const next = addItem(items, draft);
461
+ if (next) {
462
+ setValue(binding.name, next);
463
+ setDraft("");
464
+ }
465
+ };
466
+
467
+ return (
468
+ <View style={{ gap: 6 }}>
469
+ <FieldLabel label={label} />
470
+ <View style={{ gap: 4 }}>
471
+ {items.map((item, idx) => (
472
+ <View
473
+ key={idx}
474
+ style={{
475
+ flexDirection: "row",
476
+ alignItems: "center",
477
+ justifyContent: "space-between",
478
+ paddingHorizontal: 10,
479
+ paddingVertical: 6,
480
+ borderRadius: 6,
481
+ backgroundColor: theme.colors.surface2,
482
+ }}
483
+ >
484
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5 }}>{itemText(item)}</Text>
485
+ <Pressable
486
+ onPress={() => setValue(binding.name, items.filter((_, i) => i !== idx))}
487
+ >
488
+ <Icon name="X" size={13} color={theme.colors.foregroundMuted} />
489
+ </Pressable>
490
+ </View>
491
+ ))}
492
+ </View>
493
+ <View style={{ flexDirection: "row", gap: 6 }}>
494
+ <TextInput
495
+ value={draft}
496
+ onChangeText={setDraft}
497
+ placeholder={placeholder}
498
+ placeholderTextColor={theme.colors.foregroundMuted}
499
+ onSubmitEditing={add}
500
+ style={{
501
+ flex: 1,
502
+ borderRadius: 6,
503
+ borderWidth: StyleSheet.hairlineWidth,
504
+ borderColor: theme.colors.border,
505
+ backgroundColor: theme.colors.surface2,
506
+ color: theme.colors.foreground,
507
+ paddingHorizontal: 10,
508
+ paddingVertical: 6,
509
+ fontSize: 13,
510
+ }}
511
+ />
512
+ <Pressable
513
+ onPress={add}
514
+ style={{
515
+ paddingHorizontal: 12,
516
+ paddingVertical: 6,
517
+ borderRadius: 6,
518
+ backgroundColor: theme.colors.accent,
519
+ justifyContent: "center",
520
+ }}
521
+ >
522
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 12.5, fontWeight: "600" }}>
523
+ Add
524
+ </Text>
525
+ </Pressable>
526
+ </View>
527
+ </View>
528
+ );
529
+ }
530
+
531
+ export function Input({ props }: { props: Props }) {
532
+ const { theme, setValue } = useUiRuntime();
533
+ const binding = isBinding(props.value) ? props.value : null;
534
+ if (!binding) return <ErrorLine message="Input expects a $state binding" />;
535
+ const label = optionalText(props.label);
536
+ const placeholder = optionalText(props.placeholder) ?? "";
537
+
538
+ return (
539
+ <View style={{ gap: 5 }}>
540
+ <FieldLabel label={label} />
541
+ <TextInput
542
+ value={text(binding.value)}
543
+ onChangeText={(next) => setValue(binding.name, next)}
544
+ placeholder={placeholder}
545
+ placeholderTextColor={theme.colors.foregroundMuted}
546
+ style={{
547
+ borderRadius: 8,
548
+ borderWidth: StyleSheet.hairlineWidth,
549
+ borderColor: theme.colors.border,
550
+ backgroundColor: theme.colors.surface2,
551
+ color: theme.colors.foreground,
552
+ paddingHorizontal: 10,
553
+ paddingVertical: 8,
554
+ fontSize: 13,
555
+ }}
556
+ />
557
+ </View>
558
+ );
559
+ }
560
+
561
+ export function TextArea({ props }: { props: Props }) {
562
+ const { theme, setValue } = useUiRuntime();
563
+ const binding = isBinding(props.value) ? props.value : null;
564
+ if (!binding) return <ErrorLine message="TextArea expects a $state binding" />;
565
+ const label = optionalText(props.label);
566
+ const placeholder = optionalText(props.placeholder) ?? "";
567
+
568
+ return (
569
+ <View style={{ gap: 5 }}>
570
+ <FieldLabel label={label} />
571
+ <TextInput
572
+ multiline
573
+ numberOfLines={3}
574
+ value={text(binding.value)}
575
+ onChangeText={(next) => setValue(binding.name, next)}
576
+ placeholder={placeholder}
577
+ placeholderTextColor={theme.colors.foregroundMuted}
578
+ style={{
579
+ minHeight: 72,
580
+ borderRadius: 8,
581
+ borderWidth: StyleSheet.hairlineWidth,
582
+ borderColor: theme.colors.border,
583
+ backgroundColor: theme.colors.surface2,
584
+ color: theme.colors.foreground,
585
+ paddingHorizontal: 10,
586
+ paddingVertical: 8,
587
+ fontSize: 13,
588
+ textAlignVertical: "top",
589
+ }}
590
+ />
591
+ </View>
592
+ );
593
+ }
594
+
595
+ export function DateInput({ props }: { props: Props }) {
596
+ return <Input props={{ ...props, placeholder: "YYYY-MM-DD" }} />;
597
+ }
598
+
599
+ export function Rating({ props }: { props: Props }) {
600
+ const { theme, setValue } = useUiRuntime();
601
+ const binding = isBinding(props.value) ? props.value : null;
602
+ if (!binding) return <ErrorLine message="Rating expects a $state binding" />;
603
+ const max = Math.max(1, Math.min(10, Math.round(finiteNumber(props.max) ?? 5)));
604
+ const current = Math.round(finiteNumber(binding.value) ?? 0);
605
+ const label = optionalText(props.label);
606
+
607
+ return (
608
+ <View style={{ gap: 6 }}>
609
+ <FieldLabel label={label} value={current > 0 ? `${current}/${max}` : undefined} />
610
+ <View style={{ flexDirection: "row", gap: 6 }}>
611
+ {Array.from({ length: max }, (_, idx) => {
612
+ const star = idx + 1;
613
+ const active = star <= current;
614
+ return (
615
+ <Pressable
616
+ key={star}
617
+ onPress={() => setValue(binding.name, star === current ? 0 : star)}
618
+ style={{
619
+ paddingHorizontal: 8,
620
+ paddingVertical: 5,
621
+ borderRadius: 6,
622
+ backgroundColor: active ? theme.colors.accent : theme.colors.surface2,
623
+ }}
624
+ >
625
+ <Text
626
+ style={{
627
+ color: active ? theme.colors.accentForeground : theme.colors.foregroundMuted,
628
+ fontSize: 13,
629
+ fontWeight: "600",
630
+ }}
631
+ >
632
+ ★ {star}
633
+ </Text>
634
+ </Pressable>
635
+ );
636
+ })}
637
+ </View>
638
+ </View>
639
+ );
640
+ }
641
+
642
+ export function OptionCards({ props }: { props: Props }) {
643
+ const { theme, compact, setValue } = useUiRuntime();
644
+ const binding = isBinding(props.value) ? props.value : null;
645
+ if (!binding) return <ErrorLine message="OptionCards expects a $state binding" />;
646
+ const options = cardOptions(props.options);
647
+ if (!options) return <ErrorLine message="OptionCards expects a list of options" />;
648
+ const label = optionalText(props.label);
649
+ const isMulti = Array.isArray(binding.value);
650
+ const pickedList = isMulti ? (binding.value as readonly Value[]) : [];
651
+ const allValues = options.map((o) => o.value);
652
+
653
+ return (
654
+ <View style={{ gap: 6 }}>
655
+ <FieldLabel label={label} />
656
+ <View style={{ flexDirection: compact ? "column" : "row", flexWrap: "wrap", gap: 8 }}>
657
+ {options.map((opt, idx) => {
658
+ const selected = isMulti ? pickedList.includes(opt.value) : binding.value === opt.value;
659
+ const iconName = opt.icon ? resolveIconName(opt.icon) ?? opt.icon : null;
660
+ return (
661
+ <Pressable
662
+ key={idx}
663
+ onPress={() => {
664
+ if (isMulti) {
665
+ setValue(binding.name, togglePick(pickedList, opt.value, allValues));
666
+ } else {
667
+ setValue(binding.name, opt.value);
668
+ }
669
+ }}
670
+ style={{
671
+ flex: compact ? undefined : 1,
672
+ minWidth: 150,
673
+ borderRadius: 10,
674
+ borderWidth: selected ? 1.5 : StyleSheet.hairlineWidth,
675
+ borderColor: selected ? theme.colors.accent : theme.colors.border,
676
+ backgroundColor: theme.colors.surface2,
677
+ padding: 10,
678
+ gap: 6,
679
+ }}
680
+ >
681
+ {opt.image ? <LoadedImageView src={opt.image} alt={opt.title} height={100} /> : null}
682
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
683
+ {iconName ? <Icon name={iconName} size={15} color={theme.colors.accent} /> : null}
684
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600", flex: 1 }}>
685
+ {opt.title}
686
+ </Text>
687
+ </View>
688
+ {opt.detail ? (
689
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{opt.detail}</Text>
690
+ ) : null}
691
+ </Pressable>
692
+ );
693
+ })}
694
+ </View>
695
+ </View>
696
+ );
697
+ }
698
+
699
+ export function Swatches({ props }: { props: Props }) {
700
+ const { theme, setValue } = useUiRuntime();
701
+ const binding = isBinding(props.value) ? props.value : null;
702
+ const parsed = swatchColors(props.colors, (c) => /^#([0-9a-f]{3,8})$|^(rgb|hsl|oklch|[a-z]+)$/i.test(c));
703
+ if (!parsed) return <ErrorLine message="Swatches expects a list of colors" />;
704
+ const label = optionalText(props.label);
705
+
706
+ return (
707
+ <View style={{ gap: 6 }}>
708
+ <FieldLabel label={label} />
709
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
710
+ {parsed.colors.map((swatch, idx) => {
711
+ const active = binding ? sameColor(binding.value, swatch.value) : false;
712
+ return (
713
+ <Pressable
714
+ key={idx}
715
+ onPress={() => {
716
+ if (binding) setValue(binding.name, swatch.value);
717
+ }}
718
+ style={{
719
+ flexDirection: "row",
720
+ alignItems: "center",
721
+ gap: 6,
722
+ paddingHorizontal: 8,
723
+ paddingVertical: 5,
724
+ borderRadius: 8,
725
+ borderWidth: active ? 1.5 : StyleSheet.hairlineWidth,
726
+ borderColor: active ? theme.colors.accent : theme.colors.border,
727
+ backgroundColor: theme.colors.surface2,
728
+ }}
729
+ >
730
+ <View
731
+ style={{
732
+ width: 14,
733
+ height: 14,
734
+ borderRadius: 7,
735
+ backgroundColor: swatch.value,
736
+ borderWidth: StyleSheet.hairlineWidth,
737
+ borderColor: theme.colors.border,
738
+ }}
739
+ />
740
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>
741
+ {swatch.name ?? swatch.value}
742
+ </Text>
743
+ </Pressable>
744
+ );
745
+ })}
746
+ </View>
747
+ </View>
748
+ );
749
+ }
750
+
751
+ export function EditableTable({ props }: { props: Props }) {
752
+ const { theme, setValue } = useUiRuntime();
753
+ const binding = isBinding(props.value) ? props.value : null;
754
+ if (!binding) return <ErrorLine message="EditableTable expects a $state binding" />;
755
+ const rows = tableRows(binding.value);
756
+ if (rows === null) return <ErrorLine message="EditableTable state must be a list of objects" />;
757
+ const columns = tableColumns(props.columns, rows);
758
+ const label = optionalText(props.label);
759
+
760
+ return (
761
+ <View style={{ gap: 6 }}>
762
+ <FieldLabel label={label} />
763
+ <ScrollView horizontal>
764
+ <View
765
+ style={{
766
+ borderRadius: 8,
767
+ borderWidth: StyleSheet.hairlineWidth,
768
+ borderColor: theme.colors.border,
769
+ overflow: "hidden",
770
+ minWidth: "100%",
771
+ }}
772
+ >
773
+ <View
774
+ style={{
775
+ flexDirection: "row",
776
+ backgroundColor: theme.colors.surface2,
777
+ borderBottomWidth: StyleSheet.hairlineWidth,
778
+ borderBottomColor: theme.colors.border,
779
+ }}
780
+ >
781
+ {columns.map((col) => (
782
+ <View key={col.key} style={{ width: 130, paddingHorizontal: 8, paddingVertical: 6 }}>
783
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "600" }}>
784
+ {col.label}
785
+ </Text>
786
+ </View>
787
+ ))}
788
+ <View style={{ width: 36 }} />
789
+ </View>
790
+ {rows.map((row, rIdx) => (
791
+ <View
792
+ key={rIdx}
793
+ style={{
794
+ flexDirection: "row",
795
+ alignItems: "center",
796
+ borderTopWidth: rIdx === 0 ? 0 : StyleSheet.hairlineWidth,
797
+ borderTopColor: theme.colors.border,
798
+ }}
799
+ >
800
+ {columns.map((col) => {
801
+ const cellVal = row[col.key] ?? null;
802
+ if (col.type === "boolean") {
803
+ return (
804
+ <Pressable
805
+ key={col.key}
806
+ onPress={() => setValue(binding.name, setCell(rows, rIdx, col.key, !cellVal))}
807
+ style={{ width: 130, paddingHorizontal: 8, paddingVertical: 6 }}
808
+ >
809
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5 }}>
810
+ {cellVal ? "☑ Yes" : "☐ No"}
811
+ </Text>
812
+ </Pressable>
813
+ );
814
+ }
815
+ return (
816
+ <View key={col.key} style={{ width: 130, paddingHorizontal: 6, paddingVertical: 4 }}>
817
+ <TextInput
818
+ defaultValue={cellVal === null ? "" : String(cellVal)}
819
+ onChangeText={(txt) => {
820
+ if (col.type === "number") {
821
+ const parsed = parseNumberInput(txt);
822
+ if (parsed !== undefined) {
823
+ setValue(binding.name, setCell(rows, rIdx, col.key, parsed));
824
+ }
825
+ } else {
826
+ setValue(binding.name, setCell(rows, rIdx, col.key, txt));
827
+ }
828
+ }}
829
+ style={{
830
+ color: theme.colors.foreground,
831
+ fontSize: 12.5,
832
+ paddingHorizontal: 6,
833
+ paddingVertical: 4,
834
+ borderRadius: 4,
835
+ backgroundColor: theme.colors.surface2,
836
+ }}
837
+ />
838
+ </View>
839
+ );
840
+ })}
841
+ <Pressable
842
+ onPress={() => setValue(binding.name, rows.filter((_, i) => i !== rIdx))}
843
+ style={{ width: 36, alignItems: "center", justifyContent: "center" }}
844
+ >
845
+ <Icon name="Trash2" size={13} color={theme.colors.foregroundMuted} />
846
+ </Pressable>
847
+ </View>
848
+ ))}
849
+ </View>
850
+ </ScrollView>
851
+ <Pressable
852
+ onPress={() => setValue(binding.name, [...rows, blankRow(columns)])}
853
+ style={{
854
+ alignSelf: "flex-start",
855
+ paddingHorizontal: 10,
856
+ paddingVertical: 5,
857
+ borderRadius: 6,
858
+ backgroundColor: theme.colors.surface2,
859
+ }}
860
+ >
861
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>+ Add row</Text>
862
+ </Pressable>
863
+ </View>
864
+ );
865
+ }
866
+
867
+ export function Button({ props }: { props: Props }) {
868
+ const { theme, run } = useUiRuntime();
869
+ const label = text(props.label);
870
+ const variant = text(props.variant) || "secondary";
871
+ const rawIcon = optionalText(props.icon);
872
+ const iconName = rawIcon ? resolveIconName(rawIcon) ?? rawIcon : null;
873
+ const actions: Action[] = Array.isArray(props.action)
874
+ ? props.action.filter(isAction)
875
+ : isAction(props.action)
876
+ ? [props.action]
877
+ : [{ kind: "action", type: "send", text: label }];
878
+
879
+ const bg =
880
+ variant === "primary"
881
+ ? theme.colors.accent
882
+ : variant === "destructive"
883
+ ? theme.colors.statusDanger
884
+ : theme.colors.surface2;
885
+ const fg =
886
+ variant === "primary" || variant === "destructive"
887
+ ? theme.colors.accentForeground
888
+ : theme.colors.foreground;
889
+
890
+ return (
891
+ <Pressable
892
+ accessibilityRole="button"
893
+ onPress={() => void run(actions)}
894
+ style={{
895
+ alignSelf: "flex-start",
896
+ flexDirection: "row",
897
+ alignItems: "center",
898
+ gap: 6,
899
+ paddingHorizontal: 12,
900
+ paddingVertical: 7,
901
+ borderRadius: 8,
902
+ backgroundColor: bg,
903
+ borderWidth: StyleSheet.hairlineWidth,
904
+ borderColor: variant === "secondary" ? theme.colors.border : bg,
905
+ }}
906
+ >
907
+ {iconName ? <Icon name={iconName} size={14} color={fg} /> : null}
908
+ <Text style={{ color: fg, fontSize: 12.5, fontWeight: "600" }}>{label}</Text>
909
+ </Pressable>
910
+ );
911
+ }
912
+
913
+ export function ButtonGroup({ buttons }: { buttons: readonly GroupedButton[] }) {
914
+ return (
915
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
916
+ {buttons.map((b) => (
917
+ <React.Fragment key={b.key}>{b.element}</React.Fragment>
918
+ ))}
919
+ </View>
920
+ );
921
+ }
922
+
923
+ export function Form({ props }: { props: Props }) {
924
+ const { theme, run, answer } = useUiRuntime();
925
+ const children = nodes(props.children);
926
+ const submitLabel = optionalText(props.submit) ?? "Submit";
927
+ const formNode: UiNode = { kind: "node", type: "Form", key: "form", props };
928
+
929
+ return (
930
+ <View style={{ gap: 10 }}>
931
+ <RenderNodes nodes={children} />
932
+ {answer ? (
933
+ <View
934
+ style={{
935
+ paddingHorizontal: 10,
936
+ paddingVertical: 6,
937
+ borderRadius: 6,
938
+ backgroundColor: theme.colors.surface2,
939
+ }}
940
+ >
941
+ <Text style={{ color: theme.colors.statusSuccess, fontSize: 12 }}>Submitted</Text>
942
+ </View>
943
+ ) : (
944
+ <Pressable
945
+ accessibilityRole="button"
946
+ onPress={() => {
947
+ const msg = formMessage(formNode);
948
+ if (msg.trim() !== "") {
949
+ void run([{ kind: "action", type: "send", text: msg }]);
950
+ }
951
+ }}
952
+ style={{
953
+ alignSelf: "flex-start",
954
+ paddingHorizontal: 14,
955
+ paddingVertical: 8,
956
+ borderRadius: 8,
957
+ backgroundColor: theme.colors.accent,
958
+ }}
959
+ >
960
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 13, fontWeight: "600" }}>
961
+ {submitLabel}
962
+ </Text>
963
+ </Pressable>
964
+ )}
965
+ </View>
966
+ );
967
+ }
968
+
969
+ export function FollowUps({ props }: { props: Props }) {
970
+ const { theme, run } = useUiRuntime();
971
+ const prompts = followUpPrompts(props.prompts);
972
+
973
+ return (
974
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
975
+ {prompts.map((prompt, idx) => (
976
+ <Pressable
977
+ key={idx}
978
+ accessibilityRole="button"
979
+ onPress={() => void run([{ kind: "action", type: "send", text: prompt }])}
980
+ style={{
981
+ paddingHorizontal: 10,
982
+ paddingVertical: 6,
983
+ borderRadius: 999,
984
+ backgroundColor: theme.colors.surface2,
985
+ borderWidth: StyleSheet.hairlineWidth,
986
+ borderColor: theme.colors.border,
987
+ }}
988
+ >
989
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>{prompt}</Text>
990
+ </Pressable>
991
+ ))}
992
+ </View>
993
+ );
994
+ }