@prestack/thread-ui 0.1.9 → 0.1.11

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 (210) hide show
  1. package/CHANGELOG.md +366 -0
  2. package/dist/ui/attach.js +6 -7
  3. package/dist/ui/compositions.d.ts +0 -2
  4. package/dist/ui/compositions.js +2 -112
  5. package/dist/ui/generators/routes.js +1 -0
  6. package/dist/ui/variant.d.ts +1 -3
  7. package/package.json +2 -2
  8. package/dist/ui/animations/anm_appear.ts +0 -13
  9. package/dist/ui/animations/anm_blur_in.ts +0 -19
  10. package/dist/ui/animations/anm_blur_out.ts +0 -18
  11. package/dist/ui/animations/anm_dismiss.ts +0 -18
  12. package/dist/ui/animations/anm_drop.ts +0 -19
  13. package/dist/ui/animations/anm_fall.ts +0 -18
  14. package/dist/ui/animations/anm_flash.ts +0 -18
  15. package/dist/ui/animations/anm_glow.ts +0 -16
  16. package/dist/ui/animations/anm_lift.ts +0 -16
  17. package/dist/ui/animations/anm_nudge.ts +0 -16
  18. package/dist/ui/animations/anm_pop_in.ts +0 -23
  19. package/dist/ui/animations/anm_pop_out.ts +0 -22
  20. package/dist/ui/animations/anm_press.ts +0 -12
  21. package/dist/ui/animations/anm_pulse.ts +0 -13
  22. package/dist/ui/animations/anm_rise.ts +0 -19
  23. package/dist/ui/animations/anm_shake.ts +0 -13
  24. package/dist/ui/animations/anm_slide_in_left.ts +0 -19
  25. package/dist/ui/animations/anm_slide_in_right.ts +0 -19
  26. package/dist/ui/animations/anm_slide_out_left.ts +0 -18
  27. package/dist/ui/animations/anm_slide_out_right.ts +0 -18
  28. package/dist/ui/animations/anm_spin.ts +0 -12
  29. package/dist/ui/animations/anm_swell.ts +0 -16
  30. package/dist/ui/animations/anm_wobble.ts +0 -24
  31. package/dist/ui/animations/anm_zoom_in.ts +0 -19
  32. package/dist/ui/animations/anm_zoom_out.ts +0 -18
  33. package/dist/ui/animations.ts +0 -308
  34. package/dist/ui/attach.ts +0 -553
  35. package/dist/ui/charts.tsx +0 -69
  36. package/dist/ui/checked.ts +0 -32
  37. package/dist/ui/collector.ts +0 -140
  38. package/dist/ui/compositions.ts +0 -555
  39. package/dist/ui/consent.ts +0 -55
  40. package/dist/ui/content.tsx +0 -27
  41. package/dist/ui/contrast.ts +0 -27
  42. package/dist/ui/controls.tsx +0 -25
  43. package/dist/ui/disclosure.tsx +0 -20
  44. package/dist/ui/dismiss-layer.ts +0 -87
  45. package/dist/ui/door.ts +0 -140
  46. package/dist/ui/drag.ts +0 -316
  47. package/dist/ui/elements/accordion.tsx +0 -47
  48. package/dist/ui/elements/accordion.works.ts +0 -66
  49. package/dist/ui/elements/aspect-ratio.tsx +0 -31
  50. package/dist/ui/elements/aspect-ratio.works.ts +0 -5
  51. package/dist/ui/elements/avatar.tsx +0 -34
  52. package/dist/ui/elements/avatar.works.ts +0 -16
  53. package/dist/ui/elements/badge.tsx +0 -33
  54. package/dist/ui/elements/badge.works.ts +0 -9
  55. package/dist/ui/elements/bars.tsx +0 -50
  56. package/dist/ui/elements/bars.works.ts +0 -29
  57. package/dist/ui/elements/box.tsx +0 -36
  58. package/dist/ui/elements/breadcrumb.tsx +0 -38
  59. package/dist/ui/elements/breadcrumb.works.ts +0 -24
  60. package/dist/ui/elements/button.tsx +0 -46
  61. package/dist/ui/elements/button.works.tsx +0 -52
  62. package/dist/ui/elements/column.tsx +0 -29
  63. package/dist/ui/elements/consent.tsx +0 -71
  64. package/dist/ui/elements/consent.works.tsx +0 -132
  65. package/dist/ui/elements/donut.tsx +0 -45
  66. package/dist/ui/elements/donut.works.ts +0 -42
  67. package/dist/ui/elements/each.tsx +0 -34
  68. package/dist/ui/elements/each.works.tsx +0 -82
  69. package/dist/ui/elements/empty.tsx +0 -33
  70. package/dist/ui/elements/field.tsx +0 -45
  71. package/dist/ui/elements/field.works.ts +0 -65
  72. package/dist/ui/elements/funnel.tsx +0 -50
  73. package/dist/ui/elements/funnel.works.ts +0 -37
  74. package/dist/ui/elements/gauge.tsx +0 -40
  75. package/dist/ui/elements/gauge.works.ts +0 -42
  76. package/dist/ui/elements/glyph.tsx +0 -30
  77. package/dist/ui/elements/glyph.works.tsx +0 -42
  78. package/dist/ui/elements/grid.tsx +0 -29
  79. package/dist/ui/elements/heatmap.tsx +0 -48
  80. package/dist/ui/elements/heatmap.works.ts +0 -24
  81. package/dist/ui/elements/hit-box.tsx +0 -47
  82. package/dist/ui/elements/hit-box.works.tsx +0 -576
  83. package/dist/ui/elements/image.tsx +0 -29
  84. package/dist/ui/elements/line-chart.tsx +0 -45
  85. package/dist/ui/elements/line-chart.works.ts +0 -41
  86. package/dist/ui/elements/link.tsx +0 -38
  87. package/dist/ui/elements/link.works.ts +0 -25
  88. package/dist/ui/elements/media.tsx +0 -57
  89. package/dist/ui/elements/menu.tsx +0 -51
  90. package/dist/ui/elements/menu.works.tsx +0 -154
  91. package/dist/ui/elements/money.tsx +0 -36
  92. package/dist/ui/elements/money.works.ts +0 -9
  93. package/dist/ui/elements/pagination.tsx +0 -39
  94. package/dist/ui/elements/pagination.works.ts +0 -20
  95. package/dist/ui/elements/progress.tsx +0 -34
  96. package/dist/ui/elements/progress.works.ts +0 -8
  97. package/dist/ui/elements/radio-group.tsx +0 -50
  98. package/dist/ui/elements/radio-group.works.ts +0 -33
  99. package/dist/ui/elements/row.tsx +0 -29
  100. package/dist/ui/elements/scatter.tsx +0 -42
  101. package/dist/ui/elements/scatter.works.ts +0 -38
  102. package/dist/ui/elements/select.tsx +0 -43
  103. package/dist/ui/elements/select.works.ts +0 -5
  104. package/dist/ui/elements/separator.tsx +0 -31
  105. package/dist/ui/elements/skeleton.tsx +0 -31
  106. package/dist/ui/elements/slider.tsx +0 -34
  107. package/dist/ui/elements/slider.works.ts +0 -5
  108. package/dist/ui/elements/sparkline.tsx +0 -37
  109. package/dist/ui/elements/sparkline.works.ts +0 -32
  110. package/dist/ui/elements/spinner.tsx +0 -33
  111. package/dist/ui/elements/stack.tsx +0 -29
  112. package/dist/ui/elements/stat.tsx +0 -36
  113. package/dist/ui/elements/stat.works.ts +0 -14
  114. package/dist/ui/elements/surface.tsx +0 -36
  115. package/dist/ui/elements/surface.works.ts +0 -9
  116. package/dist/ui/elements/table.tsx +0 -62
  117. package/dist/ui/elements/table.works.ts +0 -30
  118. package/dist/ui/elements/tabs.tsx +0 -47
  119. package/dist/ui/elements/tabs.works.ts +0 -84
  120. package/dist/ui/elements/text.tsx +0 -33
  121. package/dist/ui/elements/textarea.tsx +0 -38
  122. package/dist/ui/elements/textarea.works.ts +0 -5
  123. package/dist/ui/elements/time.tsx +0 -40
  124. package/dist/ui/elements/time.works.ts +0 -29
  125. package/dist/ui/elements/toggle.tsx +0 -34
  126. package/dist/ui/elements/toggle.works.ts +0 -5
  127. package/dist/ui/elements/tooltip.tsx +0 -40
  128. package/dist/ui/elements/tooltip.works.ts +0 -77
  129. package/dist/ui/elements/value.tsx +0 -38
  130. package/dist/ui/elements/value.works.ts +0 -9
  131. package/dist/ui/elements.ts +0 -127
  132. package/dist/ui/focus-scope.ts +0 -121
  133. package/dist/ui/fonts.ts +0 -355
  134. package/dist/ui/generators/dev.d.ts +0 -1
  135. package/dist/ui/generators/dev.js +0 -61
  136. package/dist/ui/generators/dev.ts +0 -74
  137. package/dist/ui/generators/elements.ts +0 -796
  138. package/dist/ui/generators/i18n.ts +0 -283
  139. package/dist/ui/generators/icons.ts +0 -207
  140. package/dist/ui/generators/react.ts +0 -267
  141. package/dist/ui/generators/routes.ts +0 -847
  142. package/dist/ui/generators/show.ts +0 -261
  143. package/dist/ui/generators/styles.ts +0 -677
  144. package/dist/ui/generators/variants.ts +0 -196
  145. package/dist/ui/i18n/catalogs/en.ts +0 -191
  146. package/dist/ui/i18n/format.ts +0 -221
  147. package/dist/ui/i18n/locales.ts +0 -33
  148. package/dist/ui/i18n/parse.ts +0 -315
  149. package/dist/ui/i18n/translator.ts +0 -31
  150. package/dist/ui/i18n/types.ts +0 -96
  151. package/dist/ui/icons.ts +0 -331
  152. package/dist/ui/islands/map.ts +0 -58
  153. package/dist/ui/localizations.ts +0 -75
  154. package/dist/ui/material.ts +0 -236
  155. package/dist/ui/materials/mat_bordered.ts +0 -13
  156. package/dist/ui/materials/mat_ceramic.ts +0 -13
  157. package/dist/ui/materials/mat_clay.ts +0 -13
  158. package/dist/ui/materials/mat_elevated.ts +0 -24
  159. package/dist/ui/materials/mat_flat.ts +0 -34
  160. package/dist/ui/materials/mat_glass.ts +0 -20
  161. package/dist/ui/materials/mat_lit.ts +0 -13
  162. package/dist/ui/materials/mat_moulded.ts +0 -11
  163. package/dist/ui/materials.ts +0 -197
  164. package/dist/ui/modes.ts +0 -44
  165. package/dist/ui/navigation.tsx +0 -29
  166. package/dist/ui/positioning.ts +0 -90
  167. package/dist/ui/presented.tsx +0 -119
  168. package/dist/ui/provider.tsx +0 -184
  169. package/dist/ui/render-loop.ts +0 -133
  170. package/dist/ui/roving-focus.ts +0 -161
  171. package/dist/ui/screens.ts +0 -141
  172. package/dist/ui/scroll-lock.ts +0 -28
  173. package/dist/ui/series.ts +0 -97
  174. package/dist/ui/signals.ts +0 -386
  175. package/dist/ui/styleguide.ts +0 -231
  176. package/dist/ui/styleguides.ts +0 -374
  177. package/dist/ui/themes.ts +0 -22
  178. package/dist/ui/token.ts +0 -78
  179. package/dist/ui/tokens/shadow.ts +0 -25
  180. package/dist/ui/tokens.ts +0 -234
  181. package/dist/ui/validators.ts +0 -232
  182. package/dist/ui/variant.ts +0 -352
  183. package/dist/ui/variants/var_avatar-round.ts +0 -14
  184. package/dist/ui/variants/var_bubble-me.ts +0 -14
  185. package/dist/ui/variants/var_bubble-them.ts +0 -13
  186. package/dist/ui/variants/var_button-border.ts +0 -76
  187. package/dist/ui/variants/var_button-cta.ts +0 -76
  188. package/dist/ui/variants/var_button-destructive-ghost.ts +0 -72
  189. package/dist/ui/variants/var_button-destructive.ts +0 -76
  190. package/dist/ui/variants/var_button-fill.ts +0 -76
  191. package/dist/ui/variants/var_button-ghost.ts +0 -72
  192. package/dist/ui/variants/var_button-glass.ts +0 -55
  193. package/dist/ui/variants/var_button-normal.ts +0 -76
  194. package/dist/ui/variants/var_button-quiet.ts +0 -74
  195. package/dist/ui/variants/var_card.ts +0 -17
  196. package/dist/ui/variants/var_chip.ts +0 -13
  197. package/dist/ui/variants/var_choice.ts +0 -15
  198. package/dist/ui/variants/var_framed.ts +0 -10
  199. package/dist/ui/variants/var_glyph-plain.ts +0 -11
  200. package/dist/ui/variants/var_input.ts +0 -43
  201. package/dist/ui/variants/var_link-inline.ts +0 -29
  202. package/dist/ui/variants/var_list-row.ts +0 -34
  203. package/dist/ui/variants/var_rule.ts +0 -10
  204. package/dist/ui/variants/var_skeleton-block.ts +0 -12
  205. package/dist/ui/variants/var_spinner-turning.ts +0 -12
  206. package/dist/ui/variants/var_stat-tile.ts +0 -13
  207. package/dist/ui/variants/var_tab-bar.ts +0 -15
  208. package/dist/ui/variants/var_tap-target.ts +0 -25
  209. package/dist/ui/variants.ts +0 -64
  210. package/dist/ui/wrappers.tsx +0 -429
