@prestack/thread-ui 0.0.1 → 0.0.2

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 (269) hide show
  1. package/dist/ui/animations/anm_appear.ts +13 -0
  2. package/dist/ui/animations/anm_blur_in.ts +19 -0
  3. package/dist/ui/animations/anm_blur_out.ts +18 -0
  4. package/dist/ui/animations/anm_dismiss.ts +18 -0
  5. package/dist/ui/animations/anm_drop.ts +19 -0
  6. package/dist/ui/animations/anm_fall.ts +18 -0
  7. package/dist/ui/animations/anm_flash.ts +18 -0
  8. package/dist/ui/animations/anm_glow.ts +16 -0
  9. package/dist/ui/animations/anm_lift.ts +16 -0
  10. package/dist/ui/animations/anm_nudge.ts +16 -0
  11. package/dist/ui/animations/anm_pop_in.ts +23 -0
  12. package/dist/ui/animations/anm_pop_out.ts +22 -0
  13. package/dist/ui/animations/anm_press.ts +12 -0
  14. package/dist/ui/animations/anm_pulse.ts +13 -0
  15. package/dist/ui/animations/anm_rise.ts +19 -0
  16. package/dist/ui/animations/anm_shake.ts +13 -0
  17. package/dist/ui/animations/anm_slide_in_left.ts +19 -0
  18. package/dist/ui/animations/anm_slide_in_right.ts +19 -0
  19. package/dist/ui/animations/anm_slide_out_left.ts +18 -0
  20. package/dist/ui/animations/anm_slide_out_right.ts +18 -0
  21. package/dist/ui/animations/anm_spin.ts +12 -0
  22. package/dist/ui/animations/anm_swell.ts +16 -0
  23. package/dist/ui/animations/anm_wobble.ts +24 -0
  24. package/dist/ui/animations/anm_zoom_in.ts +19 -0
  25. package/dist/ui/animations/anm_zoom_out.ts +18 -0
  26. package/dist/ui/animations.ts +317 -0
  27. package/dist/ui/attach.js +13 -4
  28. package/dist/ui/attach.ts +613 -0
  29. package/dist/ui/charts.tsx +74 -0
  30. package/dist/ui/checked.d.ts +2 -2
  31. package/dist/ui/checked.ts +32 -0
  32. package/dist/ui/collector.ts +140 -0
  33. package/dist/ui/compositions.d.ts +10 -1
  34. package/dist/ui/compositions.js +262 -23
  35. package/dist/ui/compositions.ts +640 -0
  36. package/dist/ui/consent.ts +55 -0
  37. package/dist/ui/content.tsx +39 -0
  38. package/dist/ui/contrast.ts +27 -0
  39. package/dist/ui/controls.tsx +30 -0
  40. package/dist/ui/disclosure.tsx +22 -0
  41. package/dist/ui/dismiss-layer.ts +87 -0
  42. package/dist/ui/door.ts +140 -0
  43. package/dist/ui/drag.ts +316 -0
  44. package/dist/ui/elements/accordion.js +2 -2
  45. package/dist/ui/elements/accordion.tsx +47 -0
  46. package/dist/ui/elements/accordion.works.ts +66 -0
  47. package/dist/ui/elements/aspect-ratio.js +1 -1
  48. package/dist/ui/elements/aspect-ratio.tsx +30 -0
  49. package/dist/ui/elements/aspect-ratio.works.ts +5 -0
  50. package/dist/ui/elements/avatar.js +1 -1
  51. package/dist/ui/elements/avatar.tsx +33 -0
  52. package/dist/ui/elements/avatar.works.ts +16 -0
  53. package/dist/ui/elements/badge.js +1 -1
  54. package/dist/ui/elements/badge.tsx +32 -0
  55. package/dist/ui/elements/badge.works.ts +9 -0
  56. package/dist/ui/elements/bars.js +2 -2
  57. package/dist/ui/elements/bars.tsx +49 -0
  58. package/dist/ui/elements/bars.works.ts +29 -0
  59. package/dist/ui/elements/box.js +3 -2
  60. package/dist/ui/elements/box.tsx +35 -0
  61. package/dist/ui/elements/breadcrumb.js +2 -2
  62. package/dist/ui/elements/breadcrumb.tsx +37 -0
  63. package/dist/ui/elements/breadcrumb.works.ts +24 -0
  64. package/dist/ui/elements/button.js +1 -1
  65. package/dist/ui/elements/button.tsx +45 -0
  66. package/dist/ui/elements/button.works.tsx +52 -0
  67. package/dist/ui/elements/column.js +1 -1
  68. package/dist/ui/elements/column.tsx +28 -0
  69. package/dist/ui/elements/consent.js +2 -2
  70. package/dist/ui/elements/consent.tsx +70 -0
  71. package/dist/ui/elements/consent.works.tsx +132 -0
  72. package/dist/ui/elements/donut.js +2 -2
  73. package/dist/ui/elements/donut.tsx +44 -0
  74. package/dist/ui/elements/donut.works.ts +42 -0
  75. package/dist/ui/elements/each.js +1 -1
  76. package/dist/ui/elements/each.tsx +33 -0
  77. package/dist/ui/elements/each.works.tsx +82 -0
  78. package/dist/ui/elements/empty.js +1 -1
  79. package/dist/ui/elements/empty.tsx +32 -0
  80. package/dist/ui/elements/field.js +1 -1
  81. package/dist/ui/elements/field.tsx +44 -0
  82. package/dist/ui/elements/field.works.ts +65 -0
  83. package/dist/ui/elements/funnel.js +2 -2
  84. package/dist/ui/elements/funnel.tsx +49 -0
  85. package/dist/ui/elements/funnel.works.ts +37 -0
  86. package/dist/ui/elements/gauge.js +1 -1
  87. package/dist/ui/elements/gauge.tsx +39 -0
  88. package/dist/ui/elements/gauge.works.ts +42 -0
  89. package/dist/ui/elements/glyph.js +1 -1
  90. package/dist/ui/elements/glyph.tsx +29 -0
  91. package/dist/ui/elements/glyph.works.tsx +42 -0
  92. package/dist/ui/elements/grid.js +1 -1
  93. package/dist/ui/elements/grid.tsx +28 -0
  94. package/dist/ui/elements/heatmap.js +2 -2
  95. package/dist/ui/elements/heatmap.tsx +47 -0
  96. package/dist/ui/elements/heatmap.works.ts +24 -0
  97. package/dist/ui/elements/hit-box.js +1 -1
  98. package/dist/ui/elements/hit-box.tsx +46 -0
  99. package/dist/ui/elements/hit-box.works.tsx +576 -0
  100. package/dist/ui/elements/image.js +1 -1
  101. package/dist/ui/elements/image.tsx +28 -0
  102. package/dist/ui/elements/line-chart.js +2 -2
  103. package/dist/ui/elements/line-chart.tsx +44 -0
  104. package/dist/ui/elements/line-chart.works.ts +41 -0
  105. package/dist/ui/elements/link.js +1 -1
  106. package/dist/ui/elements/link.tsx +37 -0
  107. package/dist/ui/elements/link.works.ts +25 -0
  108. package/dist/ui/elements/media.js +1 -1
  109. package/dist/ui/elements/media.tsx +56 -0
  110. package/dist/ui/elements/menu.js +2 -2
  111. package/dist/ui/elements/menu.tsx +50 -0
  112. package/dist/ui/elements/menu.works.tsx +153 -0
  113. package/dist/ui/elements/money.js +1 -1
  114. package/dist/ui/elements/money.tsx +28 -0
  115. package/dist/ui/elements/money.works.ts +9 -0
  116. package/dist/ui/elements/pagination.js +2 -2
  117. package/dist/ui/elements/pagination.tsx +38 -0
  118. package/dist/ui/elements/pagination.works.ts +20 -0
  119. package/dist/ui/elements/progress.js +1 -1
  120. package/dist/ui/elements/progress.tsx +33 -0
  121. package/dist/ui/elements/progress.works.ts +8 -0
  122. package/dist/ui/elements/radio-group.js +2 -2
  123. package/dist/ui/elements/radio-group.tsx +49 -0
  124. package/dist/ui/elements/radio-group.works.ts +33 -0
  125. package/dist/ui/elements/row.js +1 -1
  126. package/dist/ui/elements/row.tsx +28 -0
  127. package/dist/ui/elements/scatter.js +1 -1
  128. package/dist/ui/elements/scatter.tsx +41 -0
  129. package/dist/ui/elements/scatter.works.ts +38 -0
  130. package/dist/ui/elements/select.js +2 -2
  131. package/dist/ui/elements/select.tsx +42 -0
  132. package/dist/ui/elements/select.works.ts +5 -0
  133. package/dist/ui/elements/separator.js +1 -1
  134. package/dist/ui/elements/separator.tsx +30 -0
  135. package/dist/ui/elements/skeleton.js +1 -1
  136. package/dist/ui/elements/skeleton.tsx +30 -0
  137. package/dist/ui/elements/slider.js +1 -1
  138. package/dist/ui/elements/slider.tsx +33 -0
  139. package/dist/ui/elements/slider.works.ts +5 -0
  140. package/dist/ui/elements/sparkline.js +1 -1
  141. package/dist/ui/elements/sparkline.tsx +36 -0
  142. package/dist/ui/elements/sparkline.works.ts +32 -0
  143. package/dist/ui/elements/spinner.js +1 -1
  144. package/dist/ui/elements/spinner.tsx +32 -0
  145. package/dist/ui/elements/stack.js +1 -1
  146. package/dist/ui/elements/stack.tsx +28 -0
  147. package/dist/ui/elements/stat.js +1 -1
  148. package/dist/ui/elements/stat.tsx +35 -0
  149. package/dist/ui/elements/stat.works.ts +14 -0
  150. package/dist/ui/elements/surface.js +1 -1
  151. package/dist/ui/elements/surface.tsx +35 -0
  152. package/dist/ui/elements/surface.works.ts +9 -0
  153. package/dist/ui/elements/table.js +2 -2
  154. package/dist/ui/elements/table.tsx +61 -0
  155. package/dist/ui/elements/table.works.ts +30 -0
  156. package/dist/ui/elements/tabs.js +2 -2
  157. package/dist/ui/elements/tabs.tsx +47 -0
  158. package/dist/ui/elements/tabs.works.ts +84 -0
  159. package/dist/ui/elements/text.js +1 -1
  160. package/dist/ui/elements/text.tsx +32 -0
  161. package/dist/ui/elements/textarea.js +1 -1
  162. package/dist/ui/elements/textarea.tsx +37 -0
  163. package/dist/ui/elements/textarea.works.ts +5 -0
  164. package/dist/ui/elements/time.js +1 -1
  165. package/dist/ui/elements/time.tsx +34 -0
  166. package/dist/ui/elements/time.works.ts +25 -0
  167. package/dist/ui/elements/toggle.js +1 -1
  168. package/dist/ui/elements/toggle.tsx +33 -0
  169. package/dist/ui/elements/toggle.works.ts +5 -0
  170. package/dist/ui/elements/tooltip.js +1 -1
  171. package/dist/ui/elements/tooltip.tsx +39 -0
  172. package/dist/ui/elements/tooltip.works.ts +77 -0
  173. package/dist/ui/elements/value.js +1 -1
  174. package/dist/ui/elements/value.tsx +30 -0
  175. package/dist/ui/elements/value.works.ts +9 -0
  176. package/dist/ui/elements.ts +127 -0
  177. package/dist/ui/focus-scope.ts +121 -0
  178. package/dist/ui/fonts.ts +355 -0
  179. package/dist/ui/generators/dev.ts +79 -0
  180. package/dist/ui/generators/elements.d.ts +1 -11
  181. package/dist/ui/generators/elements.js +43 -13
  182. package/dist/ui/generators/elements.ts +794 -0
  183. package/dist/ui/generators/i18n.ts +301 -0
  184. package/dist/ui/generators/icons.ts +214 -0
  185. package/dist/ui/generators/react.js +10 -3
  186. package/dist/ui/generators/react.ts +268 -0
  187. package/dist/ui/generators/routes.d.ts +1 -0
  188. package/dist/ui/generators/routes.js +89 -69
  189. package/dist/ui/generators/routes.ts +739 -0
  190. package/dist/ui/generators/show.ts +259 -0
  191. package/dist/ui/generators/styles.js +1 -1
  192. package/dist/ui/generators/styles.ts +670 -0
  193. package/dist/ui/generators/variants.ts +205 -0
  194. package/dist/ui/i18n/catalogs/en.ts +191 -0
  195. package/dist/ui/i18n/format.ts +221 -0
  196. package/dist/ui/i18n/locales.ts +33 -0
  197. package/dist/ui/i18n/parse.ts +315 -0
  198. package/dist/ui/i18n/translator.ts +31 -0
  199. package/dist/ui/i18n/types.ts +96 -0
  200. package/dist/ui/icons.ts +331 -0
  201. package/dist/ui/islands/map.ts +58 -0
  202. package/dist/ui/localizations.ts +75 -0
  203. package/dist/ui/makers/message.ts +292 -0
  204. package/dist/ui/material.ts +236 -0
  205. package/dist/ui/materials/mat_bordered.ts +13 -0
  206. package/dist/ui/materials/mat_ceramic.ts +13 -0
  207. package/dist/ui/materials/mat_clay.ts +13 -0
  208. package/dist/ui/materials/mat_elevated.ts +24 -0
  209. package/dist/ui/materials/mat_flat.ts +34 -0
  210. package/dist/ui/materials/mat_glass.ts +20 -0
  211. package/dist/ui/materials/mat_lit.ts +13 -0
  212. package/dist/ui/materials/mat_moulded.ts +11 -0
  213. package/dist/ui/materials.ts +206 -0
  214. package/dist/ui/modes.ts +44 -0
  215. package/dist/ui/navigation.tsx +32 -0
  216. package/dist/ui/positioning.ts +90 -0
  217. package/dist/ui/presented.tsx +119 -0
  218. package/dist/ui/provider.d.ts +2 -0
  219. package/dist/ui/provider.js +7 -1
  220. package/dist/ui/provider.tsx +182 -0
  221. package/dist/ui/render-loop.ts +133 -0
  222. package/dist/ui/roving-focus.ts +161 -0
  223. package/dist/ui/screens.js +17 -8
  224. package/dist/ui/screens.ts +142 -0
  225. package/dist/ui/scroll-lock.ts +28 -0
  226. package/dist/ui/series.ts +97 -0
  227. package/dist/ui/signals.ts +386 -0
  228. package/dist/ui/styleguide.ts +231 -0
  229. package/dist/ui/styleguides.ts +374 -0
  230. package/dist/ui/themes.ts +22 -0
  231. package/dist/ui/token.js +1 -1
  232. package/dist/ui/token.ts +78 -0
  233. package/dist/ui/tokens/shadow.ts +25 -0
  234. package/dist/ui/tokens.ts +235 -0
  235. package/dist/ui/validators.ts +232 -0
  236. package/dist/ui/variant.d.ts +1 -0
  237. package/dist/ui/variant.js +3 -0
  238. package/dist/ui/variant.ts +352 -0
  239. package/dist/ui/variants/var_avatar-round.ts +14 -0
  240. package/dist/ui/variants/var_bubble-me.ts +14 -0
  241. package/dist/ui/variants/var_bubble-them.ts +13 -0
  242. package/dist/ui/variants/var_button-border.ts +76 -0
  243. package/dist/ui/variants/var_button-cta.ts +76 -0
  244. package/dist/ui/variants/var_button-destructive-ghost.ts +72 -0
  245. package/dist/ui/variants/var_button-destructive.ts +76 -0
  246. package/dist/ui/variants/var_button-fill.ts +76 -0
  247. package/dist/ui/variants/var_button-ghost.ts +72 -0
  248. package/dist/ui/variants/var_button-glass.ts +55 -0
  249. package/dist/ui/variants/var_button-normal.ts +76 -0
  250. package/dist/ui/variants/var_button-quiet.ts +74 -0
  251. package/dist/ui/variants/var_card.ts +17 -0
  252. package/dist/ui/variants/var_chip.ts +13 -0
  253. package/dist/ui/variants/var_choice.ts +15 -0
  254. package/dist/ui/variants/var_framed.ts +10 -0
  255. package/dist/ui/variants/var_glyph-plain.ts +11 -0
  256. package/dist/ui/variants/var_input.ts +43 -0
  257. package/dist/ui/variants/var_link-inline.ts +29 -0
  258. package/dist/ui/variants/var_list-row.ts +34 -0
  259. package/dist/ui/variants/var_rule.ts +10 -0
  260. package/dist/ui/variants/var_skeleton-block.ts +12 -0
  261. package/dist/ui/variants/var_spinner-turning.ts +12 -0
  262. package/dist/ui/variants/var_stat-tile.ts +13 -0
  263. package/dist/ui/variants/var_tab-bar.ts +15 -0
  264. package/dist/ui/variants/var_tap-target.ts +25 -0
  265. package/dist/ui/variants.ts +64 -0
  266. package/dist/ui/wrappers.d.ts +4 -1
  267. package/dist/ui/wrappers.js +60 -20
  268. package/dist/ui/wrappers.tsx +426 -0
  269. package/package.json +8 -2
