@prestack/thread-ui 0.1.10 → 0.1.12

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 (209) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/dist/ui/attach.js +6 -7
  3. package/dist/ui/compositions.d.ts +0 -8
  4. package/dist/ui/compositions.js +3 -166
  5. package/dist/ui/generators/routes.js +1 -0
  6. package/package.json +2 -2
  7. package/dist/ui/animations/anm_appear.ts +0 -13
  8. package/dist/ui/animations/anm_blur_in.ts +0 -19
  9. package/dist/ui/animations/anm_blur_out.ts +0 -18
  10. package/dist/ui/animations/anm_dismiss.ts +0 -18
  11. package/dist/ui/animations/anm_drop.ts +0 -19
  12. package/dist/ui/animations/anm_fall.ts +0 -18
  13. package/dist/ui/animations/anm_flash.ts +0 -18
  14. package/dist/ui/animations/anm_glow.ts +0 -16
  15. package/dist/ui/animations/anm_lift.ts +0 -16
  16. package/dist/ui/animations/anm_nudge.ts +0 -16
  17. package/dist/ui/animations/anm_pop_in.ts +0 -23
  18. package/dist/ui/animations/anm_pop_out.ts +0 -22
  19. package/dist/ui/animations/anm_press.ts +0 -12
  20. package/dist/ui/animations/anm_pulse.ts +0 -13
  21. package/dist/ui/animations/anm_rise.ts +0 -19
  22. package/dist/ui/animations/anm_shake.ts +0 -13
  23. package/dist/ui/animations/anm_slide_in_left.ts +0 -19
  24. package/dist/ui/animations/anm_slide_in_right.ts +0 -19
  25. package/dist/ui/animations/anm_slide_out_left.ts +0 -18
  26. package/dist/ui/animations/anm_slide_out_right.ts +0 -18
  27. package/dist/ui/animations/anm_spin.ts +0 -12
  28. package/dist/ui/animations/anm_swell.ts +0 -16
  29. package/dist/ui/animations/anm_wobble.ts +0 -24
  30. package/dist/ui/animations/anm_zoom_in.ts +0 -19
  31. package/dist/ui/animations/anm_zoom_out.ts +0 -18
  32. package/dist/ui/animations.ts +0 -308
  33. package/dist/ui/attach.ts +0 -553
  34. package/dist/ui/charts.tsx +0 -69
  35. package/dist/ui/checked.ts +0 -32
  36. package/dist/ui/collector.ts +0 -140
  37. package/dist/ui/compositions.ts +0 -613
  38. package/dist/ui/consent.ts +0 -55
  39. package/dist/ui/content.tsx +0 -27
  40. package/dist/ui/contrast.ts +0 -27
  41. package/dist/ui/controls.tsx +0 -25
  42. package/dist/ui/disclosure.tsx +0 -20
  43. package/dist/ui/dismiss-layer.ts +0 -87
  44. package/dist/ui/door.ts +0 -140
  45. package/dist/ui/drag.ts +0 -316
  46. package/dist/ui/elements/accordion.tsx +0 -47
  47. package/dist/ui/elements/accordion.works.ts +0 -66
  48. package/dist/ui/elements/aspect-ratio.tsx +0 -31
  49. package/dist/ui/elements/aspect-ratio.works.ts +0 -5
  50. package/dist/ui/elements/avatar.tsx +0 -34
  51. package/dist/ui/elements/avatar.works.ts +0 -16
  52. package/dist/ui/elements/badge.tsx +0 -33
  53. package/dist/ui/elements/badge.works.ts +0 -9
  54. package/dist/ui/elements/bars.tsx +0 -50
  55. package/dist/ui/elements/bars.works.ts +0 -29
  56. package/dist/ui/elements/box.tsx +0 -36
  57. package/dist/ui/elements/breadcrumb.tsx +0 -38
  58. package/dist/ui/elements/breadcrumb.works.ts +0 -24
  59. package/dist/ui/elements/button.tsx +0 -46
  60. package/dist/ui/elements/button.works.tsx +0 -52
  61. package/dist/ui/elements/column.tsx +0 -29
  62. package/dist/ui/elements/consent.tsx +0 -71
  63. package/dist/ui/elements/consent.works.tsx +0 -132
  64. package/dist/ui/elements/donut.tsx +0 -45
  65. package/dist/ui/elements/donut.works.ts +0 -42
  66. package/dist/ui/elements/each.tsx +0 -34
  67. package/dist/ui/elements/each.works.tsx +0 -82
  68. package/dist/ui/elements/empty.tsx +0 -33
  69. package/dist/ui/elements/field.tsx +0 -45
  70. package/dist/ui/elements/field.works.ts +0 -65
  71. package/dist/ui/elements/funnel.tsx +0 -50
  72. package/dist/ui/elements/funnel.works.ts +0 -37
  73. package/dist/ui/elements/gauge.tsx +0 -40
  74. package/dist/ui/elements/gauge.works.ts +0 -42
  75. package/dist/ui/elements/glyph.tsx +0 -30
  76. package/dist/ui/elements/glyph.works.tsx +0 -42
  77. package/dist/ui/elements/grid.tsx +0 -29
  78. package/dist/ui/elements/heatmap.tsx +0 -48
  79. package/dist/ui/elements/heatmap.works.ts +0 -24
  80. package/dist/ui/elements/hit-box.tsx +0 -47
  81. package/dist/ui/elements/hit-box.works.tsx +0 -576
  82. package/dist/ui/elements/image.tsx +0 -29
  83. package/dist/ui/elements/line-chart.tsx +0 -45
  84. package/dist/ui/elements/line-chart.works.ts +0 -41
  85. package/dist/ui/elements/link.tsx +0 -38
  86. package/dist/ui/elements/link.works.ts +0 -25
  87. package/dist/ui/elements/media.tsx +0 -57
  88. package/dist/ui/elements/menu.tsx +0 -51
  89. package/dist/ui/elements/menu.works.tsx +0 -154
  90. package/dist/ui/elements/money.tsx +0 -36
  91. package/dist/ui/elements/money.works.ts +0 -9
  92. package/dist/ui/elements/pagination.tsx +0 -39
  93. package/dist/ui/elements/pagination.works.ts +0 -20
  94. package/dist/ui/elements/progress.tsx +0 -34
  95. package/dist/ui/elements/progress.works.ts +0 -8
  96. package/dist/ui/elements/radio-group.tsx +0 -50
  97. package/dist/ui/elements/radio-group.works.ts +0 -33
  98. package/dist/ui/elements/row.tsx +0 -29
  99. package/dist/ui/elements/scatter.tsx +0 -42
  100. package/dist/ui/elements/scatter.works.ts +0 -38
  101. package/dist/ui/elements/select.tsx +0 -43
  102. package/dist/ui/elements/select.works.ts +0 -5
  103. package/dist/ui/elements/separator.tsx +0 -31
  104. package/dist/ui/elements/skeleton.tsx +0 -31
  105. package/dist/ui/elements/slider.tsx +0 -34
  106. package/dist/ui/elements/slider.works.ts +0 -5
  107. package/dist/ui/elements/sparkline.tsx +0 -37
  108. package/dist/ui/elements/sparkline.works.ts +0 -32
  109. package/dist/ui/elements/spinner.tsx +0 -33
  110. package/dist/ui/elements/stack.tsx +0 -29
  111. package/dist/ui/elements/stat.tsx +0 -36
  112. package/dist/ui/elements/stat.works.ts +0 -14
  113. package/dist/ui/elements/surface.tsx +0 -36
  114. package/dist/ui/elements/surface.works.ts +0 -9
  115. package/dist/ui/elements/table.tsx +0 -62
  116. package/dist/ui/elements/table.works.ts +0 -30
  117. package/dist/ui/elements/tabs.tsx +0 -47
  118. package/dist/ui/elements/tabs.works.ts +0 -84
  119. package/dist/ui/elements/text.tsx +0 -33
  120. package/dist/ui/elements/textarea.tsx +0 -38
  121. package/dist/ui/elements/textarea.works.ts +0 -5
  122. package/dist/ui/elements/time.tsx +0 -40
  123. package/dist/ui/elements/time.works.ts +0 -29
  124. package/dist/ui/elements/toggle.tsx +0 -34
  125. package/dist/ui/elements/toggle.works.ts +0 -5
  126. package/dist/ui/elements/tooltip.tsx +0 -40
  127. package/dist/ui/elements/tooltip.works.ts +0 -77
  128. package/dist/ui/elements/value.tsx +0 -38
  129. package/dist/ui/elements/value.works.ts +0 -9
  130. package/dist/ui/elements.ts +0 -127
  131. package/dist/ui/focus-scope.ts +0 -121
  132. package/dist/ui/fonts.ts +0 -355
  133. package/dist/ui/generators/dev.d.ts +0 -1
  134. package/dist/ui/generators/dev.js +0 -62
  135. package/dist/ui/generators/dev.ts +0 -75
  136. package/dist/ui/generators/elements.ts +0 -796
  137. package/dist/ui/generators/i18n.ts +0 -283
  138. package/dist/ui/generators/icons.ts +0 -207
  139. package/dist/ui/generators/react.ts +0 -267
  140. package/dist/ui/generators/routes.ts +0 -847
  141. package/dist/ui/generators/show.ts +0 -261
  142. package/dist/ui/generators/styles.ts +0 -677
  143. package/dist/ui/generators/variants.ts +0 -196
  144. package/dist/ui/i18n/catalogs/en.ts +0 -191
  145. package/dist/ui/i18n/format.ts +0 -221
  146. package/dist/ui/i18n/locales.ts +0 -33
  147. package/dist/ui/i18n/parse.ts +0 -315
  148. package/dist/ui/i18n/translator.ts +0 -31
  149. package/dist/ui/i18n/types.ts +0 -96
  150. package/dist/ui/icons.ts +0 -331
  151. package/dist/ui/islands/map.ts +0 -58
  152. package/dist/ui/localizations.ts +0 -75
  153. package/dist/ui/material.ts +0 -236
  154. package/dist/ui/materials/mat_bordered.ts +0 -13
  155. package/dist/ui/materials/mat_ceramic.ts +0 -13
  156. package/dist/ui/materials/mat_clay.ts +0 -13
  157. package/dist/ui/materials/mat_elevated.ts +0 -24
  158. package/dist/ui/materials/mat_flat.ts +0 -34
  159. package/dist/ui/materials/mat_glass.ts +0 -20
  160. package/dist/ui/materials/mat_lit.ts +0 -13
  161. package/dist/ui/materials/mat_moulded.ts +0 -11
  162. package/dist/ui/materials.ts +0 -197
  163. package/dist/ui/modes.ts +0 -44
  164. package/dist/ui/navigation.tsx +0 -29
  165. package/dist/ui/positioning.ts +0 -90
  166. package/dist/ui/presented.tsx +0 -119
  167. package/dist/ui/provider.tsx +0 -184
  168. package/dist/ui/render-loop.ts +0 -133
  169. package/dist/ui/roving-focus.ts +0 -161
  170. package/dist/ui/screens.ts +0 -141
  171. package/dist/ui/scroll-lock.ts +0 -28
  172. package/dist/ui/series.ts +0 -97
  173. package/dist/ui/signals.ts +0 -386
  174. package/dist/ui/styleguide.ts +0 -231
  175. package/dist/ui/styleguides.ts +0 -374
  176. package/dist/ui/themes.ts +0 -22
  177. package/dist/ui/token.ts +0 -78
  178. package/dist/ui/tokens/shadow.ts +0 -25
  179. package/dist/ui/tokens.ts +0 -234
  180. package/dist/ui/validators.ts +0 -232
  181. package/dist/ui/variant.ts +0 -349
  182. package/dist/ui/variants/var_avatar-round.ts +0 -14
  183. package/dist/ui/variants/var_bubble-me.ts +0 -14
  184. package/dist/ui/variants/var_bubble-them.ts +0 -13
  185. package/dist/ui/variants/var_button-border.ts +0 -76
  186. package/dist/ui/variants/var_button-cta.ts +0 -76
  187. package/dist/ui/variants/var_button-destructive-ghost.ts +0 -72
  188. package/dist/ui/variants/var_button-destructive.ts +0 -76
  189. package/dist/ui/variants/var_button-fill.ts +0 -76
  190. package/dist/ui/variants/var_button-ghost.ts +0 -72
  191. package/dist/ui/variants/var_button-glass.ts +0 -55
  192. package/dist/ui/variants/var_button-normal.ts +0 -76
  193. package/dist/ui/variants/var_button-quiet.ts +0 -74
  194. package/dist/ui/variants/var_card.ts +0 -17
  195. package/dist/ui/variants/var_chip.ts +0 -13
  196. package/dist/ui/variants/var_choice.ts +0 -15
  197. package/dist/ui/variants/var_framed.ts +0 -10
  198. package/dist/ui/variants/var_glyph-plain.ts +0 -11
  199. package/dist/ui/variants/var_input.ts +0 -43
  200. package/dist/ui/variants/var_link-inline.ts +0 -29
  201. package/dist/ui/variants/var_list-row.ts +0 -34
  202. package/dist/ui/variants/var_rule.ts +0 -10
  203. package/dist/ui/variants/var_skeleton-block.ts +0 -12
  204. package/dist/ui/variants/var_spinner-turning.ts +0 -12
  205. package/dist/ui/variants/var_stat-tile.ts +0 -13
  206. package/dist/ui/variants/var_tab-bar.ts +0 -15
  207. package/dist/ui/variants/var_tap-target.ts +0 -25
  208. package/dist/ui/variants.ts +0 -64
  209. package/dist/ui/wrappers.tsx +0 -429
