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,836 @@
1
+ import { ExternalLink } from "@getpaseo/plugin/client/ui";
2
+ import React, { useEffect, useMemo, useState } from "react";
3
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
4
+ import {
5
+ decisionMessage,
6
+ estimateTask,
7
+ estimateTotals,
8
+ estimateUnit,
9
+ formatQuantity,
10
+ formatWithUnit,
11
+ initialChecks,
12
+ nudgeWeight,
13
+ planMessage,
14
+ planTotal,
15
+ rankOptions,
16
+ readDecision,
17
+ readEstimate,
18
+ readPlan,
19
+ round,
20
+ weightMax,
21
+ weightStep,
22
+ } from "../../shared/core/decisions";
23
+ import {
24
+ axisShare,
25
+ formatDay,
26
+ formatRange,
27
+ HEALTH_LABEL,
28
+ localDay,
29
+ readBoard,
30
+ readRoadmap,
31
+ readStatus,
32
+ ROADMAP_STATUS_LABEL,
33
+ } from "../../shared/core/tracking";
34
+ import { parseSteps } from "../../shared/logic/explain-logic";
35
+ import { finiteNumber, optionalText, text, type Props } from "../../shared/logic/values";
36
+ import {
37
+ countdownSeconds,
38
+ elapsedMs,
39
+ formatClock,
40
+ parseCards,
41
+ parseQuestions,
42
+ pauseClock,
43
+ quizScore,
44
+ remainingMs,
45
+ startClock,
46
+ STOPPED,
47
+ type Clock,
48
+ } from "../../shared/logic/widget-logic";
49
+ import { MarkdownText } from "../markdown";
50
+ import { ErrorLine } from "../node-renderer";
51
+ import { propsKey, usePersistedState, useUiRuntime } from "../runtime";
52
+
53
+ export function Timer({ props }: { props: Props }) {
54
+ const { theme } = useUiRuntime();
55
+ const totalSeconds = finiteNumber(props.seconds);
56
+ const label = optionalText(props.label) ?? (totalSeconds !== null ? "Timer" : "Stopwatch");
57
+ const [clock, setClock] = useState<Clock>(STOPPED);
58
+ const [now, setNow] = useState(() => Date.now());
59
+
60
+ useEffect(() => {
61
+ if (clock.startedAt === null) return;
62
+ const id = setInterval(() => setNow(Date.now()), 200);
63
+ return () => clearInterval(id);
64
+ }, [clock.startedAt]);
65
+
66
+ const running = clock.startedAt !== null;
67
+ const shownSeconds =
68
+ totalSeconds !== null
69
+ ? countdownSeconds(remainingMs(clock, totalSeconds * 1000, now))
70
+ : Math.floor(elapsedMs(clock, now) / 1000);
71
+
72
+ return (
73
+ <View
74
+ style={{
75
+ flexDirection: "row",
76
+ alignItems: "center",
77
+ justifyContent: "space-between",
78
+ padding: 12,
79
+ borderRadius: 10,
80
+ borderWidth: StyleSheet.hairlineWidth,
81
+ borderColor: theme.colors.border,
82
+ backgroundColor: theme.colors.surface2,
83
+ }}
84
+ >
85
+ <View style={{ gap: 2 }}>
86
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5 }}>{label}</Text>
87
+ <Text style={{ color: theme.colors.foreground, fontSize: 22, fontWeight: "700", fontFamily: "monospace" }}>
88
+ {formatClock(shownSeconds)}
89
+ </Text>
90
+ </View>
91
+ <View style={{ flexDirection: "row", gap: 8 }}>
92
+ <Pressable
93
+ onPress={() => {
94
+ const t = Date.now();
95
+ setNow(t);
96
+ setClock((c) => (c.startedAt === null ? startClock(c, t) : pauseClock(c, t)));
97
+ }}
98
+ style={{
99
+ paddingHorizontal: 12,
100
+ paddingVertical: 6,
101
+ borderRadius: 6,
102
+ backgroundColor: theme.colors.accent,
103
+ }}
104
+ >
105
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 12.5, fontWeight: "600" }}>
106
+ {running ? "Pause" : "Start"}
107
+ </Text>
108
+ </Pressable>
109
+ <Pressable
110
+ onPress={() => setClock(STOPPED)}
111
+ style={{
112
+ paddingHorizontal: 10,
113
+ paddingVertical: 6,
114
+ borderRadius: 6,
115
+ backgroundColor: theme.colors.surface1,
116
+ }}
117
+ >
118
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5 }}>Reset</Text>
119
+ </Pressable>
120
+ </View>
121
+ </View>
122
+ );
123
+ }
124
+
125
+ export function Quiz({ props }: { props: Props }) {
126
+ const { theme } = useUiRuntime();
127
+ const { questions } = parseQuestions(props.questions);
128
+ const [picks, setPicks] = usePersistedState<(number | null)[]>(
129
+ `quiz:${propsKey(props.questions)}`,
130
+ questions.map(() => null),
131
+ (v) => (Array.isArray(v) ? v.map((x) => (typeof x === "number" ? x : null)) : null),
132
+ );
133
+ if (questions.length === 0) return <ErrorLine message="Quiz has no valid questions" />;
134
+ const answered = picks.filter((p) => p !== null).length;
135
+ const score = quizScore(questions, picks);
136
+
137
+ return (
138
+ <View style={{ gap: 10 }}>
139
+ {questions.map((q, qIdx) => {
140
+ const picked = picks[qIdx] ?? null;
141
+ return (
142
+ <View
143
+ key={qIdx}
144
+ style={{
145
+ borderRadius: 8,
146
+ borderWidth: StyleSheet.hairlineWidth,
147
+ borderColor: theme.colors.border,
148
+ backgroundColor: theme.colors.surface2,
149
+ padding: 12,
150
+ gap: 8,
151
+ }}
152
+ >
153
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>
154
+ {qIdx + 1}. {q.q}
155
+ </Text>
156
+ <View style={{ gap: 6 }}>
157
+ {q.options.map((opt, oIdx) => {
158
+ const isPicked = picked === oIdx;
159
+ const isCorrect = q.answer === oIdx;
160
+ const borderColor =
161
+ picked === null
162
+ ? theme.colors.border
163
+ : isCorrect
164
+ ? theme.colors.statusSuccess
165
+ : isPicked
166
+ ? theme.colors.statusDanger
167
+ : theme.colors.border;
168
+ return (
169
+ <Pressable
170
+ key={oIdx}
171
+ onPress={() =>
172
+ setPicks((prev) => {
173
+ const copy = [...prev];
174
+ copy[qIdx] = oIdx;
175
+ return copy;
176
+ })
177
+ }
178
+ style={{
179
+ paddingHorizontal: 10,
180
+ paddingVertical: 7,
181
+ borderRadius: 6,
182
+ borderWidth: isPicked || (picked !== null && isCorrect) ? 1.5 : StyleSheet.hairlineWidth,
183
+ borderColor,
184
+ backgroundColor: theme.colors.surface1,
185
+ }}
186
+ >
187
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5 }}>{opt}</Text>
188
+ </Pressable>
189
+ );
190
+ })}
191
+ </View>
192
+ {picked !== null && q.why ? (
193
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{q.why}</Text>
194
+ ) : null}
195
+ </View>
196
+ );
197
+ })}
198
+ {answered > 0 ? (
199
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
200
+ Score: {score} / {questions.length}
201
+ </Text>
202
+ ) : null}
203
+ </View>
204
+ );
205
+ }
206
+
207
+ export function Flashcards({ props }: { props: Props }) {
208
+ const { theme } = useUiRuntime();
209
+ const cards = parseCards(props.cards);
210
+ const [index, setIndex] = useState(0);
211
+ const [flipped, setFlipped] = useState(false);
212
+ if (cards.length === 0) return <ErrorLine message="Flashcards has no valid cards" />;
213
+ const card = cards[Math.min(index, cards.length - 1)]!;
214
+
215
+ return (
216
+ <View style={{ gap: 8 }}>
217
+ <Pressable
218
+ onPress={() => setFlipped((f) => !f)}
219
+ style={{
220
+ minHeight: 110,
221
+ borderRadius: 10,
222
+ borderWidth: StyleSheet.hairlineWidth,
223
+ borderColor: theme.colors.border,
224
+ backgroundColor: theme.colors.surface2,
225
+ padding: 16,
226
+ justifyContent: "center",
227
+ alignItems: "center",
228
+ gap: 6,
229
+ }}
230
+ >
231
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
232
+ {flipped ? "Back" : "Front"} · Card {index + 1} of {cards.length} (tap to flip)
233
+ </Text>
234
+ <Text style={{ color: theme.colors.foreground, fontSize: 14, fontWeight: "600", textAlign: "center" }}>
235
+ {flipped ? card.back : card.front}
236
+ </Text>
237
+ </Pressable>
238
+ <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
239
+ <Pressable
240
+ onPress={() => {
241
+ setFlipped(false);
242
+ setIndex((i) => (i > 0 ? i - 1 : cards.length - 1));
243
+ }}
244
+ style={{ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: theme.colors.surface2 }}
245
+ >
246
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>Previous</Text>
247
+ </Pressable>
248
+ <Pressable
249
+ onPress={() => {
250
+ setFlipped(false);
251
+ setIndex((i) => (i + 1) % cards.length);
252
+ }}
253
+ style={{ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: theme.colors.surface2 }}
254
+ >
255
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>Next</Text>
256
+ </Pressable>
257
+ </View>
258
+ </View>
259
+ );
260
+ }
261
+
262
+ export function Walkthrough({ props }: { props: Props }) {
263
+ const { theme, compact } = useUiRuntime();
264
+ const { steps } = parseSteps(props.steps);
265
+ const [stepIdx, setStepIdx] = useState(0);
266
+ if (steps.length === 0) return <ErrorLine message="Walkthrough has no steps" />;
267
+ const current = steps[Math.min(stepIdx, steps.length - 1)]!;
268
+
269
+ return (
270
+ <View
271
+ style={{
272
+ borderRadius: 10,
273
+ borderWidth: StyleSheet.hairlineWidth,
274
+ borderColor: theme.colors.border,
275
+ backgroundColor: theme.colors.surface2,
276
+ padding: 12,
277
+ gap: 10,
278
+ }}
279
+ >
280
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
281
+ <Text style={{ color: theme.colors.foreground, fontSize: 13.5, fontWeight: "600" }}>
282
+ {current.title || `Step ${stepIdx + 1}`}
283
+ </Text>
284
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5 }}>
285
+ {stepIdx + 1} / {steps.length}
286
+ </Text>
287
+ </View>
288
+ {current.body ? <MarkdownText text={current.body} theme={theme} compact={compact} /> : null}
289
+ {current.code ? (
290
+ <View
291
+ style={{
292
+ borderRadius: 6,
293
+ backgroundColor: theme.colors.surface1,
294
+ padding: 10,
295
+ borderWidth: StyleSheet.hairlineWidth,
296
+ borderColor: theme.colors.border,
297
+ }}
298
+ >
299
+ <Text selectable style={{ color: theme.colors.foreground, fontFamily: "monospace", fontSize: 12 }}>
300
+ {current.code}
301
+ </Text>
302
+ </View>
303
+ ) : null}
304
+ <View style={{ flexDirection: "row", justifyContent: "space-between" }}>
305
+ <Pressable
306
+ disabled={stepIdx === 0}
307
+ onPress={() => setStepIdx((i) => Math.max(0, i - 1))}
308
+ style={{
309
+ paddingHorizontal: 10,
310
+ paddingVertical: 5,
311
+ borderRadius: 6,
312
+ backgroundColor: theme.colors.surface1,
313
+ opacity: stepIdx === 0 ? 0.4 : 1,
314
+ }}
315
+ >
316
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>Back</Text>
317
+ </Pressable>
318
+ <Pressable
319
+ disabled={stepIdx >= steps.length - 1}
320
+ onPress={() => setStepIdx((i) => Math.min(steps.length - 1, i + 1))}
321
+ style={{
322
+ paddingHorizontal: 10,
323
+ paddingVertical: 5,
324
+ borderRadius: 6,
325
+ backgroundColor: theme.colors.accent,
326
+ opacity: stepIdx >= steps.length - 1 ? 0.4 : 1,
327
+ }}
328
+ >
329
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 12, fontWeight: "600" }}>Next</Text>
330
+ </Pressable>
331
+ </View>
332
+ </View>
333
+ );
334
+ }
335
+
336
+ export function Plan({ props }: { props: Props }) {
337
+ const { theme, run, answer } = useUiRuntime();
338
+ const { steps, error } = readPlan(props.steps);
339
+ const submitLabel = optionalText(props.submit) ?? "Approve plan";
340
+ const [checked, setChecked] = usePersistedState<boolean[]>(
341
+ `plan:${propsKey(props.steps)}`,
342
+ initialChecks(steps),
343
+ (v) => (Array.isArray(v) ? v.map(Boolean) : null),
344
+ );
345
+ if (error) return <ErrorLine message={error} />;
346
+ const total = planTotal(steps, checked);
347
+
348
+ return (
349
+ <View style={{ gap: 8 }}>
350
+ {steps.map((step, idx) => {
351
+ const isOn = checked[idx] !== false;
352
+ return (
353
+ <Pressable
354
+ key={idx}
355
+ onPress={() =>
356
+ setChecked((prev) => {
357
+ const next = [...prev];
358
+ next[idx] = !isOn;
359
+ return next;
360
+ })
361
+ }
362
+ style={{
363
+ flexDirection: "row",
364
+ alignItems: "flex-start",
365
+ gap: 10,
366
+ padding: 10,
367
+ borderRadius: 8,
368
+ borderWidth: StyleSheet.hairlineWidth,
369
+ borderColor: isOn ? theme.colors.accent : theme.colors.border,
370
+ backgroundColor: theme.colors.surface2,
371
+ }}
372
+ >
373
+ <Text style={{ color: isOn ? theme.colors.accent : theme.colors.foregroundMuted, fontSize: 14 }}>
374
+ {isOn ? "☑" : "☐"}
375
+ </Text>
376
+ <View style={{ flex: 1, gap: 3 }}>
377
+ <View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
378
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600", flex: 1 }}>
379
+ {idx + 1}. {step.title}
380
+ </Text>
381
+ {step.estimate ? (
382
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5 }}>
383
+ {step.estimate}
384
+ </Text>
385
+ ) : null}
386
+ </View>
387
+ {step.detail ? (
388
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{step.detail}</Text>
389
+ ) : null}
390
+ {step.files.length > 0 ? (
391
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
392
+ {step.files.map((f, fIdx) => (
393
+ <Text
394
+ key={fIdx}
395
+ style={{ color: theme.colors.accent, fontFamily: "monospace", fontSize: 11 }}
396
+ >
397
+ {f.path}
398
+ </Text>
399
+ ))}
400
+ </View>
401
+ ) : null}
402
+ </View>
403
+ </Pressable>
404
+ );
405
+ })}
406
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
407
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
408
+ {total.total ? `Estimated total: ${formatQuantity(total.total)}` : `${checked.filter(Boolean).length} steps selected`}
409
+ </Text>
410
+ {answer ? (
411
+ <Text style={{ color: theme.colors.statusSuccess, fontSize: 12 }}>Approved</Text>
412
+ ) : (
413
+ <Pressable
414
+ onPress={() => {
415
+ const msg = planMessage(steps, checked, "");
416
+ if (msg) void run([{ kind: "action", type: "send", text: msg }]);
417
+ }}
418
+ style={{
419
+ paddingHorizontal: 12,
420
+ paddingVertical: 7,
421
+ borderRadius: 8,
422
+ backgroundColor: theme.colors.accent,
423
+ }}
424
+ >
425
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 12.5, fontWeight: "600" }}>
426
+ {submitLabel}
427
+ </Text>
428
+ </Pressable>
429
+ )}
430
+ </View>
431
+ </View>
432
+ );
433
+ }
434
+
435
+ export function Decision({ props }: { props: Props }) {
436
+ const { theme, run } = useUiRuntime();
437
+ const parsed = readDecision(props.options, props.criteria);
438
+ if (!parsed.ok) return <ErrorLine message={parsed.error} />;
439
+ const { model } = parsed;
440
+ const [weights, setWeights] = usePersistedState<number[]>(
441
+ `decision:${propsKey(props)}`,
442
+ model.criteria.map((c) => c.weight),
443
+ (v) => (Array.isArray(v) && v.length === model.criteria.length ? v.map((n) => Number(n) || 0) : null),
444
+ );
445
+
446
+ const ranked = rankOptions(model, weights);
447
+ const step = weightStep(weights);
448
+ const maxW = weightMax(weights);
449
+
450
+ return (
451
+ <View style={{ gap: 10 }}>
452
+ <View style={{ gap: 6 }}>
453
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5, fontWeight: "600" }}>
454
+ Criteria weights
455
+ </Text>
456
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
457
+ {model.criteria.map((c, cIdx) => {
458
+ const w = weights[cIdx] ?? c.weight;
459
+ return (
460
+ <View
461
+ key={cIdx}
462
+ style={{
463
+ flexDirection: "row",
464
+ alignItems: "center",
465
+ gap: 6,
466
+ paddingHorizontal: 8,
467
+ paddingVertical: 5,
468
+ borderRadius: 6,
469
+ backgroundColor: theme.colors.surface2,
470
+ borderWidth: StyleSheet.hairlineWidth,
471
+ borderColor: theme.colors.border,
472
+ }}
473
+ >
474
+ <Text style={{ color: theme.colors.foreground, fontSize: 12 }}>{c.name}</Text>
475
+ <Pressable
476
+ onPress={() =>
477
+ setWeights((prev) =>
478
+ prev.map((val, i) => (i === cIdx ? nudgeWeight(val, -1, step, maxW) : val)),
479
+ )
480
+ }
481
+ >
482
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, paddingHorizontal: 4 }}>−</Text>
483
+ </Pressable>
484
+ <Text style={{ color: theme.colors.accent, fontSize: 12, fontWeight: "600", fontFamily: "monospace" }}>
485
+ {round(w, 2)}
486
+ </Text>
487
+ <Pressable
488
+ onPress={() =>
489
+ setWeights((prev) =>
490
+ prev.map((val, i) => (i === cIdx ? nudgeWeight(val, 1, step, maxW) : val)),
491
+ )
492
+ }
493
+ >
494
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, paddingHorizontal: 4 }}>+</Text>
495
+ </Pressable>
496
+ </View>
497
+ );
498
+ })}
499
+ </View>
500
+ </View>
501
+
502
+ <View style={{ gap: 8 }}>
503
+ {ranked.map((entry, rIdx) => {
504
+ const opt = model.options[entry.index]!;
505
+ const isLeader = rIdx === 0;
506
+ return (
507
+ <View
508
+ key={entry.index}
509
+ style={{
510
+ borderRadius: 8,
511
+ borderWidth: isLeader ? 1.5 : StyleSheet.hairlineWidth,
512
+ borderColor: isLeader ? theme.colors.accent : theme.colors.border,
513
+ backgroundColor: theme.colors.surface2,
514
+ padding: 10,
515
+ gap: 6,
516
+ }}
517
+ >
518
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
519
+ <Text style={{ color: theme.colors.foreground, fontSize: 13.5, fontWeight: "600" }}>
520
+ {opt.name}
521
+ </Text>
522
+ <Text style={{ color: theme.colors.accent, fontSize: 13, fontWeight: "700" }}>
523
+ {round(entry.total, 2)} / {model.scale}
524
+ </Text>
525
+ </View>
526
+ {opt.pros.length > 0 ? (
527
+ <Text style={{ color: theme.colors.statusSuccess, fontSize: 12 }}>
528
+ + {opt.pros.join(" · ")}
529
+ </Text>
530
+ ) : null}
531
+ {opt.cons.length > 0 ? (
532
+ <Text style={{ color: theme.colors.statusWarning, fontSize: 12 }}>
533
+ − {opt.cons.join(" · ")}
534
+ </Text>
535
+ ) : null}
536
+ <Pressable
537
+ onPress={() => {
538
+ const msg = decisionMessage(model, entry.index, weights);
539
+ void run([{ kind: "action", type: "send", text: msg }]);
540
+ }}
541
+ style={{
542
+ alignSelf: "flex-start",
543
+ paddingHorizontal: 10,
544
+ paddingVertical: 5,
545
+ borderRadius: 6,
546
+ backgroundColor: isLeader ? theme.colors.accent : theme.colors.surface1,
547
+ }}
548
+ >
549
+ <Text
550
+ style={{
551
+ color: isLeader ? theme.colors.accentForeground : theme.colors.foreground,
552
+ fontSize: 12,
553
+ fontWeight: "600",
554
+ }}
555
+ >
556
+ Go with {opt.name}
557
+ </Text>
558
+ </Pressable>
559
+ </View>
560
+ );
561
+ })}
562
+ </View>
563
+ </View>
564
+ );
565
+ }
566
+
567
+ export function Estimate({ props }: { props: Props }) {
568
+ const { theme } = useUiRuntime();
569
+ const { tasks, error } = readEstimate(props.tasks);
570
+ if (error) return <ErrorLine message={error} />;
571
+ const unit = estimateUnit(props.unit);
572
+ const estimates = tasks.map(estimateTask);
573
+ const totals = estimateTotals(estimates);
574
+
575
+ return (
576
+ <View style={{ gap: 10 }}>
577
+ <View style={{ flexDirection: "row", gap: 8 }}>
578
+ <View style={{ flex: 1, padding: 10, borderRadius: 8, backgroundColor: theme.colors.surface2 }}>
579
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>P50 (Expected)</Text>
580
+ <Text style={{ color: theme.colors.foreground, fontSize: 16, fontWeight: "700" }}>
581
+ {formatWithUnit(totals.p50, unit)}
582
+ </Text>
583
+ </View>
584
+ <View style={{ flex: 1, padding: 10, borderRadius: 8, backgroundColor: theme.colors.surface2 }}>
585
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>P80</Text>
586
+ <Text style={{ color: theme.colors.accent, fontSize: 16, fontWeight: "700" }}>
587
+ {formatWithUnit(totals.p80, unit)}
588
+ </Text>
589
+ </View>
590
+ <View style={{ flex: 1, padding: 10, borderRadius: 8, backgroundColor: theme.colors.surface2 }}>
591
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>P90 (Safe)</Text>
592
+ <Text style={{ color: theme.colors.statusWarning, fontSize: 16, fontWeight: "700" }}>
593
+ {formatWithUnit(totals.p90, unit)}
594
+ </Text>
595
+ </View>
596
+ </View>
597
+ <View
598
+ style={{
599
+ borderRadius: 8,
600
+ borderWidth: StyleSheet.hairlineWidth,
601
+ borderColor: theme.colors.border,
602
+ overflow: "hidden",
603
+ }}
604
+ >
605
+ {estimates.map((est, idx) => (
606
+ <View
607
+ key={idx}
608
+ style={{
609
+ flexDirection: "row",
610
+ justifyContent: "space-between",
611
+ paddingHorizontal: 10,
612
+ paddingVertical: 8,
613
+ borderTopWidth: idx === 0 ? 0 : StyleSheet.hairlineWidth,
614
+ borderTopColor: theme.colors.border,
615
+ }}
616
+ >
617
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, flex: 1 }}>
618
+ {est.task.title}
619
+ </Text>
620
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontFamily: "monospace" }}>
621
+ {est.error
622
+ ? est.error
623
+ : `${est.task.low} / ${round(est.likely, 1)} / ${est.task.high} ${unit}`}
624
+ </Text>
625
+ </View>
626
+ ))}
627
+ </View>
628
+ </View>
629
+ );
630
+ }
631
+
632
+ export function Board({ props }: { props: Props }) {
633
+ const { theme, compact } = useUiRuntime();
634
+ const parsed = readBoard(props.items, props.columns);
635
+ if (!parsed.ok) return <ErrorLine message={parsed.error} />;
636
+ const board = parsed.value;
637
+
638
+ return (
639
+ <ScrollView horizontal showsHorizontalScrollIndicator={!compact}>
640
+ <View style={{ flexDirection: "row", gap: 10 }}>
641
+ {board.columns.map((col) => (
642
+ <View
643
+ key={col.status}
644
+ style={{
645
+ width: 230,
646
+ borderRadius: 8,
647
+ borderWidth: StyleSheet.hairlineWidth,
648
+ borderColor: theme.colors.border,
649
+ backgroundColor: theme.colors.surface2,
650
+ padding: 8,
651
+ gap: 6,
652
+ }}
653
+ >
654
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
655
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, fontWeight: "600" }}>
656
+ {col.label}
657
+ </Text>
658
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5 }}>
659
+ {col.cards.length}
660
+ </Text>
661
+ </View>
662
+ {col.cards.map((card) => (
663
+ <View
664
+ key={card.id}
665
+ style={{
666
+ borderRadius: 6,
667
+ borderWidth: StyleSheet.hairlineWidth,
668
+ borderColor: theme.colors.border,
669
+ backgroundColor: theme.colors.surface1,
670
+ padding: 8,
671
+ gap: 4,
672
+ }}
673
+ >
674
+ <View style={{ flexDirection: "row", justifyContent: "space-between", gap: 6 }}>
675
+ {card.key ? (
676
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, fontFamily: "monospace" }}>
677
+ {card.key}
678
+ </Text>
679
+ ) : null}
680
+ {card.priority ? (
681
+ <Text style={{ color: theme.colors.accent, fontSize: 10.5 }}>{card.priority}</Text>
682
+ ) : null}
683
+ </View>
684
+ {card.url ? (
685
+ <ExternalLink href={card.url}>
686
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, fontWeight: "500" }}>
687
+ {card.title}
688
+ </Text>
689
+ </ExternalLink>
690
+ ) : (
691
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, fontWeight: "500" }}>
692
+ {card.title}
693
+ </Text>
694
+ )}
695
+ {card.owners.length > 0 ? (
696
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
697
+ {card.owners.join(", ")}
698
+ </Text>
699
+ ) : null}
700
+ </View>
701
+ ))}
702
+ </View>
703
+ ))}
704
+ </View>
705
+ </ScrollView>
706
+ );
707
+ }
708
+
709
+ export function Roadmap({ props }: { props: Props }) {
710
+ const { theme } = useUiRuntime();
711
+ const today = useMemo(() => localDay(new Date()), []);
712
+ const parsed = readRoadmap(props.items, today);
713
+ if (!parsed.ok) return <ErrorLine message={parsed.error} />;
714
+ const roadmap = parsed.value;
715
+
716
+ return (
717
+ <View style={{ gap: 8 }}>
718
+ {roadmap.lanes.map((lane, lIdx) => (
719
+ <View
720
+ key={lIdx}
721
+ style={{
722
+ borderRadius: 8,
723
+ borderWidth: StyleSheet.hairlineWidth,
724
+ borderColor: theme.colors.border,
725
+ backgroundColor: theme.colors.surface2,
726
+ padding: 10,
727
+ gap: 8,
728
+ }}
729
+ >
730
+ {lane.name ? (
731
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5, fontWeight: "600" }}>
732
+ {lane.name}
733
+ </Text>
734
+ ) : null}
735
+ {lane.items.map((item) => {
736
+ const leftPct = Math.max(0, Math.min(95, axisShare(item.start, roadmap) * 100));
737
+ const rightPct = Math.max(leftPct + 3, Math.min(100, axisShare(item.end + 1, roadmap) * 100));
738
+ const widthPct = Math.max(3, rightPct - leftPct);
739
+ const barColor =
740
+ item.status === "done"
741
+ ? theme.colors.statusSuccess
742
+ : item.status === "blocked" || item.status === "at-risk"
743
+ ? theme.colors.statusWarning
744
+ : theme.colors.accent;
745
+ return (
746
+ <View key={item.index} style={{ gap: 4 }}>
747
+ <View style={{ flexDirection: "row", justifyContent: "space-between", gap: 8 }}>
748
+ <Text style={{ color: theme.colors.foreground, fontSize: 12.5, fontWeight: "500" }}>
749
+ {item.title}
750
+ </Text>
751
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5 }}>
752
+ {formatRange(item.start, item.end, today)} · {ROADMAP_STATUS_LABEL[item.status]}
753
+ </Text>
754
+ </View>
755
+ <View style={{ height: 8, borderRadius: 4, backgroundColor: theme.colors.surface1 }}>
756
+ <View
757
+ style={{
758
+ marginLeft: `${leftPct}%`,
759
+ width: `${widthPct}%`,
760
+ height: "100%",
761
+ borderRadius: 4,
762
+ backgroundColor: barColor,
763
+ }}
764
+ />
765
+ </View>
766
+ </View>
767
+ );
768
+ })}
769
+ </View>
770
+ ))}
771
+ </View>
772
+ );
773
+ }
774
+
775
+ export function Status({ props }: { props: Props }) {
776
+ const { theme } = useUiRuntime();
777
+ const today = useMemo(() => localDay(new Date()), []);
778
+ const parsed = readStatus(props.items, today);
779
+ if (!parsed.ok) return <ErrorLine message={parsed.error} />;
780
+ const report = parsed.value;
781
+
782
+ return (
783
+ <View
784
+ style={{
785
+ borderRadius: 8,
786
+ borderWidth: StyleSheet.hairlineWidth,
787
+ borderColor: theme.colors.border,
788
+ overflow: "hidden",
789
+ }}
790
+ >
791
+ {report.items.map((item, idx) => {
792
+ const color =
793
+ item.status === "on-track" || item.status === "done"
794
+ ? theme.colors.statusSuccess
795
+ : item.status === "at-risk"
796
+ ? theme.colors.statusWarning
797
+ : item.status === "off-track" || item.status === "blocked"
798
+ ? theme.colors.statusDanger
799
+ : theme.colors.foregroundMuted;
800
+ return (
801
+ <View
802
+ key={item.index}
803
+ style={{
804
+ paddingHorizontal: 12,
805
+ paddingVertical: 9,
806
+ borderTopWidth: idx === 0 ? 0 : StyleSheet.hairlineWidth,
807
+ borderTopColor: theme.colors.border,
808
+ gap: 3,
809
+ }}
810
+ >
811
+ <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
812
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>
813
+ {item.name}
814
+ </Text>
815
+ <Text style={{ color, fontSize: 11.5, fontWeight: "600" }}>
816
+ {item.status ? HEALTH_LABEL[item.status] : item.given ?? "Unknown"}
817
+ {item.progress !== null ? ` (${Math.round(item.progress)}%)` : ""}
818
+ </Text>
819
+ </View>
820
+ {item.note || item.owner || item.due !== null ? (
821
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
822
+ {[
823
+ item.owner,
824
+ item.due !== null ? `Due ${formatDay(item.due, today)}` : null,
825
+ item.note,
826
+ ]
827
+ .filter(Boolean)
828
+ .join(" · ")}
829
+ </Text>
830
+ ) : null}
831
+ </View>
832
+ );
833
+ })}
834
+ </View>
835
+ );
836
+ }