@@ -0,0 +1,206 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import { argv, stdout } from 'node:process'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { exit, invoked } from '@prestack/thread/core/invoked'
7
+ import type { Threaded } from '@prestack/thread/core/thread'
8
+ import { findTree, inTree, strayFiles } from '@prestack/thread/core/tree'
9
+
10
+ export const MATERIALS = {
11
+ id: 'material',
12
+ at: 'thread/ui/materials.ts',
13
+ files: 'ui/mat_*.ts',
14
+ describes:
15
+ 'A combination of style guide values that gives one specific appearance, worn by any variant that names it: glass, clay, a glaze. It composes and never computes, because every value it sets is a name the style guide holds and never a raw one, which is what lets one material be worn by many variants. Each row names a file prefixed `mat_`, and that file is the material; a file under it that no row names is not a material but a file somebody wrote. An app adds its own to this same list, and a row that goes missing breaks the build, because what the framework offers is not an app’s to withdraw. These are materials and not aesthetics: Neumorphism, Claymorphism and Cyberpunk are templates, and a template is what picks a material.',
16
+ version: 1,
17
+ cites: [
18
+ {
19
+ on: '2026-08-25',
20
+ said: 'Style guides they can be used for simple pickings that need no thinking. We can pick colours and basic effects for using the app. Materials however are for more complex and detailed work. Materials.ts is a declaration sheet, SSOT, and each row is a file, mat_',
21
+ },
22
+ ],
23
+ declares: [
24
+ { id: 'glass', at: 'thread/ui/materials/mat_glass.ts', describes: 'A pane you can see through, with what is behind it softened and desaturation corrected for.' },
25
+ { id: 'flat', at: 'thread/ui/materials/mat_flat.ts', describes: 'Paper. A plain filled panel with a hairline and almost no lift — the same tokens as glass, asked more quietly.' },
26
+ { id: 'elevated', at: 'thread/ui/materials/mat_elevated.ts', describes: 'Something lifted off the page, reading by its shadow rather than by any outline.' },
27
+ { id: 'bordered', at: 'thread/ui/materials/mat_bordered.ts', describes: 'Ink on paper — a hard outline and a shadow with no blur, offset like a stamp.' },
28
+ { id: 'moulded', at: 'thread/ui/materials/mat_moulded.ts', describes: 'Pressed out of the page rather than laid on it. Its fill is the page itself, and its relief is one shadow cast twice in opposite directions.' },
29
+ { id: 'clay', at: 'thread/ui/materials/mat_clay.ts', describes: 'Something soft and inflated, lit from above — an outer shadow it sits in and an inset highlight along its top edge.' },
30
+ { id: 'lit', at: 'thread/ui/materials/mat_lit.ts', describes: 'Something that emits rather than reflects. Its glow is a shadow cast from nowhere in particular, and it names no colour of its own.' },
31
+ { id: 'ceramic', at: 'thread/ui/materials/mat_ceramic.ts', describes: 'A glazed surface with the light falling down its face. The one material that asks for a gradient rather than a fill.' },
32
+ ],
33
+ } as const satisfies Threaded & { readonly files: string }
34
+
35
+ export function nameOf(id: string): string {
36
+ return id.replace(/-/g, '_').toUpperCase()
37
+ }
38
+
39
+ function starter(row: Threaded): string {
40
+ const name = nameOf(row.id)
41
+ return `import { material } from './material.ts'
42
+
43
+ export const ${name} = material({
44
+ id: '${row.id}',
45
+ sets: [],
46
+ } as const)
47
+ `
48
+ }
49
+
50
+ export function seeds(rows: readonly Threaded[], held: (at: string) => boolean): readonly { readonly path: string; readonly content: string }[] {
51
+ return rows.filter((row) => !held(row.at)).map((row) => ({ path: row.at, content: starter(row) }))
52
+ }
53
+
54
+ export async function mismatched(
55
+ rows: readonly Threaded[],
56
+ loader: (row: Threaded) => Promise<{ readonly id?: string } | undefined>,
57
+ ): Promise<readonly string[]> {
58
+ const found: string[] = []
59
+ for (const row of rows) {
60
+ const material = await loader(row)
61
+ if (material === undefined) {
62
+ found.push(
63
+ `\`${row.id}\` is declared at \`${row.at}\`, and that file exports no \`${nameOf(row.id)}\`. ` +
64
+ 'A row names a file, and the file is the thing.',
65
+ )
66
+ continue
67
+ }
68
+ if (material.id !== row.id) {
69
+ found.push(
70
+ `\`${row.at}\` declares itself \`${String(material.id)}\`, and the row that names it calls it \`${row.id}\`. ` +
71
+ "A material file and the row that names it have to be the same material — make the file's `id` match the row, or fix the row.",
72
+ )
73
+ }
74
+ }
75
+ return found
76
+ }
77
+
78
+ function extensionOf(path: string): string {
79
+ const name = basename(path)
80
+ const at = name.lastIndexOf('.')
81
+ return at === -1 ? '' : name.slice(at)
82
+ }
83
+
84
+ const MAT_PREFIX = /^mat_/
85
+
86
+ export function orphaned(rows: readonly Threaded[], strays: readonly string[]): readonly string[] {
87
+ const named = new Set(rows.map((row) => row.at))
88
+ return strays
89
+ .filter((one) => !named.has(one))
90
+ .map(
91
+ (one) =>
92
+ `\`${one}\` is under materials and no row names it — not a material, a file somebody wrote. ` +
93
+ 'Add a row for it, or delete the file.',
94
+ )
95
+ }
96
+
97
+ const GUARDED = /=\s*material\s*\(/
98
+
99
+ const TYPED = /import type \{ Material \} from '([^']*)'/
100
+
101
+ const OPENS = /export const ([A-Z0-9_]+) = \{/
102
+
103
+ const CLOSES = /\} as const satisfies Material/
104
+
105
+ export function unguarded(rows: readonly Threaded[], read: (at: string) => string): readonly string[] {
106
+ return rows
107
+ .filter((row) => !GUARDED.test(read(row.at)))
108
+ .map(
109
+ (row) =>
110
+ `\`${row.at}\` declares \`${row.id}\` with \`satisfies Material\`, which accepts a value no ` +
111
+ 'row holds — `satisfies` checks against `string`, so `8px` and `var(--x)` compile. Run ' +
112
+ '`thread materials` and it rewrites the two lines that wrap the declaration, leaving what is ' +
113
+ 'between them alone.',
114
+ )
115
+ }
116
+
117
+ export function guarding(source: string): string | undefined {
118
+ if (GUARDED.test(source)) return undefined
119
+ const typed = TYPED.exec(source)
120
+ const opens = OPENS.exec(source)
121
+ if (typed === null || opens === null || !CLOSES.test(source)) return undefined
122
+ return source
123
+ .replace(typed[0], `import { material } from '${typed[1] ?? ''}'`)
124
+ .replace(opens[0], `export const ${opens[1] ?? ''} = material({`)
125
+ .replace(CLOSES, '} as const)')
126
+ }
127
+
128
+ async function run(): Promise<number> {
129
+ const tree = findTree()
130
+ const at = inTree(tree, 'materials.ts')
131
+ if (!existsSync(at)) {
132
+ stdout.write(`thread: this tree has no ${tree.name}/${basename(at)}, so it declares no material and there is nothing to seed or check.\n`)
133
+ return 0
134
+ }
135
+ const held = (await import(pathToFileURL(at).href)) as { MATERIAL?: { readonly declares?: readonly Threaded[] } }
136
+
137
+ if (held.MATERIAL === undefined) {
138
+ stdout.write(`thread: ${tree.name}/materials.ts exports no \`MATERIAL\`, so nothing declares a material here.\n`)
139
+ return 1
140
+ }
141
+
142
+ const rows = held.MATERIAL.declares ?? []
143
+ const checking = argv.includes('--check')
144
+ const exists = (where: string): boolean => existsSync(join(tree.at, where))
145
+ const made = seeds(rows, exists)
146
+
147
+ if (checking) {
148
+ const unseeded = rows.find((row) => !exists(row.at))
149
+ if (unseeded !== undefined) {
150
+ stdout.write(
151
+ `thread: \`${unseeded.id}\` is declared at \`${unseeded.at}\`, and the file is not there yet. ` +
152
+ 'A row names a file, and `--check` never writes one — run `thread materials` and commit what it seeds.\n',
153
+ )
154
+ return 1
155
+ }
156
+ } else {
157
+ for (const one of made) {
158
+ mkdirSync(dirname(join(tree.at, one.path)), { recursive: true })
159
+ writeFileSync(join(tree.at, one.path), one.content, 'utf8')
160
+ stdout.write(`thread: seeded ${one.path}\n`)
161
+ }
162
+ }
163
+
164
+ const wrong = (
165
+ await mismatched(rows, async (row) => {
166
+ const module = (await import(pathToFileURL(join(tree.at, row.at)).href)) as Record<string, unknown>
167
+ return module[nameOf(row.id)] as { readonly id?: string } | undefined
168
+ })
169
+ )[0]
170
+ if (wrong !== undefined) {
171
+ stdout.write(`thread: ${wrong}\n`)
172
+ return 1
173
+ }
174
+
175
+ const read = (at: string): string => readFileSync(join(tree.at, at), 'utf8')
176
+ if (checking) {
177
+ const loose = unguarded(rows, read)[0]
178
+ if (loose !== undefined) {
179
+ stdout.write(`thread: ${loose}\n`)
180
+ return 1
181
+ }
182
+ } else {
183
+ for (const row of rows) {
184
+ const written = guarding(read(row.at))
185
+ if (written === undefined) continue
186
+ writeFileSync(join(tree.at, row.at), written, 'utf8')
187
+ stdout.write(`thread: ${row.at} now declares through \`material(...)\`\n`)
188
+ }
189
+ }
190
+
191
+ const stray = orphaned(rows, strayFiles(tree.at, 'mat_', '.' + 'ts'))[0]
192
+ if (stray !== undefined) {
193
+ stdout.write(`thread: ${stray}\n`)
194
+ return 1
195
+ }
196
+
197
+ stdout.write(
198
+ checking
199
+ ? `thread: ${String(rows.length)} material(s) declared, all seeded\n`
200
+ : `thread: ${String(rows.length)} material(s) declared, ${String(made.length)} seeded, ` +
201
+ `${String(rows.length - made.length)} already written\n`,
202
+ )
203
+ return 0
204
+ }
205
+
206
+ if (invoked(import.meta.url)) exit(await run())
@@ -0,0 +1,44 @@
1
+ import type { Threaded } from '@prestack/thread/core/thread'
2
+
3
+ const here = 'thread/ui/modes.ts'
4
+
5
+ const said = [
6
+ {
7
+ on: '2026-08-25',
8
+ said: 'themes can have dark and light modes too. So they are not at all the same.',
9
+ },
10
+ ]
11
+
12
+ const one = <Id extends string>(
13
+ id: Id,
14
+ is: string,
15
+ describes: string,
16
+ ): Omit<Threaded, 'at'> & { readonly id: Id; readonly is: string } => ({
17
+ id,
18
+ is,
19
+ describes,
20
+ version: 1,
21
+ })
22
+
23
+ export const holds = [
24
+ one(
25
+ 'dark',
26
+ '(prefers-color-scheme: dark)',
27
+ 'The reader has asked their device for a dark interface. Nothing about the app chooses this and nothing about the app can override it.',
28
+ ),
29
+ one(
30
+ 'light',
31
+ '(prefers-color-scheme: light)',
32
+ 'The reader has asked their device for a light interface. Stated rather than assumed, because the resting values are not the same thing as an answer to the question.',
33
+ ),
34
+ ] as const satisfies readonly (Omit<Threaded, 'at'> & { readonly is: string })[]
35
+
36
+ export const MODES = {
37
+ id: 'mode',
38
+ at: here,
39
+ describes:
40
+ 'Every way a reader’s own device can ask for a different look, and the query that answers it. A mode is chosen for the app rather than by it, which is what tells it from a theme — and a theme has its own modes, so the two are dimensions rather than one list. A mode changes what the variables hold, never what a material or a variant says.',
41
+ version: 1,
42
+ cites: said,
43
+ holds,
44
+ } as const satisfies Threaded & { readonly holds: readonly (Omit<Threaded, 'at'> & { readonly is: string })[] }
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import type { MapIsland, Screen } from './islands/map.ts'
4
+
5
+ import { Link } from './elements/link.tsx'
6
+
7
+ export type ScreenName = Screen['id']
8
+ import { element, useSurface } from './wrappers.tsx'
9
+
10
+ export function trailTo(map: MapIsland, to: ScreenName): readonly ScreenName[] {
11
+ const entries = map.screens.filter((s) => s.entry).map((s) => s.id)
12
+ if (entries.includes(to)) return [to]
13
+
14
+ const seen = new Set<ScreenName>(entries)
15
+ let frontier: readonly (readonly ScreenName[])[] = entries.map((id) => [id])
16
+
17
+ while (frontier.length > 0) {
18
+ const next: (readonly ScreenName[])[] = []
19
+ for (const path of frontier) {
20
+ const here = path[path.length - 1] as ScreenName
21
+ for (const link of map.links.filter((l) => l.from === here)) {
22
+ if (link.to === to) return [...path, link.to]
23
+ if (seen.has(link.to)) continue
24
+ seen.add(link.to)
25
+ next.push([...path, link.to])
26
+ }
27
+ }
28
+ frontier = next
29
+ }
30
+ return []
31
+ }
32
+
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import {
4
+ arrow as arrowMiddleware,
5
+ computePosition,
6
+ flip,
7
+ offset,
8
+ shift,
9
+ size,
10
+ type Middleware,
11
+ type Placement,
12
+ type Platform,
13
+ } from '@floating-ui/dom'
14
+ import { useFloating, autoUpdate } from '@floating-ui/react-dom'
15
+ import type { RefObject } from 'react'
16
+
17
+ export interface AnchorOptions {
18
+ readonly placement?: Placement
19
+
20
+ readonly gap?: number
21
+
22
+ readonly padding?: number
23
+ readonly arrowRef?: RefObject<HTMLElement | null>
24
+ }
25
+
26
+ export interface Position {
27
+ readonly x: number
28
+ readonly y: number
29
+
30
+ readonly placement: Placement
31
+
32
+ readonly maxWidth?: number
33
+ readonly maxHeight?: number
34
+ readonly arrow?: { readonly x?: number; readonly y?: number }
35
+ }
36
+
37
+ const DEFAULT_GAP = 4
38
+ const DEFAULT_PADDING = 8
39
+
40
+ function middleware(options: AnchorOptions): Middleware[] {
41
+ const padding = options.padding ?? DEFAULT_PADDING
42
+ const list: Middleware[] = [
43
+ offset(options.gap ?? DEFAULT_GAP),
44
+ flip({ padding }),
45
+ shift({ padding }),
46
+ size({
47
+ padding,
48
+ apply({ availableWidth, availableHeight, elements }) {
49
+
50
+ Object.assign(elements.floating.style, {
51
+ maxWidth: `${Math.max(0, availableWidth)}px`,
52
+ maxHeight: `${Math.max(0, availableHeight)}px`,
53
+ overflow: 'auto',
54
+ })
55
+ },
56
+ }),
57
+ ]
58
+
59
+ if (options.arrowRef !== undefined) {
60
+ list.push(arrowMiddleware({ element: options.arrowRef.current as Element, padding }))
61
+ }
62
+ return list
63
+ }
64
+
65
+ export async function positionOf(
66
+ reference: Element,
67
+ floating: HTMLElement,
68
+ options: AnchorOptions & { readonly platform?: Platform } = {},
69
+ ): Promise<Position> {
70
+ const result = await computePosition(reference, floating, {
71
+ placement: options.placement ?? 'bottom',
72
+ middleware: middleware(options),
73
+ ...(options.platform === undefined ? {} : { platform: options.platform }),
74
+ })
75
+ const arrowData = result.middlewareData.arrow
76
+ return {
77
+ x: result.x,
78
+ y: result.y,
79
+ placement: result.placement,
80
+ ...(arrowData === undefined ? {} : { arrow: { ...(arrowData.x === undefined ? {} : { x: arrowData.x }), ...(arrowData.y === undefined ? {} : { y: arrowData.y }) } }),
81
+ }
82
+ }
83
+
84
+ export function useAnchored(options: AnchorOptions = {}): ReturnType<typeof useFloating> {
85
+ return useFloating({
86
+ placement: options.placement ?? 'bottom',
87
+ middleware: middleware(options),
88
+ whileElementsMounted: autoUpdate,
89
+ })
90
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ import { PRESENTATION_BEHAVIOUR, type Presentation } from './door.ts'
7
+ import { useDismissLayer } from './dismiss-layer.ts'
8
+ import { useFocusScope } from './focus-scope.ts'
9
+ import { lockScroll } from './scroll-lock.ts'
10
+
11
+ export interface PresentedProps {
12
+ readonly presentation: Presentation
13
+
14
+ readonly open?: boolean
15
+ readonly onDismiss?: () => void
16
+ readonly className?: string
17
+ readonly children: ReactNode
18
+ }
19
+
20
+ export function Presented(props: PresentedProps): ReactNode {
21
+ const behaviour = PRESENTATION_BEHAVIOUR[props.presentation]
22
+ const open = props.open ?? true
23
+ const ref = useRef<HTMLDivElement>(null)
24
+ const [leaving, setLeaving] = useState(false)
25
+ const [was, setWas] = useState(open)
26
+ const labelId = useId()
27
+
28
+ useFocusScope(ref, {
29
+ active: open,
30
+ trapFocus: behaviour.trapFocus,
31
+ returnFocus: behaviour.returnFocus,
32
+ })
33
+
34
+ useDismissLayer(ref, {
35
+ active: open && (behaviour.dismissOnEscape || behaviour.dismissOnOutside),
36
+ dismissOnEscape: behaviour.dismissOnEscape,
37
+ dismissOnOutside: behaviour.dismissOnOutside,
38
+ onDismiss: props.onDismiss ?? (() => undefined),
39
+ })
40
+
41
+ useEffect(() => {
42
+ if (!open || !behaviour.backdrop) return
43
+ return lockScroll()
44
+ }, [open, behaviour.backdrop])
45
+
46
+ if (was !== open) {
47
+ setWas(open)
48
+ setLeaving(!open)
49
+ }
50
+
51
+ useEffect(() => {
52
+ if (!leaving) return undefined
53
+ const node = ref.current
54
+ if (node === null) {
55
+ setLeaving(false)
56
+ return undefined
57
+ }
58
+ const running =
59
+ typeof globalThis.getComputedStyle === 'function' &&
60
+ globalThis.getComputedStyle(node).animationName !== 'none'
61
+ if (!running) {
62
+ setLeaving(false)
63
+ return undefined
64
+ }
65
+ const done = (): void => setLeaving(false)
66
+ node.addEventListener('animationend', done)
67
+
68
+ const style = globalThis.getComputedStyle(node)
69
+ const seconds = (one: string): number =>
70
+ one.split(',').reduce((most, part) => Math.max(most, Number.parseFloat(part) || 0), 0)
71
+ const after = (seconds(style.animationDuration) + seconds(style.animationDelay)) * 1000 + 50
72
+ const timer = setTimeout(done, after)
73
+
74
+ return () => {
75
+ node.removeEventListener('animationend', done)
76
+ clearTimeout(timer)
77
+ }
78
+ }, [leaving])
79
+
80
+ useEffect(() => {
81
+ if (!open) return
82
+ const root = ref.current
83
+ if (root === null) return
84
+ const title = root.querySelector('[data-screen-title], h1, h2, h3, h4, h5, h6')
85
+ if (title === null) return
86
+ if (title.id === '') title.id = labelId
87
+ root.setAttribute('aria-labelledby', title.id)
88
+ }, [open, labelId])
89
+
90
+ if (!open && !leaving) return null
91
+
92
+ const surface = (
93
+ <div
94
+ ref={ref}
95
+ className={props.className}
96
+ data-presentation={props.presentation}
97
+ {...(leaving ? { 'data-leaving': '' } : {})}
98
+
99
+ {...(behaviour.trapFocus ? { role: 'dialog', 'aria-modal': true } : {})}
100
+ {...(props.presentation === 'toast' ? { role: 'status', 'aria-live': 'polite' as const } : {})}
101
+ >
102
+ {props.children}
103
+ </div>
104
+ )
105
+
106
+ const content = behaviour.backdrop ? (
107
+ <>
108
+ {}
109
+ <div data-backdrop="true" aria-hidden="true" />
110
+ {surface}
111
+ </>
112
+ ) : (
113
+ surface
114
+ )
115
+
116
+ if (!behaviour.portal) return content
117
+
118
+ return createPortal(content, document.body)
119
+ }
@@ -17,7 +17,9 @@ export interface ThreadUiProps {
17
17
  readonly asking?: readonly {
18
18
  readonly id: string;
19
19
  readonly selector: string;
20
+ readonly when?: string;
20
21
  }[];
22
+ readonly permits?: (action: string) => boolean;
21
23
  readonly platform?: string;
22
24
  readonly telemetry?: {
23
25
  readonly endpoint?: string;
@@ -83,6 +83,11 @@ export function ThreadUi(props) {
83
83
  });
84
84
  }, [telemetryOff, props.telemetry?.endpoint, props.telemetry?.sample, props.telemetry?.allowed]);
