@prestack/thread-ui 0.0.1 → 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 (279) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/ui/animations/anm_appear.ts +13 -0
  3. package/dist/ui/animations/anm_blur_in.ts +19 -0
  4. package/dist/ui/animations/anm_blur_out.ts +18 -0
  5. package/dist/ui/animations/anm_dismiss.ts +18 -0
  6. package/dist/ui/animations/anm_drop.ts +19 -0
  7. package/dist/ui/animations/anm_fall.ts +18 -0
  8. package/dist/ui/animations/anm_flash.ts +18 -0
  9. package/dist/ui/animations/anm_glow.ts +16 -0
  10. package/dist/ui/animations/anm_lift.ts +16 -0
  11. package/dist/ui/animations/anm_nudge.ts +16 -0
  12. package/dist/ui/animations/anm_pop_in.ts +23 -0
  13. package/dist/ui/animations/anm_pop_out.ts +22 -0
  14. package/dist/ui/animations/anm_press.ts +12 -0
  15. package/dist/ui/animations/anm_pulse.ts +13 -0
  16. package/dist/ui/animations/anm_rise.ts +19 -0
  17. package/dist/ui/animations/anm_shake.ts +13 -0
  18. package/dist/ui/animations/anm_slide_in_left.ts +19 -0
  19. package/dist/ui/animations/anm_slide_in_right.ts +19 -0
  20. package/dist/ui/animations/anm_slide_out_left.ts +18 -0
  21. package/dist/ui/animations/anm_slide_out_right.ts +18 -0
  22. package/dist/ui/animations/anm_spin.ts +12 -0
  23. package/dist/ui/animations/anm_swell.ts +16 -0
  24. package/dist/ui/animations/anm_wobble.ts +24 -0
  25. package/dist/ui/animations/anm_zoom_in.ts +19 -0
  26. package/dist/ui/animations/anm_zoom_out.ts +18 -0
  27. package/dist/ui/animations.js +2 -3
  28. package/dist/ui/animations.ts +316 -0
  29. package/dist/ui/attach.js +13 -10
  30. package/dist/ui/attach.ts +607 -0
  31. package/dist/ui/charts.tsx +74 -0
  32. package/dist/ui/checked.d.ts +2 -2
  33. package/dist/ui/checked.ts +32 -0
  34. package/dist/ui/collector.ts +140 -0
  35. package/dist/ui/compositions.d.ts +11 -2
  36. package/dist/ui/compositions.js +283 -59
  37. package/dist/ui/compositions.ts +633 -0
  38. package/dist/ui/consent.ts +55 -0
  39. package/dist/ui/content.tsx +39 -0
  40. package/dist/ui/contrast.ts +27 -0
  41. package/dist/ui/controls.tsx +30 -0
  42. package/dist/ui/disclosure.tsx +22 -0
  43. package/dist/ui/dismiss-layer.ts +87 -0
  44. package/dist/ui/door.ts +140 -0
  45. package/dist/ui/drag.ts +316 -0
  46. package/dist/ui/elements/accordion.js +2 -2
  47. package/dist/ui/elements/accordion.tsx +47 -0
  48. package/dist/ui/elements/accordion.works.ts +66 -0
  49. package/dist/ui/elements/aspect-ratio.js +1 -1
  50. package/dist/ui/elements/aspect-ratio.tsx +30 -0
  51. package/dist/ui/elements/aspect-ratio.works.ts +5 -0
  52. package/dist/ui/elements/avatar.js +1 -1
  53. package/dist/ui/elements/avatar.tsx +33 -0
  54. package/dist/ui/elements/avatar.works.ts +16 -0
  55. package/dist/ui/elements/badge.js +1 -1
  56. package/dist/ui/elements/badge.tsx +32 -0
  57. package/dist/ui/elements/badge.works.ts +9 -0
  58. package/dist/ui/elements/bars.js +2 -2
  59. package/dist/ui/elements/bars.tsx +49 -0
  60. package/dist/ui/elements/bars.works.ts +29 -0
  61. package/dist/ui/elements/box.js +3 -2
  62. package/dist/ui/elements/box.tsx +35 -0
  63. package/dist/ui/elements/breadcrumb.js +2 -2
  64. package/dist/ui/elements/breadcrumb.tsx +37 -0
  65. package/dist/ui/elements/breadcrumb.works.ts +24 -0
  66. package/dist/ui/elements/button.js +1 -1
  67. package/dist/ui/elements/button.tsx +45 -0
  68. package/dist/ui/elements/button.works.tsx +52 -0
  69. package/dist/ui/elements/column.js +1 -1
  70. package/dist/ui/elements/column.tsx +28 -0
  71. package/dist/ui/elements/consent.js +2 -2
  72. package/dist/ui/elements/consent.tsx +70 -0
  73. package/dist/ui/elements/consent.works.tsx +132 -0
  74. package/dist/ui/elements/donut.js +2 -2
  75. package/dist/ui/elements/donut.tsx +44 -0
  76. package/dist/ui/elements/donut.works.ts +42 -0
  77. package/dist/ui/elements/each.js +1 -1
  78. package/dist/ui/elements/each.tsx +33 -0
  79. package/dist/ui/elements/each.works.tsx +82 -0
  80. package/dist/ui/elements/empty.js +1 -1
  81. package/dist/ui/elements/empty.tsx +32 -0
  82. package/dist/ui/elements/field.js +1 -1
  83. package/dist/ui/elements/field.tsx +44 -0
  84. package/dist/ui/elements/field.works.ts +65 -0
  85. package/dist/ui/elements/funnel.js +2 -2
  86. package/dist/ui/elements/funnel.tsx +49 -0
  87. package/dist/ui/elements/funnel.works.ts +37 -0
  88. package/dist/ui/elements/gauge.js +1 -1
  89. package/dist/ui/elements/gauge.tsx +39 -0
  90. package/dist/ui/elements/gauge.works.ts +42 -0
  91. package/dist/ui/elements/glyph.js +1 -1
  92. package/dist/ui/elements/glyph.tsx +29 -0
  93. package/dist/ui/elements/glyph.works.tsx +42 -0
  94. package/dist/ui/elements/grid.js +1 -1
  95. package/dist/ui/elements/grid.tsx +28 -0
  96. package/dist/ui/elements/heatmap.js +2 -2
  97. package/dist/ui/elements/heatmap.tsx +47 -0
  98. package/dist/ui/elements/heatmap.works.ts +24 -0
  99. package/dist/ui/elements/hit-box.js +1 -1
  100. package/dist/ui/elements/hit-box.tsx +46 -0
  101. package/dist/ui/elements/hit-box.works.tsx +576 -0
  102. package/dist/ui/elements/image.js +1 -1
  103. package/dist/ui/elements/image.tsx +28 -0
  104. package/dist/ui/elements/line-chart.js +2 -2
  105. package/dist/ui/elements/line-chart.tsx +44 -0
  106. package/dist/ui/elements/line-chart.works.ts +41 -0
  107. package/dist/ui/elements/link.js +1 -1
  108. package/dist/ui/elements/link.tsx +37 -0
  109. package/dist/ui/elements/link.works.ts +25 -0
  110. package/dist/ui/elements/media.js +1 -1
  111. package/dist/ui/elements/media.tsx +56 -0
  112. package/dist/ui/elements/menu.js +2 -2
  113. package/dist/ui/elements/menu.tsx +50 -0
  114. package/dist/ui/elements/menu.works.tsx +153 -0
  115. package/dist/ui/elements/money.js +1 -1
  116. package/dist/ui/elements/money.tsx +28 -0
  117. package/dist/ui/elements/money.works.ts +9 -0
  118. package/dist/ui/elements/pagination.js +2 -2
  119. package/dist/ui/elements/pagination.tsx +38 -0
  120. package/dist/ui/elements/pagination.works.ts +20 -0
  121. package/dist/ui/elements/progress.js +1 -1
  122. package/dist/ui/elements/progress.tsx +33 -0
  123. package/dist/ui/elements/progress.works.ts +8 -0
  124. package/dist/ui/elements/radio-group.js +2 -2
  125. package/dist/ui/elements/radio-group.tsx +49 -0
  126. package/dist/ui/elements/radio-group.works.ts +33 -0
  127. package/dist/ui/elements/row.js +1 -1
  128. package/dist/ui/elements/row.tsx +28 -0
  129. package/dist/ui/elements/scatter.js +1 -1
  130. package/dist/ui/elements/scatter.tsx +41 -0
  131. package/dist/ui/elements/scatter.works.ts +38 -0
  132. package/dist/ui/elements/select.js +2 -2
  133. package/dist/ui/elements/select.tsx +42 -0
  134. package/dist/ui/elements/select.works.ts +5 -0
  135. package/dist/ui/elements/separator.js +1 -1
  136. package/dist/ui/elements/separator.tsx +30 -0
  137. package/dist/ui/elements/skeleton.js +1 -1
  138. package/dist/ui/elements/skeleton.tsx +30 -0
  139. package/dist/ui/elements/slider.js +1 -1
  140. package/dist/ui/elements/slider.tsx +33 -0
  141. package/dist/ui/elements/slider.works.ts +5 -0
  142. package/dist/ui/elements/sparkline.js +1 -1
  143. package/dist/ui/elements/sparkline.tsx +36 -0
  144. package/dist/ui/elements/sparkline.works.ts +32 -0
  145. package/dist/ui/elements/spinner.js +1 -1
  146. package/dist/ui/elements/spinner.tsx +32 -0
  147. package/dist/ui/elements/stack.js +1 -1
  148. package/dist/ui/elements/stack.tsx +28 -0
  149. package/dist/ui/elements/stat.js +1 -1
  150. package/dist/ui/elements/stat.tsx +35 -0
  151. package/dist/ui/elements/stat.works.ts +14 -0
  152. package/dist/ui/elements/surface.js +1 -1
  153. package/dist/ui/elements/surface.tsx +35 -0
  154. package/dist/ui/elements/surface.works.ts +9 -0
  155. package/dist/ui/elements/table.js +2 -2
  156. package/dist/ui/elements/table.tsx +61 -0
  157. package/dist/ui/elements/table.works.ts +30 -0
  158. package/dist/ui/elements/tabs.js +2 -2
  159. package/dist/ui/elements/tabs.tsx +47 -0
  160. package/dist/ui/elements/tabs.works.ts +84 -0
  161. package/dist/ui/elements/text.js +1 -1
  162. package/dist/ui/elements/text.tsx +32 -0
  163. package/dist/ui/elements/textarea.js +1 -1
  164. package/dist/ui/elements/textarea.tsx +37 -0
  165. package/dist/ui/elements/textarea.works.ts +5 -0
  166. package/dist/ui/elements/time.js +1 -1
  167. package/dist/ui/elements/time.tsx +34 -0
  168. package/dist/ui/elements/time.works.ts +25 -0
  169. package/dist/ui/elements/toggle.js +1 -1
  170. package/dist/ui/elements/toggle.tsx +33 -0
  171. package/dist/ui/elements/toggle.works.ts +5 -0
  172. package/dist/ui/elements/tooltip.js +1 -1
  173. package/dist/ui/elements/tooltip.tsx +39 -0
  174. package/dist/ui/elements/tooltip.works.ts +77 -0
  175. package/dist/ui/elements/value.js +1 -1
  176. package/dist/ui/elements/value.tsx +30 -0
  177. package/dist/ui/elements/value.works.ts +9 -0
  178. package/dist/ui/elements.ts +127 -0
  179. package/dist/ui/focus-scope.ts +121 -0
  180. package/dist/ui/fonts.ts +355 -0
  181. package/dist/ui/generators/dev.js +2 -1
  182. package/dist/ui/generators/dev.ts +80 -0
  183. package/dist/ui/generators/elements.d.ts +1 -11
  184. package/dist/ui/generators/elements.js +44 -14
  185. package/dist/ui/generators/elements.ts +794 -0
  186. package/dist/ui/generators/i18n.js +2 -3
  187. package/dist/ui/generators/i18n.ts +300 -0
  188. package/dist/ui/generators/icons.js +3 -12
  189. package/dist/ui/generators/icons.ts +214 -0
  190. package/dist/ui/generators/react.js +13 -5
  191. package/dist/ui/generators/react.ts +269 -0
  192. package/dist/ui/generators/routes.d.ts +1 -0
  193. package/dist/ui/generators/routes.js +99 -83
  194. package/dist/ui/generators/routes.ts +743 -0
  195. package/dist/ui/generators/show.js +4 -12
  196. package/dist/ui/generators/show.ts +260 -0
  197. package/dist/ui/generators/styles.js +3 -4
  198. package/dist/ui/generators/styles.ts +669 -0
  199. package/dist/ui/generators/variants.js +2 -3
  200. package/dist/ui/generators/variants.ts +204 -0
  201. package/dist/ui/i18n/catalogs/en.ts +191 -0
  202. package/dist/ui/i18n/format.ts +221 -0
  203. package/dist/ui/i18n/locales.ts +33 -0
  204. package/dist/ui/i18n/parse.ts +315 -0
  205. package/dist/ui/i18n/translator.ts +31 -0
  206. package/dist/ui/i18n/types.ts +96 -0
  207. package/dist/ui/icons.ts +331 -0
  208. package/dist/ui/islands/map.ts +58 -0
  209. package/dist/ui/localizations.ts +75 -0
  210. package/dist/ui/material.ts +236 -0
  211. package/dist/ui/materials/mat_bordered.ts +13 -0
  212. package/dist/ui/materials/mat_ceramic.ts +13 -0
  213. package/dist/ui/materials/mat_clay.ts +13 -0
  214. package/dist/ui/materials/mat_elevated.ts +24 -0
  215. package/dist/ui/materials/mat_flat.ts +34 -0
  216. package/dist/ui/materials/mat_glass.ts +20 -0
  217. package/dist/ui/materials/mat_lit.ts +13 -0
  218. package/dist/ui/materials/mat_moulded.ts +11 -0
  219. package/dist/ui/materials.js +2 -3
  220. package/dist/ui/materials.ts +205 -0
  221. package/dist/ui/modes.ts +44 -0
  222. package/dist/ui/navigation.tsx +32 -0
  223. package/dist/ui/positioning.ts +90 -0
  224. package/dist/ui/presented.tsx +119 -0
  225. package/dist/ui/provider.d.ts +2 -0
  226. package/dist/ui/provider.js +7 -1
  227. package/dist/ui/provider.tsx +182 -0
  228. package/dist/ui/render-loop.ts +133 -0
  229. package/dist/ui/roving-focus.ts +161 -0
  230. package/dist/ui/screens.js +22 -22
  231. package/dist/ui/screens.ts +141 -0
  232. package/dist/ui/scroll-lock.ts +28 -0
  233. package/dist/ui/series.ts +97 -0
  234. package/dist/ui/signals.ts +386 -0
  235. package/dist/ui/styleguide.ts +231 -0
  236. package/dist/ui/styleguides.ts +374 -0
  237. package/dist/ui/themes.ts +22 -0
  238. package/dist/ui/token.js +1 -1
  239. package/dist/ui/token.ts +78 -0
  240. package/dist/ui/tokens/shadow.ts +25 -0
  241. package/dist/ui/tokens.js +1 -2
  242. package/dist/ui/tokens.ts +234 -0
  243. package/dist/ui/validators.ts +232 -0
  244. package/dist/ui/variant.d.ts +1 -0
  245. package/dist/ui/variant.js +3 -0
  246. package/dist/ui/variant.ts +352 -0
  247. package/dist/ui/variants/var_avatar-round.ts +14 -0
  248. package/dist/ui/variants/var_bubble-me.ts +14 -0
  249. package/dist/ui/variants/var_bubble-them.ts +13 -0
  250. package/dist/ui/variants/var_button-border.ts +76 -0
  251. package/dist/ui/variants/var_button-cta.ts +76 -0
  252. package/dist/ui/variants/var_button-destructive-ghost.ts +72 -0
  253. package/dist/ui/variants/var_button-destructive.ts +76 -0
  254. package/dist/ui/variants/var_button-fill.ts +76 -0
  255. package/dist/ui/variants/var_button-ghost.ts +72 -0
  256. package/dist/ui/variants/var_button-glass.ts +55 -0
  257. package/dist/ui/variants/var_button-normal.ts +76 -0
  258. package/dist/ui/variants/var_button-quiet.ts +74 -0
  259. package/dist/ui/variants/var_card.ts +17 -0
  260. package/dist/ui/variants/var_chip.ts +13 -0
  261. package/dist/ui/variants/var_choice.ts +15 -0
  262. package/dist/ui/variants/var_framed.ts +10 -0
  263. package/dist/ui/variants/var_glyph-plain.ts +11 -0
  264. package/dist/ui/variants/var_input.ts +43 -0
  265. package/dist/ui/variants/var_link-inline.ts +29 -0
  266. package/dist/ui/variants/var_list-row.ts +34 -0
  267. package/dist/ui/variants/var_rule.ts +10 -0
  268. package/dist/ui/variants/var_skeleton-block.ts +12 -0
  269. package/dist/ui/variants/var_spinner-turning.ts +12 -0
  270. package/dist/ui/variants/var_stat-tile.ts +13 -0
  271. package/dist/ui/variants/var_tab-bar.ts +15 -0
  272. package/dist/ui/variants/var_tap-target.ts +25 -0
  273. package/dist/ui/variants.ts +64 -0
  274. package/dist/ui/wrappers.d.ts +4 -1
  275. package/dist/ui/wrappers.js +60 -20
  276. package/dist/ui/wrappers.tsx +426 -0
  277. package/package.json +8 -2
  278. package/dist/ui/makers/message.d.ts +0 -1
  279. package/dist/ui/makers/message.js +0 -230