@@ -1,613 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from '@prestack/thread/integrations/disk'
2
- import { basename, dirname, join, relative } from 'node:path'
3
- import { argv, stdout } from 'node:process'
4
- import ts from 'typescript'
5
-
6
- import { SHAPED } from '@prestack/thread/core/init'
7
- import { exit, invoked } from '@prestack/thread/core/invoked'
8
- import { quoted, valuesIn } from '@prestack/thread/core/rows'
9
- import type { Threaded } from '@prestack/thread/core/thread'
10
- import { THING, named as packageOf } from '@prestack/thread/core/scope'
11
- import type { Tree } from '@prestack/thread/core/tree'
12
- import { findTree, inTree } from '@prestack/thread/core/tree'
13
- import { COMPILED } from '@prestack/thread/operations/capabilities'
14
-
15
- import { compiled } from './generators/react.ts'
16
-
17
- export const REACHES = /\buseUi\s*\(/
18
-
19
- const here = 'thread/ui/compositions.ts'
20
-
21
- const said = [
22
- {
23
- on: '2026-08-25',
24
- said: 'Compositions is basically a mix of elements+variants, arranged with arrangement elements, with ways to invoke functions (navigateTo, dispatch).',
25
- },
26
- {
27
- on: '2026-08-25',
28
- 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.',
29
- },
30
- {
31
- on: '2026-08-25',
32
- 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.',
33
- },
34
- ]
35
-
36
- export const declares: readonly Threaded[] = []
37
-
38
- export const COMPOSITIONS: Threaded & { readonly files: string } = {
39
- id: 'composition',
40
- at: here,
41
- files: 'compositions/cmp_*.tsx',
42
- describes:
43
- '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.',
44
- version: 1,
45
- cites: said,
46
- declares,
47
- }
48
-
49
- export const PASCAL = (id: string): string =>
50
- id
51
- .split('-')
52
- .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
53
- .join('')
54
-
55
- export function nameOf(at: string): string {
56
- const file = at.split('\\').join('/').split('/').at(-1) ?? at
57
- return PASCAL(file.replace(/^cmp_/, '').replace(/\.[^.]+$/, ''))
58
- }
59
-
60
- export type Listed = {
61
- readonly id: string
62
- readonly at?: string
63
- readonly describes?: string
64
- readonly wears?: readonly string[]
65
- readonly arranges?: boolean
66
- }
67
-
68
- export function sheetOf(tree: Tree, file: string, ...named: readonly string[]): readonly Listed[] {
69
- const at = inTree(tree, file)
70
- if (!existsSync(at)) return []
71
- const held = valuesIn(readFileSync(at)) as Readonly<
72
- Record<string, { readonly derives?: readonly Listed[]; readonly declares?: readonly Listed[] } | undefined>
73
- >
74
- for (const one of named) {
75
- const found = held[one]?.derives ?? held[one]?.declares
76
- if (found !== undefined) return found
77
- }
78
- return []
79
- }
80
-
81
- const TAG = /<([A-Z][A-Za-z0-9]*)/g
82
-
83
- export function composed(
84
- body: string,
85
- rows: readonly { readonly id: string; readonly at?: string }[],
86
- from: 'screens' | 'compositions',
87
- except?: string,
88
- ): readonly string[] {
89
- const named = new Map<string, string>()
90
- for (const row of rows) {
91
- const at = row.at
92
- if (typeof at !== 'string' || at === '' || at === except) continue
93
- named.set(PASCAL(row.id), at)
94
- }
95
- const found = new Set<string>()
96
- for (const [, tag] of body.matchAll(TAG)) if (tag !== undefined && named.has(tag)) found.add(tag)
97
- return [...found].sort().map((one) => {
98
- const at = named.get(one) ?? ''
99
- const file = (at.split('\\').join('/').split('/').at(-1) ?? at).replace(/\.[^.]+$/, '')
100
- return `import { ${one} } from '${from === 'screens' ? '../compositions' : '.'}/${file}.tsx'`
101
- })
102
- }
103
-
104
- const IMPORTS = /^import\s[\s\S]*?from\s*['"]([^'"]+)['"]/gm
105
-
106
- const FRAMEWORK = 'thread/'
107
-
108
- export const HOME = `${packageOf('ui')}/${here.slice(FRAMEWORK.length).split('/').slice(0, -1).join('/')}`
109
-
110
- export const DOORS: ReadonlySet<string> = new Set(['react', `${HOME}/wrappers`, `${HOME}/drag`])
111
-
112
- const SHIPPED = new RegExp(`^${HOME}/elements/([a-z0-9-]+)$`)
113
-
114
- const slashed = (path: string): string => path.split('\\').join('/')
115
-
116
- export function reaching(
117
- at: string,
118
- text: string,
119
- elements: ReadonlySet<string>,
120
- declared: ReadonlySet<string>,
121
- ): readonly string[] {
122
- const here = slashed(at).split('/').slice(0, -1).join('/')
123
- const found = new Set<string>()
124
- for (const one of text.matchAll(IMPORTS)) {
125
- const from = one[1]
126
- if (from === undefined || DOORS.has(from)) continue
127
- const shipped = SHIPPED.exec(from)?.[1]
128
- if (shipped !== undefined && elements.has(shipped)) continue
129
- if (from.startsWith('.') && declared.has(slashed(join(here, from)))) continue
130
- found.add(from)
131
- }
132
- return [...found].sort()
133
- }
134
-
135
- const CLAUSE = /^import\s+(?!type\s)([\s\S]*?)\s*from\s*['"][^'"]+['"]/gm
136
-
137
- const WORD = /(?:^|[,{]|\s)(?:[A-Za-z_$][\w$]*\s+as\s+)?([A-Za-z_$][\w$]*)/g
138
-
139
- const NOT_A_NAME = new Set(['type', 'as', 'from', 'import'])
140
-
141
- 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'
142
-
143
- 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'
144
-
145
- const REFUSED: Readonly<Record<string, string>> = {
146
- useState: KEPT,
147
- useReducer: KEPT,
148
- useSyncExternalStore: KEPT,
149
- useEffect: RUNS,
150
- useLayoutEffect: RUNS,
151
- element: 'only the compiler writes an element, and a file that calls it turns off the checks that keep this one declared',
152
- }
153
-
154
- export function compiledAt(at: string): string {
155
- return `${COMPILED}/${slashed(at)}`
156
- }
157
-
158
- const ENDINGS = ['.tsx', '.ts'] as const
159
-
160
- function beside(root: string, where: string, said: string): string | undefined {
161
- if (!said.startsWith(`${HOME}/`)) return undefined
162
- const tail = said.slice(HOME.length + 1)
163
- for (const ending of ENDINGS) {
164
- const at = `${COMPILED}/ui/${tail}${ending}`
165
- if (!existsSync(join(root, at))) continue
166
- const out = slashed(relative(dirname(where), at))
167
- return out.startsWith('.') ? out : `./${out}`
168
- }
169
- return undefined
170
- }
171
-
172
- const NAMED_IMPORT = /(['"])(@[^'"]+)\1/g
173
-
174
- export function settled(
175
- root: string,
176
- rows: readonly Threaded[],
177
- checking: boolean,
178
- by: string,
179
- kept: Set<string>,
180
- ): readonly string[] {
181
- const said: string[] = []
182
- for (const row of rows) {
183
- const file = join(root, row.at)
184
- if (!existsSync(file)) continue
185
- const held = compiled(readFileSync(file, 'utf8'), row.at)
186
- if (held.because !== undefined) {
187
- said.push(held.because)
188
- continue
189
- }
190
- const where = compiledAt(row.at)
191
- kept.add(where)
192
- const text = (held.text ?? '').replace(NAMED_IMPORT, (whole: string, quote: string, said: string) => {
193
- const found = beside(root, where, said)
194
- return found === undefined ? whole : `${quote}${found}${quote}`
195
- })
196
- const at = join(root, where)
197
- if (!checking) {
198
- writeFileSync(at, text, 'utf8')
199
- continue
200
- }
201
- if (existsSync(at) && readFileSync(at, 'utf8') === text) continue
202
- said.push(
203
- `${where} is not what the compiler writes from ${row.at}. A compiled file that differs from its ` +
204
- `source is a hand edit wearing a generated name. Run \`${by}\` and commit what it writes.`,
205
- )
206
- }
207
- return said
208
- }
209
-
210
- const ORG = packageOf('ui').split('/')[0] ?? ''
211
-
212
- const CORE = packageOf('core')
213
-
214
- const FROM_CORE = `${COMPILED}/${THING}/`
215
-
216
- interface Reached {
217
- readonly from: string
218
- readonly typed: boolean
219
- }
220
-
221
- export function reachedIn(at: string, text: string): readonly Reached[] {
222
- const file = ts.createSourceFile(at, text, ts.ScriptTarget.Latest, false, at.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
223
- const found: Reached[] = []
224
- for (const one of file.statements) {
225
- if (!ts.isImportDeclaration(one) && !ts.isExportDeclaration(one)) continue
226
- const said = one.moduleSpecifier
227
- if (said === undefined || !ts.isStringLiteral(said)) continue
228
- found.push({ from: said.text, typed: ts.isImportDeclaration(one) ? one.importClause?.isTypeOnly === true : one.isTypeOnly })
229
- }
230
- return found
231
- }
232
-
233
- export const RUNTIME: readonly string[] = ['wrappers.tsx', 'drag.ts', 'provider.tsx', 'islands/map.ts']
234
-
235
- const NODE = /^node:/
236
-
237
- function installs(root: string): ReadonlySet<string> {
238
- try {
239
- const held = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
240
- readonly dependencies?: Readonly<Record<string, string>>
241
- readonly devDependencies?: Readonly<Record<string, string>>
242
- }
243
- return new Set([...Object.keys(held.dependencies ?? {}), ...Object.keys(held.devDependencies ?? {})])
244
- } catch {
245
- return new Set()
246
- }
247
- }
248
-
249
- function packaging(said: string): string {
250
- return said.startsWith('@') ? said.split('/').slice(0, 2).join('/') : (said.split('/')[0] ?? said)
251
- }
252
-
253
- export function emitting(
254
- root: string,
255
- entries: readonly string[],
256
- checking: boolean,
257
- kept: Set<string>,
258
- ): readonly string[] {
259
- const has = installs(root)
260
- const from = join(root, 'node_modules', packageOf('ui'), 'dist', 'ui')
261
- const said: string[] = []
262
- if (!existsSync(from)) {
263
- return [
264
- `this app declares an interface and ${packageOf('ui')} is not installed, so there is nothing to emit it from. ` +
265
- `Install it and run \`thread attach ${packageOf('ui')}\` again.`,
266
- ]
267
- }
268
- const seen = new Set<string>()
269
- const core = join(root, 'node_modules', CORE, 'dist')
270
- const copied = (path: string): string | undefined => {
271
- const file = join(core, `${path}.ts`)
272
- if (!existsSync(file)) return undefined
273
- const text = readFileSync(file, 'utf8')
274
- if (reachedIn(path, text).length > 0) return undefined
275
- const where = `${FROM_CORE}${path}.ts`
276
- if (seen.has(where)) return where
277
- seen.add(where)
278
- kept.add(where)
279
- const to = join(root, where)
280
- if (checking) {
281
- if (!existsSync(to) || readFileSync(to, 'utf8') !== text) {
282
- said.push(
283
- `${where} is not what this app emits from ${CORE}. An emitted file that differs from its source is a hand ` +
284
- 'edit wearing a generated name. Run `thread compositions` and commit what it writes.',
285
- )
286
- }
287
- } else {
288
- writeFileSync(to, text, 'utf8')
289
- }
290
- return where
291
- }
292
- const walk = (at: string): void => {
293
- if (seen.has(at)) return
294
- seen.add(at)
295
- const file = join(from, at)
296
- if (!existsSync(file)) {
297
- said.push(
298
- `${at} is part of the interface this app emits, and the installed ${packageOf('ui')} does not hold it. ` +
299
- 'Upgrade the framework packages and run this again.',
300
- )
301
- return
302
- }
303
- const text = readFileSync(file, 'utf8')
304
- const where = `${COMPILED}/ui/${at}`
305
- let shipped = text
306
- for (const one of reachedIn(at, text)) {
307
- const named = one.from
308
- if (named.startsWith('.')) {
309
- const next = slashed(join(dirname(at), named))
310
- if (next.startsWith('..')) {
311
- said.push(`${at} reaches \`${named}\`, which is outside the interface this app emits.`)
312
- continue
313
- }
314
- walk(next)
315
- continue
316
- }
317
- if (one.typed) continue
318
- if (!named.startsWith(`${ORG}/`)) {
319
- const held = packaging(named)
320
- if (NODE.test(named) || has.has(held)) continue
321
- said.push(
322
- `${at} reaches ${held}, and this app installs nothing by that name. An emitted interface brings ` +
323
- `what it reaches with it, so ${held} has to arrive when ` +
324
- `thread attach ${packageOf('ui')} runs.`,
325
- )
326
- continue
327
- }
328
- const copy = named.startsWith(`${CORE}/`) ? copied(named.slice(CORE.length + 1)) : undefined
329
- if (copy !== undefined) {
330
- const near = slashed(relative(dirname(where), copy))
331
- const pointed = near.startsWith('.') ? near : `./${near}`
332
- shipped = shipped.split(`'${named}'`).join(`'${pointed}'`).split(`"${named}"`).join(`"${pointed}"`)
333
- continue
334
- }
335
- said.push(
336
- `${at} reaches \`${named}\`, so emitting it would leave this app importing the framework while it runs. ` +
337
- 'It reaches what is emitted beside it, or nothing of this framework at all.',
338
- )
339
- }
340
- kept.add(where)
341
- const to = join(root, where)
342
- if (checking) {
343
- if (!existsSync(to) || readFileSync(to, 'utf8') !== shipped) {
344
- said.push(
345
- `${where} is not what this app emits from ${packageOf('ui')}. An emitted file that differs from ` +
346
- 'its source is a hand edit wearing a generated name. Run `thread compositions` and commit what it writes.',
347
- )
348
- }
349
- } else {
350
- writeFileSync(to, shipped, 'utf8')
351
- }
352
- }
353
- for (const one of entries) walk(one)
354
- return said
355
- }
356
-
357
- export function stale(root: string, kept: ReadonlySet<string>, checking: boolean): readonly string[] {
358
- const said: string[] = []
359
- const walk = (at: string): void => {
360
- const where = join(root, at)
361
- if (!existsSync(where)) return
362
- for (const each of readdirSync(where, { withFileTypes: true })) {
363
- const path = `${at}/${each.name}`
364
- if (each.isDirectory()) {
365
- walk(path)
366
- continue
367
- }
368
- if (kept.has(path)) continue
369
- if (checking) {
370
- said.push(
371
- `${path} sits under ${COMPILED} and nothing declares what it was written from any more. ` +
372
- 'Run `thread compositions` and it goes.',
373
- )
374
- continue
375
- }
376
- rmSync(join(root, path), { force: true })
377
- }
378
- }
379
- walk(COMPILED)
380
- return said
381
- }
382
-
383
- const SHEET = 'compiled.ts'
384
-
385
- const EMITS = 'thread compositions'
386
-
387
- export const SCREENED_BY = 'thread screens'
388
-
389
- const SHIPS = `${COMPILED}/ui/`
390
-
391
- const idFor = (at: string): string => `compiled-${at.slice(COMPILED.length + 1).replace(/\.[^./]+$/, '').split('/').join('-')}`
392
-
393
- function meaning(at: string, by: string): string {
394
- if (at.startsWith(FROM_CORE)) {
395
- return (
396
- '`' + at.slice(FROM_CORE.length) + '` from ' + CORE + ', copied into this app beside the interface that imports it. ' +
397
- 'It imports nothing itself, so a page runs it with nothing of the framework installed. `' + by + '` writes it, so an ' +
398
- 'edit made here is refused.'
399
- )
400
- }
401
- if (at.startsWith(SHIPS)) {
402
- return (
403
- '`' + at.slice(SHIPS.length) + '` from ' + packageOf('ui') + ', copied into this app so a page runs it with nothing of ' +
404
- 'the framework installed. `' + by + '` writes it, so an edit made here is refused.'
405
- )
406
- }
407
- return (
408
- '`' + at.slice(COMPILED.length + 1) + '` as React runs it, compiled from the file a person wrote: a variant worn as a ' +
409
- 'tag has become its class, and a named place the class its arrangement declares. `' + by + '` writes it, so an ' +
410
- 'edit made here is refused.'
411
- )
412
- }
413
-
414
- type Sheeted = { readonly text: string } | { readonly because: string }
415
-
416
- function sheeted(tree: Tree, written: readonly string[], screened: ReadonlySet<string>): Sheeted {
417
- const at = relative(tree.at, inTree(tree, SHEET))
418
- const ui = inTree(tree, 'ui.ts')
419
- const held = (existsSync(ui) ? valuesIn(readFileSync(ui)) : {}) as { readonly GENERATORS?: readonly Threaded[] }
420
- const row = (held.GENERATORS ?? []).flatMap((one) => one.derives ?? []).find((one) => one.at === at)
421
- if (row === undefined) {
422
- return {
423
- because:
424
- tree.name + '/ui.ts names no ' + at + ', so every file this command emits would be one nothing declares. ' +
425
- 'Run `thread attach ' + packageOf('ui') + '`: that sheet is written by the package, never by hand.',
426
- }
427
- }
428
- const rows = [...written].sort().map((one) => {
429
- const by = screened.has(one) ? SCREENED_BY : EMITS
430
- return [
431
- ' {',
432
- ` id: ${quoted(idFor(one))},`,
433
- ` at: ${quoted(one)},`,
434
- ` describes: ${quoted(meaning(one, by))},`,
435
- ' version: 1,',
436
- ` by: ${quoted(by)},`,
437
- ` runs: ${quoted(`${by} --check`)},`,
438
- ' },',
439
- ].join('\n')
440
- })
441
- return {
442
- text: [
443
- `import type { Threaded } from '${SHAPED}'`,
444
- '',
445
- 'export const COMPILED: Threaded = {',
446
- ` id: ${quoted(row.id)},`,
447
- ` at: ${quoted(at)},`,
448
- ` describes: ${quoted(row.describes)},`,
449
- ' version: 1,',
450
- ' derives: [',
451
- ...rows,
452
- ' ],',
453
- '}',
454
- '',
455
- ].join('\n'),
456
- }
457
- }
458
-
459
- function declaring(tree: Tree, written: readonly string[], checking: boolean, screened: ReadonlySet<string>): readonly string[] {
460
- const made = sheeted(tree, written, screened)
461
- if ('because' in made) return [made.because]
462
- const at = inTree(tree, SHEET)
463
- if (!checking) {
464
- writeFileSync(at, made.text, 'utf8')
465
- return []
466
- }
467
- if (existsSync(at) && readFileSync(at, 'utf8') === made.text) return []
468
- return [
469
- relative(tree.at, at) + ' is not what this command writes. A sheet that differs from the files it ' +
470
- 'declares is a hand edit wearing a generated name. Run `' + EMITS + '` and commit what it writes.',
471
- ]
472
- }
473
-
474
- export function handing(at: string, text: string): readonly string[] {
475
- const found: string[] = []
476
- for (const one of text.matchAll(CLAUSE)) {
477
- for (const name of (one[1] ?? '').matchAll(WORD)) {
478
- const held = name[1]
479
- if (held === undefined || NOT_A_NAME.has(held)) continue
480
- const why = REFUSED[held]
481
- if (why === undefined) continue
482
- found.push(`${at} imports \`${held}\`, and ${why}.`)
483
- }
484
- }
485
- if (/\bfetch\s*\(/.test(text)) {
486
- found.push(
487
- `${at} calls \`fetch\`, and nothing here reaches the network by hand. A screen names the reads it ` +
488
- 'binds in its own views list, and the framework runs each one.',
489
- )
490
- }
491
- return found
492
- }
493
-
494
- export function undeclared(
495
- tree: Tree,
496
- rows: readonly Threaded[],
497
- ): readonly string[] {
498
- const elements = new Set<string>()
499
- const declared = new Set<string>()
500
-
501
- for (const [file, exported] of [
502
- ['elements.ts', 'ELEMENT'],
503
- ['compositions.ts', 'COMPOSITION'],
504
- ['screens.ts', 'SCREEN'],
505
- ] as const) {
506
- const where = inTree(tree, file)
507
- if (!existsSync(where)) continue
508
- const sheet = valuesIn(readFileSync(where)) as Readonly<
509
- Record<string, { readonly declares?: readonly Threaded[] } | undefined>
510
- >
511
- for (const row of sheet[exported]?.declares ?? []) {
512
- if (exported === 'ELEMENT') elements.add(row.id)
513
- if (row.at !== '') declared.add(slashed(row.at))
514
- }
515
- }
516
-
517
- const found: string[] = []
518
- for (const row of rows) {
519
- const file = join(tree.at, row.at)
520
- if (!existsSync(file)) continue
521
- const text = readFileSync(file, 'utf8')
522
- found.push(...handing(row.at, text))
523
- for (const one of reaching(row.at, text, elements, declared)) {
524
- found.push(
525
- `${row.at} imports \`${one}\`, which this app declares nothing by. A composition reaches ` +
526
- 'declared elements, other declared compositions, and the door — everything else is a thing ' +
527
- 'nothing wrote down. Declare it, or take it out and write the body through `thread create`.',
528
- )
529
- }
530
- }
531
- return found
532
- }
533
-
534
- function starter(row: Threaded): string {
535
- const name = nameOf(row.at)
536
- return `'use client'
537
-
538
- import { Box } from '${HOME}/elements/box'
539
- import { composition } from '${HOME}/wrappers'
540
-
541
- export const ${name} = composition('${name}', () => <Box.card />)
542
- `
543
- }
544
-
545
- export function seeds(rows: readonly Threaded[], held: (at: string) => boolean): readonly { readonly path: string; readonly content: string }[] {
546
- return rows.filter((row) => !held(row.at)).map((row) => ({ path: row.at, content: starter(row) }))
547
- }
548
-
549
- function run(): number {
550
- const tree = findTree()
551
- const at = inTree(tree, 'compositions.ts')
552
- if (!existsSync(at)) {
553
- 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`)
554
- return 0
555
- }
556
- const held = valuesIn(readFileSync(at)) as { COMPOSITION?: { readonly declares?: readonly Threaded[] } }
557
-
558
- if (held.COMPOSITION === undefined) {
559
- stdout.write(`thread: ${tree.name}/compositions.ts exports no \`COMPOSITION\`, so nothing declares a composition here.\n`)
560
- return 1
561
- }
562
-
563
- const rows = held.COMPOSITION.declares ?? []
564
- const checking = argv.includes('--check')
565
- const made = seeds(rows, (where) => existsSync(join(tree.at, where)))
566
-
567
- const unseeded = made[0]
568
- if (checking && unseeded !== undefined) {
569
- stdout.write(
570
- `thread: ${unseeded.path} is declared and the file is not there yet. A row names a file, and ` +
571
- '`--check` never writes one — run `thread compositions` and commit what it seeds.\n',
572
- )
573
- return 1
574
- }
575
-
576
- if (!checking) {
577
- for (const one of made) {
578
- writeFileSync(join(tree.at, one.path), one.content, 'utf8')
579
- stdout.write(`thread: seeded ${one.path}\n`)
580
- }
581
- }
582
-
583
- const parts = sheetOf(tree, 'elements.ts', 'ELEMENT', 'ELEMENTS')
584
- const shipped = `node_modules/${packageOf('ui')}/dist/ui/`
585
- const entries = [
586
- ...RUNTIME,
587
- ...parts.flatMap((one) =>
588
- (one.at ?? '').startsWith(shipped) ? [(one.at ?? '').slice(shipped.length).replace(/\.js$/, '.tsx')] : [],
589
- ),
590
- ]
591
- const screened = new Set<string>()
592
- for (const one of sheetOf(tree, 'screens.ts', 'SCREEN', 'SCREENS')) {
593
- if (one.at !== undefined && one.at !== '') screened.add(compiledAt(one.at))
594
- }
595
- const kept = new Set<string>(screened)
596
- const found = [
597
- ...undeclared(tree, rows),
598
- ...emitting(tree.at, entries, checking, kept),
599
- ...settled(tree.at, rows, checking, EMITS, kept),
600
- ]
601
- found.push(...declaring(tree, [...kept], checking, screened))
602
- const loose = [...found, ...(found.length === 0 ? stale(tree.at, kept, checking) : [])]
603
- for (const one of loose) stdout.write(`thread: ${one}\n`)
604
-
605
- stdout.write(
606
- `thread: ${String(rows.length)} composition(s) declared, ${String(made.length)} seeded, ` +
607
- `${String(rows.length - made.length)} already written, ` +
608
- `${loose.length === 0 ? 'none reaching anything undeclared' : `${String(loose.length)} reaching something undeclared`}\n`,
609
- )
610
- return loose.length === 0 ? 0 : 1
611
- }
612
-
613
- if (invoked(import.meta.url)) exit(run())
@@ -1,55 +0,0 @@
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
- }
@@ -1,27 +0,0 @@
1
- 'use client'
2
-
3
- import type { ReactNode } from 'react'
4
- import { formatValue, runValidator } from './validators.ts'
5
-
6
- import { useSurface } from './wrappers.tsx'
7
-
8
- export type MessageKey = string
9
-
10
- export interface ValueProps {
11
- readonly validator: string
12
- readonly value: unknown
13
-
14
- readonly context?: Readonly<Record<string, unknown>>
15
- }
16
-
17
- export function renderValue(
18
- { validator, value, context }: ValueProps,
19
- surface: ReturnType<typeof useSurface>,
20
- ): ReactNode {
21
- const result = runValidator(validator, value, context ?? {})
22
-
23
- if (!result.ok) return surface.text(result.errorKey)
24
-
25
- return formatValue(validator, result.value, context ?? {})
26
- }
27
-