85
85
  useEffect(() => () => collector.stop(), [collector]);
86
+ const gate = props.permits;
87
+ const asked = props.asking;
88
+ const holding = useMemo(() => gate === undefined
89
+ ? []
90
+ : (asked ?? []).filter((one) => one.when !== undefined && !gate(one.when)).map((one) => one.id), [gate, asked]);
86
91
  const derived = useMemo(() => ({
87
92
  palette: props.palette ?? readPalette(),
88
93
  appearance,
@@ -92,12 +97,13 @@ export function ThreadUi(props) {
92
97
  edgeScroll: readEdgeScroll(),
93
98
  ...(props.actor === undefined ? {} : { actor: props.actor }),
94
99
  ...(props.asking === undefined ? {} : { asking: props.asking }),
100
+ ...(holding.length === 0 ? {} : { holding }),
95
101
  ...(props.platform === undefined ? {} : { platform: props.platform }),
96
102
  ...(props.dispatch === undefined ? {} : { dispatch: props.dispatch }),
97
103
  ...(props.navigateTo === undefined ? {} : { navigateTo: props.navigateTo }),
98
104
  collector,
99
105
  translate: (key, params) => translate(key, params),
100
- }), [props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate]);
106
+ }), [props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, holding, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate]);
101
107
  if (existing !== null && !opinionated)
102
108
  return props.children;
103
109
  return _jsx(UiProvider, { config: props.config ?? derived, children: props.children });