@@ -0,0 +1,633 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from '@prestack/thread/core/files'
2
+ import { basename, dirname, join, relative } from 'node:path'
3
+ import { argv, stdout } from 'node:process'
4
+
5
+ import { TYPED, wrapped } from '@prestack/thread/core/answers'
6
+ import { exit, invoked } from '@prestack/thread/core/invoked'
7
+ import { valuesIn } from '@prestack/thread/core/rows'
8
+ import type { Threaded } from '@prestack/thread/core/thread'
9
+ import { named as packageOf } from '@prestack/thread/core/scope'
10
+ import type { Tree } from '@prestack/thread/core/tree'
11
+ import { findTree, inTree } from '@prestack/thread/core/tree'
12
+
13
+ import { compiled } from './generators/react.ts'
14
+
15
+ export const TEACHES: readonly string[] = [
16
+ ' what the body may reach, and how a premium state is written:',
17
+ '',
18
+ ' `useUi()` is the door. It carries `actor` — who is asking, with the roles read from',
19
+ ' the row that declared itself the caller — and `translate`, `clock` and `dispatch`. Name',
20
+ ' it in the body and the import is written for you.',
21
+ '',
22
+ ' A premium look is not decided at the call site. A grant already says who may run an',
23
+ ' operation, in words your own entity declares, and a signal that names that operation',
24
+ ' holds for exactly the callers the grant refuses. The rule is written once, on the',
25
+ ' grant, and every screen follows it:',
26
+ '',
27
+ ' <FeatureBox.paywalled>',
28
+ ' <Text k=\'feature.title\' />',
29
+ ' </FeatureBox.paywalled>',
30
+ '',
31
+ ' Read it in three parts, and each is its own command:',
32
+ '',
33
+ ' `locked` is a signal the ELEMENT declares it raises — `thread create element` with',
34
+ ' `raises: locked`. A signal this app declares cannot be raised on an element that',
35
+ ' never said it raises one, so `Box` will not take it. Nothing passes it here: it is',
36
+ ' set for you wherever the gate refuses the operation the signal names.',
37
+ ' `.paywalled` is a variant worn as the TAG, never passed as a prop, and it is what',
38
+ ' carries the look — `thread create variant` with a `state: locked` and what it sets.',
39
+ ' Declare that state on a MATERIAL instead and every variant made of it locks the same',
40
+ ' way, which is how one overlay dresses a whole app from one row.',
41
+ ' The rule itself is `thread grant`, and the words in it are options on the value your',
42
+ ' entity names as saying what a caller may do — `thread create entity`.',
43
+ '',
44
+ ' THE MEMBER IS THE VARIANT\'S WHOLE ID, CAMELCASED — never the part after the element',
45
+ ' name. A variant `button-fill` is worn `<Button.buttonFill>`, and `<Button.fill>` is not',
46
+ ' a thing. `.paywalled` above reads short only because that variant is itself named',
47
+ ' `paywalled`; most carry the element in their id and wear all of it.',
48
+ '',
49
+ ' how a control reaches an operation:',
50
+ '',
51
+ ' Wiring is imperative. There is no `run=`, `operation=` or `dispatch=` prop on any',
52
+ ' element, and the compiler is what will tell you so. A control calls dispatch in its own',
53
+ ' handler:',
54
+ '',
55
+ ' <Button.buttonFill label=\'common.save\'',
56
+ ' onPress={() => useUi().dispatch?.(\'wf_save\')} />',
57
+ '',
58
+ ' `dispatch` is optional on the hook, so `?.` is required — without it the compiler says',
59
+ ' "Cannot invoke an object which is possibly \'undefined\'". The name is the operation\'s own',
60
+ ' id from `thread create operation`, and one this app never declared is refused before it',
61
+ ' runs. An operation whose `to:` named nobody is refused however the button looks — and',
62
+ ' `to:` takes roles off an entity, which is a noun that arrives with `thread attach',
63
+ ` ${packageOf('data')}\`.`,
64
+ '',
65
+ ' Nothing here decides access. The gate still refuses the operation behind the lock',
66
+ ' whatever the screen shows, so a screen that got this wrong shows the wrong thing and',
67
+ ' still cannot do the wrong thing.',
68
+ '',
69
+ ' how a press moves to another screen:',
70
+ '',
71
+ ' Name the screen. `navigateTo` takes the screen\'s export name — its id in PascalCase, so',
72
+ ' `order-detail` is reached as `navigateTo="OrderDetail"`:',
73
+ '',
74
+ ' <Button.buttonNormal label=\'home.orders\' navigateTo="Orders" />',
75
+ '',
76
+ ' That is the whole of it. `thread routes` reads every `navigateTo` the screens and their',
77
+ ' compositions hold and writes the map — from which screen, through which button, to which',
78
+ ' screen — and the pages. A page is reached by its route, and a sheet, popover, overlay or',
79
+ ' toast opens where somebody already is, so the press is written the same way for both.',
80
+ ' A name no screen has and a destination not written out as a name are each refused there.',
81
+ ' Never an `href` and never',
82
+ ' a router: a move the map cannot see is a screen the map reads as unreachable.',
83
+ '',
84
+ ' how a screen shows data:',
85
+ '',
86
+ ' A screen NAMES the reads it binds, and never fetches. `views` takes the ids of declared',
87
+ ' views, the framework runs each one, and `useView` hands back what came out:',
88
+ '',
89
+ ' export const Orders = screen(\'orders\', { as: \'page\', views: [\'vw_orders\'] }, () => (',
90
+ ' <Each rows={useView<readonly Row[]>(\'vw_orders\') ?? []} by="id">',
91
+ ' {(row) => <Text k="orders.line" with={{ name: row.name }} />}',
92
+ ' </Each>',
93
+ ' ))',
94
+ '',
95
+ ' `useView` comes through the same door as `useUi`, and it refuses a name the screen did not',
96
+ ' bind. `thread routes` refuses a view this app declares no operation for, and writes what each',
97
+ ' screen shows into the map. A fetch of your own, or a hook you wrote, is how a screen ends up',
98
+ ' showing something nothing declared, and there is no second way in.',
99
+ ]
100
+
101
+ export const REACHES = /\buseUi\s*\(/
102
+
103
+ const here = 'thread/ui/compositions.ts'
104
+
105
+ const said = [
106
+ {
107
+ on: '2026-08-25',
108
+ said: 'Compositions is basically a mix of elements+variants, arranged with arrangement elements, with ways to invoke functions (navigateTo, dispatch).',
109
+ },
110
+ {
111
+ on: '2026-08-25',
112
+ said: 'The compositions.ts just says what the thing is, and describes it. Then the file itself, seeded, is a cmp_ file, where we build the actual UI from. And the UI file is plain react, just with our system.',
113
+ },
114
+ {
115
+ on: '2026-08-25',
116
+ said: 'Variants themselves declare what elements they relate to, in their declarations. Then the composition says button.primary and if that matches the combination of variant and element it is acceptable. And make sure the composition can only dispatch existing operations.',
117
+ },
118
+ ]
119
+
120
+ export const declares: readonly Threaded[] = []
121
+
122
+ export const COMPOSITIONS: Threaded & { readonly files: string } = {
123
+ id: 'composition',
124
+ at: here,
125
+ files: 'compositions/cmp_*.tsx',
126
+ describes:
127
+ 'A React component made through this system: elements wearing variants, arranged, and able to reach a declared operation. Each row names a file prefixed `cmp_`, and that file is plain React a person writes, because the arrangement, the conditions and the content are theirs. A call site states only what is left over: what a thing does comes from its element, and how it looks and which states it answers to come from its variant, so `Button.primary` is legal only where a variant `primary` says it is worn by `Button`. Text in it is a message key, a look is a variant, and a value names a style guide row, never a raw one. The framework ships none, because what an app is made of is the app’s, and this row says what a composition is, never what is inside it.',
128
+ version: 1,
129
+ cites: said,
130
+ declares,
131
+ }
132
+
133
+ const PASCAL = (id: string): string =>
134
+ id
135
+ .split('-')
136
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
137
+ .join('')
138
+
139
+ export function nameOf(at: string): string {
140
+ const file = at.split('\\').join('/').split('/').at(-1) ?? at
141
+ return PASCAL(file.replace(/^cmp_/, '').replace(/\.[^.]+$/, ''))
142
+ }
143
+
144
+ type Listed = {
145
+ readonly id: string
146
+ readonly at?: string
147
+ readonly describes?: string
148
+ readonly wears?: readonly string[]
149
+ readonly arranges?: boolean
150
+ }
151
+
152
+ function sheetOf(tree: Tree, file: string, ...named: readonly string[]): readonly Listed[] {
153
+ const at = inTree(tree, file)
154
+ if (!existsSync(at)) return []
155
+ const held = valuesIn(readFileSync(at)) as Readonly<
156
+ Record<string, { readonly declares?: readonly Listed[] } | undefined>
157
+ >
158
+ for (const one of named) {
159
+ const found = held[one]?.declares
160
+ if (found !== undefined) return found
161
+ }
162
+ return []
163
+ }
164
+
165
+ const camelOf = (id: string): string => id.replace(/-([a-z0-9])/g, (_, one: string) => one.toUpperCase())
166
+
167
+ const sentence = (one: Listed, pad: string): readonly string[] =>
168
+ String(one.describes ?? '').trim() === '' ? [] : wrapped(String(one.describes), pad)
169
+
170
+ function listed(tree: Tree, making: 'composition' | 'screen'): readonly string[] {
171
+ const elements = sheetOf(tree, `elements.${'ts'}`, 'ELEMENT', 'ELEMENTS')
172
+ const variants = sheetOf(tree, `variants.${'ts'}`, 'VARIANT', 'VARIANTS')
173
+ const compositions = sheetOf(tree, `compositions.${'ts'}`, 'COMPOSITION', 'COMPOSITIONS')
174
+ const screens = making === 'screen' ? sheetOf(tree, `screens.${'ts'}`, 'SCREEN', 'SCREENS') : []
175
+
176
+ const worn = new Map<string, string[]>()
177
+ for (const one of variants) {
178
+ for (const each of one.wears ?? []) {
179
+ const key = PASCAL(each)
180
+ const already = worn.get(key) ?? []
181
+ if (!already.includes(one.id)) worn.set(key, [...already, one.id])
182
+ }
183
+ }
184
+
185
+ const writes = (one: Listed): string => {
186
+ const name = PASCAL(one.id)
187
+ const members = (worn.get(name) ?? []).sort().map((each) => `<${name}.${camelOf(each)}>`)
188
+ if (members.length === 0) return `<${name}>`
189
+ return one.arranges === true ? `<${name}>, or ${members.join(' ')}` : members.join(' ')
190
+ }
191
+
192
+ const section = (title: string, rows: readonly Listed[], write: (one: Listed) => string): readonly string[] =>
193
+ rows.length === 0
194
+ ? [` ${title}: none yet.`, '']
195
+ : [
196
+ ` ${title} (${String(rows.length)}):`,
197
+ ...rows.flatMap((one) => [` ${write(one)}`, ...sentence(one, ' ')]),
198
+ '',
199
+ ]
200
+
201
+ return [
202
+ '',
203
+ ...(making === 'screen'
204
+ ? section('SCREENS that exist, and the name a press reaches each one by', screens, (one) => `${one.id} — navigateTo="${PASCAL(one.id)}"`)
205
+ : []),
206
+ ...section('COMPOSITIONS you can place', compositions, (one) =>
207
+ `<${typeof one.at === 'string' && one.at !== '' ? nameOf(one.at) : PASCAL(one.id)}>`,
208
+ ),
209
+ ...section('ELEMENTS, written as the tags they can actually be typed as', elements, writes),
210
+ ...(variants.length === 0
211
+ ? [' VARIANTS: none yet.', '']
212
+ : [
213
+ ` VARIANTS (${String(variants.length)}), under the elements that accept them:`,
214
+ ...[...worn.keys()].sort().map((name) =>
215
+ ` ${name} — ${(worn.get(name) ?? []).sort().map(camelOf).join(', ')}`,
216
+ ),
217
+ ...(() => {
218
+ const nobody = variants.filter((one) => (one.wears ?? []).length === 0).map((one) => one.id)
219
+ return nobody.length === 0
220
+ ? []
221
+ : [` worn by nothing, so writable nowhere: ${nobody.sort().join(', ')}`]
222
+ })(),
223
+ '',
224
+ ]),
225
+ ' Materials, style guide values, fonts and animations are chosen inside a VARIANT, never inside',
226
+ ` a body — \`${TYPED} create variant\` prints every one of them at the moment one is needed.`,
227
+ '',
228
+
229
+ ` Every element and the tags it is written as: \`${TYPED} create element\`. Every variant and`,
230
+ ` the tag it is worn as: \`${TYPED} create variant\`. Then open the one nearest to what you are`,
231
+ ` about to build with \`${TYPED} read <id>\` — it already answers how a body is arranged, how a`,
232
+ ' variant is worn and how a control reaches an operation, and a file cannot be wrong in the way',
233
+ ' a remembered example can.',
234
+ '',
235
+ ]
236
+ }
237
+
238
+ export const catalogue = async (): Promise<readonly string[]> => listed(findTree(), 'composition')
239
+
240
+ export const screening = async (): Promise<readonly string[]> => listed(findTree(), 'screen')
241
+
242
+ const TAG = /<([A-Z][A-Za-z0-9]*)/g
243
+
244
+ export function composed(
245
+ body: string,
246
+ rows: readonly { readonly id: string; readonly at?: string }[],
247
+ from: 'screens' | 'compositions',
248
+ except?: string,
249
+ ): readonly string[] {
250
+ const named = new Map<string, string>()
251
+ for (const row of rows) {
252
+ const at = row.at
253
+ if (typeof at !== 'string' || at === '' || at === except) continue
254
+ named.set(PASCAL(row.id), at)
255
+ }
256
+ const found = new Set<string>()
257
+ for (const [, tag] of body.matchAll(TAG)) if (tag !== undefined && named.has(tag)) found.add(tag)
258
+ return [...found].sort().map((one) => {
259
+ const at = named.get(one) ?? ''
260
+ const file = (at.split('\\').join('/').split('/').at(-1) ?? at).replace(/\.[^.]+$/, '')
261
+ return `import { ${one} } from '${from === 'screens' ? '../compositions' : '.'}/${file}.tsx'`
262
+ })
263
+ }
264
+
265
+ const IMPORTS = /^import\s[\s\S]*?from\s*['"]([^'"]+)['"]/gm
266
+
267
+ const FRAMEWORK = 'thread/'
268
+
269
+ export const HOME = `${packageOf('ui')}/${here.slice(FRAMEWORK.length).split('/').slice(0, -1).join('/')}`
270
+
271
+ export const DOORS: ReadonlySet<string> = new Set(['react', `${HOME}/wrappers`, `${HOME}/drag`])
272
+
273
+ const SHIPPED = new RegExp(`^${HOME}/elements/([a-z0-9-]+)$`)
274
+
275
+ const slashed = (path: string): string => path.split('\\').join('/')
276
+
277
+ export function reaching(
278
+ at: string,
279
+ text: string,
280
+ elements: ReadonlySet<string>,
281
+ declared: ReadonlySet<string>,
282
+ ): readonly string[] {
283
+ const here = slashed(at).split('/').slice(0, -1).join('/')
284
+ const found = new Set<string>()
285
+ for (const one of text.matchAll(IMPORTS)) {
286
+ const from = one[1]
287
+ if (from === undefined || DOORS.has(from)) continue
288
+ const shipped = SHIPPED.exec(from)?.[1]
289
+ if (shipped !== undefined && elements.has(shipped)) continue
290
+ if (from.startsWith('.') && declared.has(slashed(join(here, from)))) continue
291
+ found.add(from)
292
+ }
293
+ return [...found].sort()
294
+ }
295
+
296
+ const CLAUSE = /^import\s+(?!type\s)([\s\S]*?)\s*from\s*['"][^'"]+['"]/gm
297
+
298
+ const WORD = /(?:^|[,{]|\s)(?:[A-Za-z_$][\w$]*\s+as\s+)?([A-Za-z_$][\w$]*)/g
299
+
300
+ const NOT_A_NAME = new Set(['type', 'as', 'from', 'import'])
301
+
302
+ const KEPT = 'a composition keeps no state of its own. State belongs to the element that raises the signal, in the works file beside it'
303
+
304
+ const RUNS = 'nothing here runs on its own. A read is bound by the screen in its own views list, and a press is answered by dispatch'
305
+
306
+ const REFUSED: Readonly<Record<string, string>> = {
307
+ useState: KEPT,
308
+ useReducer: KEPT,
309
+ useSyncExternalStore: KEPT,
310
+ useEffect: RUNS,
311
+ useLayoutEffect: RUNS,
312
+ element: 'only the compiler writes an element, and a file that calls it turns off the checks that keep this one declared',
313
+ }
314
+
315
+ export const COMPILED = 'app/_generated/compiled'
316
+
317
+ export function compiledAt(at: string): string {
318
+ return `${COMPILED}/${slashed(at)}`
319
+ }
320
+
321
+ const ENDINGS = ['.tsx', '.ts'] as const
322
+
323
+ function beside(root: string, where: string, said: string): string | undefined {
324
+ if (!said.startsWith(`${HOME}/`)) return undefined
325
+ const tail = said.slice(HOME.length + 1)
326
+ for (const ending of ENDINGS) {
327
+ const at = `${COMPILED}/ui/${tail}${ending}`
328
+ if (!existsSync(join(root, at))) continue
329
+ const out = slashed(relative(dirname(where), at))
330
+ return out.startsWith('.') ? out : `./${out}`
331
+ }
332
+ return undefined
333
+ }
334
+
335
+ const NAMED_IMPORT = /(['"])(@[^'"]+)\1/g
336
+
337
+ export function settled(
338
+ root: string,
339
+ rows: readonly Threaded[],
340
+ checking: boolean,
341
+ by: string,
342
+ kept: Set<string>,
343
+ ): readonly string[] {
344
+ const said: string[] = []
345
+ for (const row of rows) {
346
+ const file = join(root, row.at)
347
+ if (!existsSync(file)) continue
348
+ const held = compiled(readFileSync(file, 'utf8'), row.at)
349
+ if (held.because !== undefined) {
350
+ said.push(held.because)
351
+ continue
352
+ }
353
+ const where = compiledAt(row.at)
354
+ kept.add(where)
355
+ const text = (held.text ?? '').replace(NAMED_IMPORT, (whole: string, quote: string, said: string) => {
356
+ const found = beside(root, where, said)
357
+ return found === undefined ? whole : `${quote}${found}${quote}`
358
+ })
359
+ const at = join(root, where)
360
+ if (!checking) {
361
+ writeFileSync(at, text, 'utf8')
362
+ continue
363
+ }
364
+ if (existsSync(at) && readFileSync(at, 'utf8') === text) continue
365
+ said.push(
366
+ `${where} is not what the compiler writes from ${row.at}. A compiled file that differs from its ` +
367
+ `source is a hand edit wearing a generated name. Run \`${by}\` and commit what it writes.`,
368
+ )
369
+ }
370
+ return said
371
+ }
372
+
373
+ const ORG = packageOf('ui').split('/')[0] ?? ''
374
+
375
+ const REACHED = /^(?:import|export)\s+(type\s+)?(?:(?:[^\n]|\n(?=[ \t}]))*?\s)?from\s*['"]([^'"]+)['"]/gm
376
+
377
+ export const RUNTIME: readonly string[] = ['wrappers.tsx', 'drag.ts', 'provider.tsx', 'islands/map.ts']
378
+
379
+ const NODE = /^node:/
380
+
381
+ function installs(root: string): ReadonlySet<string> {
382
+ try {
383
+ const held = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
384
+ readonly dependencies?: Readonly<Record<string, string>>
385
+ readonly devDependencies?: Readonly<Record<string, string>>
386
+ }
387
+ return new Set([...Object.keys(held.dependencies ?? {}), ...Object.keys(held.devDependencies ?? {})])
388
+ } catch {
389
+ return new Set()
390
+ }
391
+ }
392
+
393
+ function packaging(said: string): string {
394
+ return said.startsWith('@') ? said.split('/').slice(0, 2).join('/') : (said.split('/')[0] ?? said)
395
+ }
396
+
397
+ export function emitting(
398
+ root: string,
399
+ entries: readonly string[],
400
+ checking: boolean,
401
+ kept: Set<string>,
402
+ ): readonly string[] {
403
+ const has = installs(root)
404
+ const from = join(root, 'node_modules', packageOf('ui'), 'dist', 'ui')
405
+ const said: string[] = []
406
+ if (!existsSync(from)) {
407
+ return [
408
+ `this app declares an interface and ${packageOf('ui')} is not installed, so there is nothing to emit it from. ` +
409
+ `Install it and run \`thread attach ${packageOf('ui')}\` again.`,
410
+ ]
411
+ }
412
+ const seen = new Set<string>()
413
+ const walk = (at: string): void => {
414
+ if (seen.has(at)) return
415
+ seen.add(at)
416
+ const file = join(from, at)
417
+ if (!existsSync(file)) {
418
+ said.push(
419
+ `${at} is part of the interface this app emits, and the installed ${packageOf('ui')} does not hold it. ` +
420
+ 'Upgrade the framework packages and run this again.',
421
+ )
422
+ return
423
+ }
424
+ const text = readFileSync(file, 'utf8')
425
+ const where = `${COMPILED}/ui/${at}`
426
+ kept.add(where)
427
+ const to = join(root, where)
428
+ if (checking) {
429
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
430
+ said.push(
431
+ `${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
432
+ 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.',
433
+ )
434
+ }
435
+ } else {
436
+ writeFileSync(to, text, 'utf8')
437
+ }
438
+ for (const one of text.matchAll(REACHED)) {
439
+ const named = one[2] ?? ''
440
+ if (named.startsWith('.')) {
441
+ const next = slashed(join(dirname(at), named))
442
+ if (next.startsWith('..')) {
443
+ said.push(`${at} reaches \`${named}\`, which is outside the interface this app emits.`)
444
+ continue
445
+ }
446
+ walk(next)
447
+ continue
448
+ }
449
+ if (one[1] !== undefined) continue
450
+ if (!named.startsWith(`${ORG}/`)) {
451
+ const held = packaging(named)
452
+ if (NODE.test(named) || has.has(held)) continue
453
+ said.push(
454
+ `${at} reaches ${held}, and this app installs nothing by that name. An emitted interface brings ` +
455
+ `what it reaches with it, so ${held} has to arrive when ` +
456
+ `thread attach ${packageOf('ui')} runs.`,
457
+ )
458
+ continue
459
+ }
460
+ said.push(
461
+ `${at} reaches \`${named}\`, so emitting it would leave this app importing the framework while it runs. ` +
462
+ 'It reaches what is emitted beside it, or nothing of this framework at all.',
463
+ )
464
+ }
465
+ }
466
+ for (const one of entries) walk(one)
467
+ return said
468
+ }
469
+
470
+ export function stale(root: string, kept: ReadonlySet<string>, checking: boolean): readonly string[] {
471
+ const said: string[] = []
472
+ const walk = (at: string): void => {
473
+ const where = join(root, at)
474
+ if (!existsSync(where)) return
475
+ for (const each of readdirSync(where, { withFileTypes: true })) {
476
+ const path = `${at}/${each.name}`
477
+ if (each.isDirectory()) {
478
+ walk(path)
479
+ continue
480
+ }
481
+ if (kept.has(path)) continue
482
+ if (checking) {
483
+ said.push(
484
+ `${path} sits under ${COMPILED} and nothing declares what it was written from any more. ` +
485
+ 'Run `thread compositions` and it goes.',
486
+ )
487
+ continue
488
+ }
489
+ rmSync(join(root, path), { force: true })
490
+ }
491
+ }
492
+ walk(COMPILED)
493
+ return said
494
+ }
495
+
496
+ export function handing(at: string, text: string): readonly string[] {
497
+ const found: string[] = []
498
+ for (const one of text.matchAll(CLAUSE)) {
499
+ for (const name of (one[1] ?? '').matchAll(WORD)) {
500
+ const held = name[1]
501
+ if (held === undefined || NOT_A_NAME.has(held)) continue
502
+ const why = REFUSED[held]
503
+ if (why === undefined) continue
504
+ found.push(`${at} imports \`${held}\`, and ${why}.`)
505
+ }
506
+ }
507
+ if (/\bfetch\s*\(/.test(text)) {
508
+ found.push(
509
+ `${at} calls \`fetch\`, and nothing here reaches the network by hand. A screen names the reads it ` +
510
+ 'binds in its own views list, and the framework runs each one.',
511
+ )
512
+ }
513
+ return found
514
+ }
515
+
516
+ export function undeclared(
517
+ tree: Tree,
518
+ rows: readonly Threaded[],
519
+ ): readonly string[] {
520
+ const elements = new Set<string>()
521
+ const declared = new Set<string>()
522
+
523
+ for (const [file, exported] of [
524
+ ['elements.ts', 'ELEMENT'],
525
+ ['compositions.ts', 'COMPOSITION'],
526
+ ['screens.ts', 'SCREEN'],
527
+ ] as const) {
528
+ const where = inTree(tree, file)
529
+ if (!existsSync(where)) continue
530
+ const sheet = valuesIn(readFileSync(where)) as Readonly<
531
+ Record<string, { readonly declares?: readonly Threaded[] } | undefined>
532
+ >
533
+ for (const row of sheet[exported]?.declares ?? []) {
534
+ if (exported === 'ELEMENT') elements.add(row.id)
535
+ if (row.at !== '') declared.add(slashed(row.at))
536
+ }
537
+ }
538
+
539
+ const found: string[] = []
540
+ for (const row of rows) {
541
+ const file = join(tree.at, row.at)
542
+ if (!existsSync(file)) continue
543
+ const text = readFileSync(file, 'utf8')
544
+ found.push(...handing(row.at, text))
545
+ for (const one of reaching(row.at, text, elements, declared)) {
546
+ found.push(
547
+ `${row.at} imports \`${one}\`, which this app declares nothing by. A composition reaches ` +
548
+ 'declared elements, other declared compositions, and the door — everything else is a thing ' +
549
+ 'nothing wrote down. Declare it, or take it out and write the body through `thread create`.',
550
+ )
551
+ }
552
+ }
553
+ return found
554
+ }
555
+
556
+ function starter(row: Threaded): string {
557
+ const name = nameOf(row.at)
558
+ return `'use client'
559
+
560
+ import { Box } from '${HOME}/elements/box'
561
+ import { composition } from '${HOME}/wrappers'
562
+
563
+ export const ${name} = composition('${name}', () => <Box.card />)
564
+ `
565
+ }
566
+
567
+ export function seeds(rows: readonly Threaded[], held: (at: string) => boolean): readonly { readonly path: string; readonly content: string }[] {
568
+ return rows.filter((row) => !held(row.at)).map((row) => ({ path: row.at, content: starter(row) }))
569
+ }
570
+
571
+ function run(): number {
572
+ const tree = findTree()
573
+ const at = inTree(tree, 'compositions.ts')
574
+ if (!existsSync(at)) {
575
+ stdout.write(`thread: this tree has no ${tree.name}/${basename(at)}, so it declares no composition and there is nothing to seed or check.\n`)
576
+ return 0
577
+ }
578
+ const held = valuesIn(readFileSync(at)) as { COMPOSITION?: { readonly declares?: readonly Threaded[] } }
579
+
580
+ if (held.COMPOSITION === undefined) {
581
+ stdout.write(`thread: ${tree.name}/compositions.ts exports no \`COMPOSITION\`, so nothing declares a composition here.\n`)
582
+ return 1
583
+ }
584
+
585
+ const rows = held.COMPOSITION.declares ?? []
586
+ const checking = argv.includes('--check')
587
+ const made = seeds(rows, (where) => existsSync(join(tree.at, where)))
588
+
589
+ const unseeded = made[0]
590
+ if (checking && unseeded !== undefined) {
591
+ stdout.write(
592
+ `thread: ${unseeded.path} is declared and the file is not there yet. A row names a file, and ` +
593
+ '`--check` never writes one — run `thread compositions` and commit what it seeds.\n',
594
+ )
595
+ return 1
596
+ }
597
+
598
+ if (!checking) {
599
+ for (const one of made) {
600
+ writeFileSync(join(tree.at, one.path), one.content, 'utf8')
601
+ stdout.write(`thread: seeded ${one.path}\n`)
602
+ }
603
+ }
604
+
605
+ const parts = sheetOf(tree, 'elements.ts', 'ELEMENT', 'ELEMENTS')
606
+ const shipped = `node_modules/${packageOf('ui')}/dist/ui/`
607
+ const entries = [
608
+ ...RUNTIME,
609
+ ...parts.flatMap((one) =>
610
+ (one.at ?? '').startsWith(shipped) ? [(one.at ?? '').slice(shipped.length).replace(/\.js$/, '.tsx')] : [],
611
+ ),
612
+ ]
613
+ const kept = new Set<string>()
614
+ for (const one of sheetOf(tree, 'screens.ts', 'SCREEN', 'SCREENS')) {
615
+ if (one.at !== undefined && one.at !== '') kept.add(compiledAt(one.at))
616
+ }
617
+ const found = [
618
+ ...undeclared(tree, rows),
619
+ ...emitting(tree.at, entries, checking, kept),
620
+ ...settled(tree.at, rows, checking, 'thread compositions', kept),
621
+ ]
622
+ const loose = [...found, ...(found.length === 0 ? stale(tree.at, kept, checking) : [])]
623
+ for (const one of loose) stdout.write(`thread: ${one}\n`)
624
+
625
+ stdout.write(
626
+ `thread: ${String(rows.length)} composition(s) declared, ${String(made.length)} seeded, ` +
627
+ `${String(rows.length - made.length)} already written, ` +
628
+ `${loose.length === 0 ? 'none reaching anything undeclared' : `${String(loose.length)} reaching something undeclared`}\n`,
629
+ )
630
+ return loose.length === 0 ? 0 : 1
631
+ }
632
+
633
+ if (invoked(import.meta.url)) exit(run())
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ConsentCategorySchema = z
4
+ .strictObject({
5
+ id: z
6
+ .string()
7
+ .regex(/^[a-z][a-z0-9_]*$/, 'a consent category id is lower_snake_case')
8
+ .describe('The id recorded in `thread.consent.categories`.'),
9
+ labelKey: z.string().min(1).describe('The category’s name, as a message key. Never a sentence.'),
10
+ describeKey: z.string().min(1).describe('What is collected under it, as a message key.'),
11
+
12
+ required: z.boolean().default(false).describe('True where the app cannot function without it.'),
13
+ })
14
+ .describe('One thing an app may ask permission to collect.')
15
+
16
+ export type ConsentCategory = z.infer<typeof ConsentCategorySchema>
17
+
18
+ const TELEMETRY: ConsentCategory = ConsentCategorySchema.parse({
19
+ id: 'telemetry',
20
+ labelKey: 'consent.telemetry.label',
21
+ describeKey: 'consent.telemetry.describe',
22
+ })
23
+
24
+ const REGISTRY = new Map<string, ConsentCategory>([[TELEMETRY.id, TELEMETRY]])
25
+
26
+ export function defineConsentCategory(category: ConsentCategory): void {
27
+ const parsed = ConsentCategorySchema.parse(category)
28
+ if (REGISTRY.has(parsed.id)) throw new Error(`consent category "${parsed.id}" is already registered`)
29
+ REGISTRY.set(parsed.id, parsed)
30
+ }
31
+
32
+ export function consentCategories(): readonly ConsentCategory[] {
33
+ return [...REGISTRY.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
34
+ }
35
+
36
+ export function consentCategory(id: string): ConsentCategory | undefined {
37
+ return REGISTRY.get(id)
38
+ }
39
+
40
+ export const ConsentDecisionSchema = z
41
+ .strictObject({
42
+ decision: z.enum(['accepted', 'declined', 'withdrawn']).describe('Withdrawal is a decision and is recorded like any other.'),
43
+
44
+ accepted: z.array(z.string()).describe('The category ids accepted.'),
45
+ offered: z.array(z.string()).describe('Every category shown at that moment, accepted or not.'),
46
+ })
47
+ .describe('One consent decision, with the choice set as it stood when it was made.')
48
+
49
+ export type ConsentDecision = z.infer<typeof ConsentDecisionSchema>
50
+
51
+ export function consentAllows(decision: ConsentDecision | undefined, categoryId: string): boolean {
52
+ if (consentCategory(categoryId)?.required === true) return true
53
+ if (decision === undefined || decision.decision !== 'accepted') return false
54
+ return decision.accepted.includes(categoryId)
55
+ }