@@ -1,141 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from '@prestack/thread/integrations/disk'
2
- import { basename, join } from 'node:path'
3
- import { argv, stdout } from 'node:process'
4
-
5
- import { exit, invoked } from '@prestack/thread/core/invoked'
6
- import { valuesIn } from '@prestack/thread/core/rows'
7
- import type { Threaded } from '@prestack/thread/core/thread'
8
- import { findTree, inTree } from '@prestack/thread/core/tree'
9
-
10
- import { HOME, SCREENED_BY, settled, undeclared } from './compositions.ts'
11
- import { PRESENTATIONS } from './door.ts'
12
-
13
- const here = 'thread/ui/screens.ts'
14
-
15
- const said = [
16
- {
17
- on: '2026-08-25',
18
- said: 'Screens are basically compositions arranged in a screen. And this is where we have breakpoint, etc. Compositions maybe also have breakpoints.',
19
- },
20
- {
21
- on: '2026-08-25',
22
- said: 'Arrangement is a real fucking thing. IT IS HOW THE STUFF LOOKS IN A SCREEN',
23
- },
24
- {
25
- on: '2026-08-25',
26
- said: 'Both',
27
- },
28
- {
29
- on: '2026-09-09',
30
- said: 'We have one screen or more. A button navigates to other screen. From THAT the routes are generated.',
31
- },
32
- ]
33
-
34
- export const declares: readonly Threaded[] = []
35
-
36
- export const SCREENS: Threaded & { readonly files: string } = {
37
- id: 'screen',
38
- at: here,
39
- files: 'screens/scr_*.tsx',
40
- describes:
41
- 'Compositions arranged, and the way it is presented: page, sheet, popover, overlay or toast. Where it lives is never typed. The first page an app declares answers `/`, every other page answers its own name, and a sheet, popover, overlay or toast has no route, because a press opens it where somebody already is. The arrangement is the whole of what a screen adds, because how things sit on a screen is declared rather than markup written twice. Each row names a file prefixed `scr_`, sets the properties that lay its compositions out, and may set them differently under a mode, which is where a breakpoint lives: a width is a media query the same way dark is. A composition may set its own arrangement too. The framework ships none, because what an app is made of is the app’s, and this row says what a screen is, never what is inside it.',
42
- version: 1,
43
- cites: said,
44
- declares,
45
- }
46
-
47
- const PASCAL = (id: string): string =>
48
- id
49
- .split(/[-_]/)
50
- .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
51
- .join('')
52
-
53
- export function nameOf(at: string): string {
54
- const file = at.split('\\').join('/').split('/').at(-1) ?? at
55
- return PASCAL(file.replace(/^scr_/, '').replace(/\.[^.]+$/, ''))
56
- }
57
-
58
- export const PAGE = PRESENTATIONS[0]
59
-
60
- const PRESENTED = /screen\(\s*'[^']*'\s*,\s*\{[^}]*as:\s*'([^']*)'/
61
-
62
- export function presentedIn(source: string): string {
63
- return PRESENTED.exec(source)?.[1] ?? PAGE
64
- }
65
-
66
- export function routeFor(id: string, entry: boolean): string {
67
- return entry ? '/' : `/${id}`
68
- }
69
-
70
- export function pageAt(route: string): string {
71
- const trimmed = route.replace(/^\/+|\/+$/g, '')
72
- return trimmed === '' ? `app/page.${'tsx'}` : `app/${trimmed}/page.${'tsx'}`
73
- }
74
-
75
- export function entryAmong(rows: readonly { readonly id: string }[], shownAs: (id: string) => string): string | undefined {
76
- return rows.find((row) => shownAs(row.id) === PAGE)?.id
77
- }
78
-
79
- function starter(row: Threaded): string {
80
- const name = nameOf(row.at)
81
- return `'use client'
82
-
83
- import { screen } from '${HOME}/wrappers'
84
-
85
- export const ${name} = screen('${row.id}', {}, () => (
86
- <>
87
- </>
88
- ))
89
- `
90
- }
91
-
92
- export function seeds(rows: readonly Threaded[], held: (at: string) => boolean): readonly { readonly path: string; readonly content: string }[] {
93
- return rows.filter((row) => !held(row.at)).map((row) => ({ path: row.at, content: starter(row) }))
94
- }
95
-
96
- function run(): number {
97
- const tree = findTree()
98
- const at = inTree(tree, 'screens.ts')
99
- if (!existsSync(at)) {
100
- stdout.write(`thread: this tree has no ${tree.name}/${basename(at)}, so it declares no screen and there is nothing to seed or check.\n`)
101
- return 0
102
- }
103
- const held = valuesIn(readFileSync(at)) as { SCREEN?: { readonly declares?: readonly Threaded[] } }
104
-
105
- if (held.SCREEN === undefined) {
106
- stdout.write(`thread: ${tree.name}/screens.ts exports no \`SCREEN\`, so nothing declares a screen here.\n`)
107
- return 1
108
- }
109
-
110
- const rows = held.SCREEN.declares ?? []
111
- const checking = argv.includes('--check')
112
- const made = seeds(rows, (where) => existsSync(join(tree.at, where)))
113
-
114
- const unseeded = made[0]
115
- if (checking && unseeded !== undefined) {
116
- stdout.write(
117
- `thread: ${unseeded.path} is declared and the file is not there yet. A row names a file, and ` +
118
- '`--check` never writes one — run `thread screens` and commit what it seeds.\n',
119
- )
120
- return 1
121
- }
122
-
123
- if (!checking) {
124
- for (const one of made) {
125
- writeFileSync(join(tree.at, one.path), one.content, 'utf8')
126
- stdout.write(`thread: seeded ${one.path}\n`)
127
- }
128
- }
129
-
130
- const loose = [...undeclared(tree, rows), ...settled(tree.at, rows, checking, SCREENED_BY, new Set<string>())]
131
- for (const one of loose) stdout.write(`thread: ${one}\n`)
132
-
133
- stdout.write(
134
- `thread: ${String(rows.length)} screen(s) declared, ${String(made.length)} seeded, ` +
135
- `${String(rows.length - made.length)} already written, ` +
136
- `${loose.length === 0 ? 'none reaching anything undeclared' : `${String(loose.length)} reaching something undeclared`}\n`,
137
- )
138
- return loose.length === 0 ? 0 : 1
139
- }
140
-
141
- if (invoked(import.meta.url)) exit(run())
@@ -1,28 +0,0 @@
1
- import { nameOf } from './token.ts'
2
-
3
-
4
-
5
- let held = 0
6
- let previous: string | null = null
7
-
8
- export function lockScroll(): () => void {
9
- if (held === 0) {
10
- previous = document.body.style.overflow
11
- document.body.style.overflow = `var(${nameOf('overflow-hidden')})`
12
- }
13
- held += 1
14
-
15
- let released = false
16
- return () => {
17
- if (released) return
18
- released = true
19
- held -= 1
20
- if (held > 0) return
21
- document.body.style.overflow = previous ?? ''
22
- previous = null
23
- }
24
- }
25
-
26
- export function scrollLocks(): number {
27
- return held
28
- }
package/dist/ui/series.ts DELETED
@@ -1,97 +0,0 @@
1
- import { contrast, CONTRAST_LARGE } from './contrast.ts'
2
-
3
- const GOLDEN_ANGLE = 137.508
4
-
5
- const toLinear = (c: number): number => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
6
- const toSrgb = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
7
-
8
- function parseHex(hex: string): [number, number, number] {
9
- const clean = hex.replace('#', '')
10
- const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean.slice(0, 6)
11
- const n = Number.parseInt(full, 16)
12
- return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
13
- }
14
-
15
- function toHex(r: number, g: number, b: number): string {
16
- const channel = (v: number): string =>
17
- Math.round(Math.min(1, Math.max(0, v)) * 255)
18
- .toString(16)
19
- .padStart(2, '0')
20
- return `#${channel(r)}${channel(g)}${channel(b)}`
21
- }
22
-
23
- export interface Oklch {
24
-
25
- readonly l: number
26
-
27
- readonly c: number
28
-
29
- readonly h: number
30
- }
31
-
32
- export function hexToOklch(hex: string): Oklch {
33
- const [r8, g8, b8] = parseHex(hex)
34
- const r = toLinear(r8)
35
- const g = toLinear(g8)
36
- const b = toLinear(b8)
37
-
38
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
39
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
40
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
41
-
42
- const lab = {
43
- L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
44
- a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
45
- b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
46
- }
47
-
48
- const hue = (Math.atan2(lab.b, lab.a) * 180) / Math.PI
49
- return { l: lab.L, c: Math.hypot(lab.a, lab.b), h: hue < 0 ? hue + 360 : hue }
50
- }
51
-
52
- export function oklchToHex({ l: L, c: C, h }: Oklch): string {
53
- const rad = (h * Math.PI) / 180
54
- const a = C * Math.cos(rad)
55
- const b = C * Math.sin(rad)
56
-
57
- const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
58
- const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
59
- const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
60
-
61
- return toHex(
62
- toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
63
- toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
64
- toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
65
- )
66
- }
67
-
68
- export function seriesRamp(accent: string, surface: string, count: number): readonly string[] {
69
- const base = hexToOklch(accent)
70
- const out: string[] = []
71
-
72
- for (let i = 0; i < count; i += 1) {
73
- const hue = (base.h + GOLDEN_ANGLE * i) % 360
74
- out.push(oklchToHex(readable({ ...base, h: hue }, surface)))
75
- }
76
- return out
77
- }
78
-
79
- function readable(colour: Oklch, surface: string): Oklch {
80
- if (contrast(oklchToHex(colour), surface) >= CONTRAST_LARGE) return colour
81
-
82
- const surfaceL = hexToOklch(surface).l
83
-
84
- const direction = surfaceL > 0.5 ? -1 : 1
85
-
86
- let best = colour
87
- for (let step = 1; step <= 24; step += 1) {
88
- const candidate = { ...colour, l: Math.min(0.98, Math.max(0.02, colour.l + direction * step * 0.02)) }
89
- best = candidate
90
- if (contrast(oklchToHex(candidate), surface) >= CONTRAST_LARGE) return candidate
91
- }
92
- return best
93
- }
94
-
95
- export function seriesColour(ramp: readonly string[], index: number): string {
96
- return ramp[index % ramp.length] ?? ramp[0] ?? '#888888'
97
- }
@@ -1,386 +0,0 @@
1
-
2
-
3
- import type { Threaded } from '@prestack/thread/core/thread'
4
-
5
- export interface SignalEntry {
6
-
7
- readonly name: (typeof holds)[number]['id']
8
-
9
- readonly means: string
10
- }
11
-
12
- const here = 'thread/ui/signals.ts'
13
-
14
- const kebab = (id: string): string => id.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
15
-
16
- const raised = <Id extends string>(
17
- id: Id,
18
- describes: string,
19
- selector?: string,
20
- ): Omit<Threaded, 'at' | 'id'> & { readonly id: Id; readonly selector: string } => ({
21
- id,
22
- describes,
23
- version: 1,
24
- selector: selector ?? `[data-${kebab(id)}]`,
25
- })
26
-
27
- export const holds = [
28
- raised('hover', 'A pointer is over this element with no button held. The browser knows this one and sets it itself.', ':hover'),
29
- raised('active', 'A pointer is held down on this element. The browser sets it, and it is NOT the press gesture, which has to survive a dwell.', ':active'),
30
- raised('focus', 'Keyboard focus is on this element and the browser judges it worth showing a ring for. Uses focus-visible, so a mouse click does not draw one.', ':focus-visible'),
31
- raised('disabled', 'This element refuses interaction. The browser sets it from the disabled attribute, which also takes it out of the focus order.', ':disabled'),
32
- raised('invalid', 'The value in this element is wrong, and the browser or the validator has said so.', ':invalid'),
33
- raised('loading', 'A read this element depends on has not answered yet.'),
34
- raised('leaving', 'This element has been dismissed and is still on the page while it goes. The one signal nothing outside sets: whatever removes the thing raises it, keeps the element mounted for exactly as long as its exit takes, and then lets go. Without it an exit can be declared and can never run, because a thing already removed from the page has nothing left to animate.'),
35
- raised('arriving', 'This element has just been made and its entrance has not finished. The mirror of `leaving`, and it exists for the same reason that one does: an entrance was emitted as a plain `animation` on the class, on the stated grounds that "a class carrying an `animation` plays it the moment the element exists, which is exactly once". That is not what a browser does. It plays it every time the node is inserted — and React reorders keyed children by moving the existing nodes, which the DOM performs as a remove and an insert — so moving one card in a board replayed the entrance of every card that shifted under it. It also replays whenever a state rule stops winning and the resting `animation` declaration becomes the one in force again, which happens the instant a drag ends. Nothing raises this but the element itself: it is on from the moment it is made until its own entrance ends, and it is never set again, so a node that is moved afterwards has nothing left to play.'),
36
- raised('carrying', 'This hit box is the SOURCE of the drag in progress. What "it lifted off the page" reads.'),
37
- raised('draggedOver', 'A drag is over this hit box and this hit box accepts what is being carried. What the glow, the border and the gap-opening read.'),
38
- raised('refusing', 'A drag is over this hit box and it does NOT accept the subject. The half every app hand-writes, so "you cannot drop here" is shown rather than merely not working.'),
39
- raised('tapped', 'A tap completed on this hit box — true for the one frame that recognised it.'),
40
- raised('doubleTapped', 'A second tap landed inside the gap — true for the one frame that recognised it.'),
41
- raised('pressing', 'A press has been recognised on this hit box — held past its dwell, not merely touched.'),
42
- raised('dragging', 'A drag has been recognised on this hit box and the pointer is still down.'),
43
- raised('swiping', 'A swipe is under way — far enough to count, not yet released.'),
44
- raised('pinching', 'Two pointers on this hit box are changing their distance.'),
45
- raised('rotating', 'Two pointers on this hit box are changing their angle.'),
46
-
47
- raised('spinner', 'This element is a turning indicator, for as long as it is on screen. Held rather than raised — nothing starts it and nothing stops it.'),
48
- raised('empty', 'This element is what a surface says when its read answered with nothing. Held rather than raised, like `spinner` — it is what the thing IS, not something that happened to it.'),
49
-
50
- raised('scrolling', 'This grouping scrolls its own content rather than growing to fit it. Held rather than raised: it is how the box was declared, not something that happens to it.'),
51
- ] as const satisfies readonly (Omit<Threaded, 'at'> & { readonly selector: string })[]
52
-
53
- export function selectorOf(name: string): string | undefined {
54
- const shipped = holds.find((one) => one.id === name)
55
- if (shipped !== undefined) return shipped.selector
56
- if (name === '') return undefined
57
- return `[data-${kebab(name)}]`
58
- }
59
-
60
- export const SIGNALS: Threaded & {
61
- readonly holds: readonly (Omit<Threaded, 'at'> & { readonly selector: string })[]
62
- } = {
63
- id: 'signal',
64
- at: here,
65
- describes:
66
- 'Every truth an element can be in, in one sheet. A signal is a yes-or-no fact — being pressed, loading, dragged over — and a variant declares how it looks when one is true, or when several are true at once. A signal is declared here before anything may name it; nothing brings one into existence by running.',
67
- version: 1,
68
- cites: [
69
- {
70
- on: '2026-08-24',
71
- said: 'Variants define lots of things. Including how they look in different states. Including how they look in multiple states at once.',
72
- },
73
- ],
74
- holds,
75
- }
76
-
77
- export const EXCLUSIVE: readonly (readonly string[])[] = [
78
- ['carrying', 'draggedOver', 'refusing'],
79
- ['disabled', 'pressing'],
80
-
81
- ['disabled', 'focus'],
82
-
83
- ['disabled', 'active'],
84
-
85
- ['tapped', 'doubleTapped', 'pressing', 'dragging', 'swiping', 'pinching', 'rotating'],
86
-
87
- ['carrying', 'tapped'],
88
- ['carrying', 'doubleTapped'],
89
- ]
90
-
91
- export function coexist(one: string, other: string): boolean {
92
- return !EXCLUSIVE.some((group) => group.includes(one) && group.includes(other))
93
- }
94
-
95
- const declaredByApp = new Set<string>()
96
-
97
- export function knowSignals(names: readonly string[]): void {
98
- for (const one of names) if (!holds.some((each) => each.id === one)) declaredByApp.add(one)
99
- }
100
-
101
- export function signalNames(): readonly string[] {
102
- return [...holds.map((one) => one.id), ...declaredByApp]
103
- }
104
-
105
- export function isSignal(name: string): boolean {
106
- return holds.some((one) => one.id === name) || declaredByApp.has(name)
107
- }
108
-
109
- export interface PointerSample {
110
-
111
- readonly id: number
112
-
113
- readonly x: number
114
- readonly y: number
115
-
116
- readonly startX: number
117
- readonly startY: number
118
-
119
- readonly held: number
120
-
121
- readonly down: boolean
122
- }
123
-
124
- export interface GestureFrame {
125
-
126
- readonly pointers: readonly PointerSample[]
127
-
128
- readonly t: number
129
-
130
- readonly sinceRelease: number
131
-
132
- readonly params: Readonly<Record<string, number>>
133
- }
134
-
135
- export type GesturePhase = 'idle' | 'active' | 'done'
136
-
137
- export interface Recognition {
138
- readonly phase: GesturePhase
139
-
140
- readonly values?: Readonly<Record<string, number>>
141
- }
142
-
143
- export const IDLE: Recognition = { phase: 'idle' }
144
-
145
- export interface GestureEntry {
146
- readonly name: string
147
-
148
- readonly means: string
149
-
150
- readonly pointers: number
151
-
152
- readonly raises: readonly SignalEntry[]
153
-
154
- readonly produces?: readonly string[]
155
-
156
- readonly recognise: (frame: GestureFrame) => Recognition
157
- }
158
-
159
- const GESTURES = new Map<string, GestureEntry>()
160
-
161
- export function defineGesture(entry: GestureEntry): GestureEntry {
162
- if (GESTURES.has(entry.name)) throw new Error(`gesture "${entry.name}" is already defined`)
163
- if (entry.raises.length === 0) {
164
- throw new Error(
165
- `gesture "${entry.name}" raises no signal, so nothing could react to it. Give it at least one — ` +
166
- 'that signal is its entire contract with the style system.',
167
- )
168
- }
169
- if (entry.pointers < 1) throw new Error(`gesture "${entry.name}" needs at least one pointer`)
170
-
171
- for (const signal of entry.raises) {
172
- if (isSignal(signal.name)) continue
173
- throw new Error(
174
- `gesture "${entry.name}" raises "${signal.name}", and no signal by that name is declared. ` +
175
- `Declared: ${signalNames().join(' · ')}. Add the row to the sheet first.`,
176
- )
177
- }
178
- GESTURES.set(entry.name, entry)
179
- return entry
180
- }
181
-
182
- export function gestures(): readonly GestureEntry[] {
183
- return [...GESTURES.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
184
- }
185
-
186
- export function gestureFor(name: string): GestureEntry | undefined {
187
- return GESTURES.get(name)
188
- }
189
-
190
- export function isGesture(name: string): boolean {
191
- return GESTURES.has(name)
192
- }
193
-
194
- export function gestureNames(): readonly string[] {
195
- return gestures().map((g) => g.name)
196
- }
197
-
198
- export function recognise(name: string, frame: GestureFrame): Recognition {
199
- const entry = GESTURES.get(name)
200
- if (entry === undefined) {
201
- throw new Error(`unknown gesture "${name}". Registered: ${gestureNames().join(' · ') || '(none)'}`)
202
- }
203
- if (frame.pointers.length < entry.pointers) return IDLE
204
-
205
- for (const p of frame.pointers) {
206
- if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.startX) || !Number.isFinite(p.startY) || !Number.isFinite(p.held)) {
207
- return IDLE
208
- }
209
- }
210
- return entry.recognise(frame)
211
- }
212
-
213
- const distance = (dx: number, dy: number): number => Math.sqrt(dx * dx + dy * dy)
214
-
215
- const PRESS_SLOP = 10
216
-
217
- const PRESS_DWELL = 0.4
218
-
219
- const DRAG_THRESHOLD = 8
220
-
221
- const TAP_WITHIN = PRESS_DWELL
222
-
223
- const DOUBLE_TAP_GAP = 0.3
224
-
225
- const SWIPE_DISTANCE = 24
226
- const SWIPE_SPEED = 300
227
-
228
- function registerBuiltinGestures(): void {
229
- defineGesture({
230
- name: 'tap',
231
- means: 'One pointer, down and up quickly without moving. The gesture people perform most.',
232
- pointers: 1,
233
- produces: ['tapHeld'],
234
- raises: [{ name: 'tapped', means: 'A tap completed on this hit box — true for the one frame that recognised it.' }],
235
- recognise: ({ pointers, params }) => {
236
- const p = pointers[0]
237
- if (p === undefined) return IDLE
238
- if (distance(p.x - p.startX, p.y - p.startY) > (params['slop'] ?? PRESS_SLOP)) return IDLE
239
-
240
- if (p.down) return IDLE
241
- return p.held <= (params['within'] ?? TAP_WITHIN) ? { phase: 'done', values: { tapHeld: p.held } } : IDLE
242
- },
243
- })
244
-
245
- defineGesture({
246
- name: 'doubleTap',
247
- means: 'A tap whose predecessor was inside the gap. Zoom-to-fit, like, expand.',
248
- pointers: 1,
249
- produces: ['tapGap'],
250
- raises: [{ name: 'doubleTapped', means: 'A second tap landed inside the gap — true for the one frame that recognised it.' }],
251
- recognise: ({ pointers, params, sinceRelease }) => {
252
- const p = pointers[0]
253
- if (p === undefined || p.down) return IDLE
254
- if (distance(p.x - p.startX, p.y - p.startY) > (params['slop'] ?? PRESS_SLOP)) return IDLE
255
- if (p.held > (params['within'] ?? TAP_WITHIN)) return IDLE
256
-
257
- return sinceRelease <= (params['gap'] ?? DOUBLE_TAP_GAP) ? { phase: 'done', values: { tapGap: sinceRelease } } : IDLE
258
- },
259
- })
260
-
261
- defineGesture({
262
- name: 'press',
263
- means: 'One pointer, held in place past a dwell. Long-press is this with a longer `after`.',
264
- pointers: 1,
265
- produces: ['held'],
266
- raises: [{ name: 'pressing', means: 'A press has been recognised on this hit box — held past its dwell, not merely touched.' }],
267
- recognise: ({ pointers, params }) => {
268
- const p = pointers[0]
269
- if (p === undefined) return IDLE
270
-
271
- if (distance(p.x - p.startX, p.y - p.startY) > (params['slop'] ?? PRESS_SLOP)) return IDLE
272
- if (p.held < (params['after'] ?? PRESS_DWELL)) return IDLE
273
- return { phase: p.down ? 'active' : 'done', values: { held: p.held } }
274
- },
275
- })
276
-
277
- defineGesture({
278
- name: 'drag',
279
- means: 'One pointer, down and moved past a threshold. Stays active until it lifts.',
280
- pointers: 1,
281
- produces: ['dragX', 'dragY', 'dragDistance'],
282
- raises: [{ name: 'dragging', means: 'A drag has been recognised on this hit box and the pointer is still down.' }],
283
- recognise: ({ pointers, params }) => {
284
- const p = pointers[0]
285
- if (p === undefined) return IDLE
286
- const dx = p.x - p.startX
287
- const dy = p.y - p.startY
288
- const moved = distance(dx, dy)
289
-
290
- if (moved < (params['threshold'] ?? DRAG_THRESHOLD)) return IDLE
291
- return { phase: p.down ? 'active' : 'done', values: { dragX: dx, dragY: dy, dragDistance: moved } }
292
- },
293
- })
294
-
295
- defineGesture({
296
- name: 'swipe',
297
- means: 'One pointer, moved far and released FAST. Commits on velocity, so it needs no target.',
298
- pointers: 1,
299
- produces: ['swipeX', 'swipeY', 'swipeSpeed'],
300
- raises: [{ name: 'swiping', means: 'A swipe is under way — far enough to count, not yet released.' }],
301
- recognise: ({ pointers, params }) => {
302
- const p = pointers[0]
303
- if (p === undefined) return IDLE
304
- const dx = p.x - p.startX
305
- const dy = p.y - p.startY
306
- const travelled = distance(dx, dy)
307
- if (travelled < (params['threshold'] ?? SWIPE_DISTANCE)) return IDLE
308
- const speed = p.held > 0 ? travelled / p.held : 0
309
- const values = { swipeX: dx, swipeY: dy, swipeSpeed: speed }
310
- if (p.down) return { phase: 'active', values }
311
-
312
- return speed >= (params['speed'] ?? SWIPE_SPEED) ? { phase: 'done', values } : IDLE
313
- },
314
- })
315
-
316
- defineGesture({
317
- name: 'pinch',
318
- means: 'Two pointers changing their distance. Produces a scale relative to where they started.',
319
- pointers: 2,
320
- produces: ['pinchScale'],
321
- raises: [{ name: 'pinching', means: 'Two pointers on this hit box are changing their distance.' }],
322
- recognise: ({ pointers }) => {
323
- const [a, b] = pointers
324
- if (a === undefined || b === undefined) return IDLE
325
- const began = distance(a.startX - b.startX, a.startY - b.startY)
326
-
327
- if (began === 0) return IDLE
328
- const now = distance(a.x - b.x, a.y - b.y)
329
- return { phase: a.down && b.down ? 'active' : 'done', values: { pinchScale: now / began } }
330
- },
331
- })
332
-
333
- defineGesture({
334
- name: 'rotate',
335
- means: 'Two pointers changing their angle. Produces radians turned since they went down.',
336
- pointers: 2,
337
- produces: ['rotateAngle'],
338
- raises: [{ name: 'rotating', means: 'Two pointers on this hit box are changing their angle.' }],
339
- recognise: ({ pointers }) => {
340
- const [a, b] = pointers
341
- if (a === undefined || b === undefined) return IDLE
342
- const began = Math.atan2(a.startY - b.startY, a.startX - b.startX)
343
- const now = Math.atan2(a.y - b.y, a.x - b.x)
344
-
345
- let turned = now - began
346
- while (turned > Math.PI) turned -= 2 * Math.PI
347
- while (turned <= -Math.PI) turned += 2 * Math.PI
348
- return { phase: a.down && b.down ? 'active' : 'done', values: { rotateAngle: turned } }
349
- },
350
- })
351
- }
352
-
353
- registerBuiltinGestures()
354
-
355
- export interface SignalIssue {
356
- readonly path: string
357
- readonly message: string
358
- }
359
-
360
- export function checkInteractions(): readonly SignalIssue[] {
361
- const issues: SignalIssue[] = []
362
- const producers = new Map<string, string>()
363
-
364
- for (const gesture of gestures()) {
365
- for (const value of gesture.produces ?? []) {
366
- const owner = producers.get(value)
367
- if (owner !== undefined) {
368
- issues.push({
369
- path: `gestures.${gesture.name}.produces`,
370
- message: `"${value}" is produced by both "${owner}" and "${gesture.name}" — a driver reading it would get whichever recognised last`,
371
- })
372
- }
373
- producers.set(value, gesture.name)
374
- }
375
-
376
- for (const signal of gesture.raises) {
377
- if (isSignal(signal.name)) continue
378
- issues.push({
379
- path: `gestures.${gesture.name}.raises`,
380
- message: `"${signal.name}" is raised by "${gesture.name}" and no row on the signal sheet declares it`,
381
- })
382
- }
383
- }
384
-
385
- return issues
386
- }