@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.
- package/dist/ui/animations/anm_appear.ts +13 -0
- package/dist/ui/animations/anm_blur_in.ts +19 -0
- package/dist/ui/animations/anm_blur_out.ts +18 -0
- package/dist/ui/animations/anm_dismiss.ts +18 -0
- package/dist/ui/animations/anm_drop.ts +19 -0
- package/dist/ui/animations/anm_fall.ts +18 -0
- package/dist/ui/animations/anm_flash.ts +18 -0
- package/dist/ui/animations/anm_glow.ts +16 -0
- package/dist/ui/animations/anm_lift.ts +16 -0
- package/dist/ui/animations/anm_nudge.ts +16 -0
- package/dist/ui/animations/anm_pop_in.ts +23 -0
- package/dist/ui/animations/anm_pop_out.ts +22 -0
- package/dist/ui/animations/anm_press.ts +12 -0
- package/dist/ui/animations/anm_pulse.ts +13 -0
- package/dist/ui/animations/anm_rise.ts +19 -0
- package/dist/ui/animations/anm_shake.ts +13 -0
- package/dist/ui/animations/anm_slide_in_left.ts +19 -0
- package/dist/ui/animations/anm_slide_in_right.ts +19 -0
- package/dist/ui/animations/anm_slide_out_left.ts +18 -0
- package/dist/ui/animations/anm_slide_out_right.ts +18 -0
- package/dist/ui/animations/anm_spin.ts +12 -0
- package/dist/ui/animations/anm_swell.ts +16 -0
- package/dist/ui/animations/anm_wobble.ts +24 -0
- package/dist/ui/animations/anm_zoom_in.ts +19 -0
- package/dist/ui/animations/anm_zoom_out.ts +18 -0
- package/dist/ui/animations.ts +317 -0
- package/dist/ui/attach.js +13 -4
- package/dist/ui/attach.ts +613 -0
- package/dist/ui/charts.tsx +74 -0
- package/dist/ui/checked.d.ts +2 -2
- package/dist/ui/checked.ts +32 -0
- package/dist/ui/collector.ts +140 -0
- package/dist/ui/compositions.d.ts +10 -1
- package/dist/ui/compositions.js +262 -23
- package/dist/ui/compositions.ts +640 -0
- package/dist/ui/consent.ts +55 -0
- package/dist/ui/content.tsx +39 -0
- package/dist/ui/contrast.ts +27 -0
- package/dist/ui/controls.tsx +30 -0
- package/dist/ui/disclosure.tsx +22 -0
- package/dist/ui/dismiss-layer.ts +87 -0
- package/dist/ui/door.ts +140 -0
- package/dist/ui/drag.ts +316 -0
- package/dist/ui/elements/accordion.js +2 -2
- package/dist/ui/elements/accordion.tsx +47 -0
- package/dist/ui/elements/accordion.works.ts +66 -0
- package/dist/ui/elements/aspect-ratio.js +1 -1
- package/dist/ui/elements/aspect-ratio.tsx +30 -0
- package/dist/ui/elements/aspect-ratio.works.ts +5 -0
- package/dist/ui/elements/avatar.js +1 -1
- package/dist/ui/elements/avatar.tsx +33 -0
- package/dist/ui/elements/avatar.works.ts +16 -0
- package/dist/ui/elements/badge.js +1 -1
- package/dist/ui/elements/badge.tsx +32 -0
- package/dist/ui/elements/badge.works.ts +9 -0
- package/dist/ui/elements/bars.js +2 -2
- package/dist/ui/elements/bars.tsx +49 -0
- package/dist/ui/elements/bars.works.ts +29 -0
- package/dist/ui/elements/box.js +3 -2
- package/dist/ui/elements/box.tsx +35 -0
- package/dist/ui/elements/breadcrumb.js +2 -2
- package/dist/ui/elements/breadcrumb.tsx +37 -0
- package/dist/ui/elements/breadcrumb.works.ts +24 -0
- package/dist/ui/elements/button.js +1 -1
- package/dist/ui/elements/button.tsx +45 -0
- package/dist/ui/elements/button.works.tsx +52 -0
- package/dist/ui/elements/column.js +1 -1
- package/dist/ui/elements/column.tsx +28 -0
- package/dist/ui/elements/consent.js +2 -2
- package/dist/ui/elements/consent.tsx +70 -0
- package/dist/ui/elements/consent.works.tsx +132 -0
- package/dist/ui/elements/donut.js +2 -2
- package/dist/ui/elements/donut.tsx +44 -0
- package/dist/ui/elements/donut.works.ts +42 -0
- package/dist/ui/elements/each.js +1 -1
- package/dist/ui/elements/each.tsx +33 -0
- package/dist/ui/elements/each.works.tsx +82 -0
- package/dist/ui/elements/empty.js +1 -1
- package/dist/ui/elements/empty.tsx +32 -0
- package/dist/ui/elements/field.js +1 -1
- package/dist/ui/elements/field.tsx +44 -0
- package/dist/ui/elements/field.works.ts +65 -0
- package/dist/ui/elements/funnel.js +2 -2
- package/dist/ui/elements/funnel.tsx +49 -0
- package/dist/ui/elements/funnel.works.ts +37 -0
- package/dist/ui/elements/gauge.js +1 -1
- package/dist/ui/elements/gauge.tsx +39 -0
- package/dist/ui/elements/gauge.works.ts +42 -0
- package/dist/ui/elements/glyph.js +1 -1
- package/dist/ui/elements/glyph.tsx +29 -0
- package/dist/ui/elements/glyph.works.tsx +42 -0
- package/dist/ui/elements/grid.js +1 -1
- package/dist/ui/elements/grid.tsx +28 -0
- package/dist/ui/elements/heatmap.js +2 -2
- package/dist/ui/elements/heatmap.tsx +47 -0
- package/dist/ui/elements/heatmap.works.ts +24 -0
- package/dist/ui/elements/hit-box.js +1 -1
- package/dist/ui/elements/hit-box.tsx +46 -0
- package/dist/ui/elements/hit-box.works.tsx +576 -0
- package/dist/ui/elements/image.js +1 -1
- package/dist/ui/elements/image.tsx +28 -0
- package/dist/ui/elements/line-chart.js +2 -2
- package/dist/ui/elements/line-chart.tsx +44 -0
- package/dist/ui/elements/line-chart.works.ts +41 -0
- package/dist/ui/elements/link.js +1 -1
- package/dist/ui/elements/link.tsx +37 -0
- package/dist/ui/elements/link.works.ts +25 -0
- package/dist/ui/elements/media.js +1 -1
- package/dist/ui/elements/media.tsx +56 -0
- package/dist/ui/elements/menu.js +2 -2
- package/dist/ui/elements/menu.tsx +50 -0
- package/dist/ui/elements/menu.works.tsx +153 -0
- package/dist/ui/elements/money.js +1 -1
- package/dist/ui/elements/money.tsx +28 -0
- package/dist/ui/elements/money.works.ts +9 -0
- package/dist/ui/elements/pagination.js +2 -2
- package/dist/ui/elements/pagination.tsx +38 -0
- package/dist/ui/elements/pagination.works.ts +20 -0
- package/dist/ui/elements/progress.js +1 -1
- package/dist/ui/elements/progress.tsx +33 -0
- package/dist/ui/elements/progress.works.ts +8 -0
- package/dist/ui/elements/radio-group.js +2 -2
- package/dist/ui/elements/radio-group.tsx +49 -0
- package/dist/ui/elements/radio-group.works.ts +33 -0
- package/dist/ui/elements/row.js +1 -1
- package/dist/ui/elements/row.tsx +28 -0
- package/dist/ui/elements/scatter.js +1 -1
- package/dist/ui/elements/scatter.tsx +41 -0
- package/dist/ui/elements/scatter.works.ts +38 -0
- package/dist/ui/elements/select.js +2 -2
- package/dist/ui/elements/select.tsx +42 -0
- package/dist/ui/elements/select.works.ts +5 -0
- package/dist/ui/elements/separator.js +1 -1
- package/dist/ui/elements/separator.tsx +30 -0
- package/dist/ui/elements/skeleton.js +1 -1
- package/dist/ui/elements/skeleton.tsx +30 -0
- package/dist/ui/elements/slider.js +1 -1
- package/dist/ui/elements/slider.tsx +33 -0
- package/dist/ui/elements/slider.works.ts +5 -0
- package/dist/ui/elements/sparkline.js +1 -1
- package/dist/ui/elements/sparkline.tsx +36 -0
- package/dist/ui/elements/sparkline.works.ts +32 -0
- package/dist/ui/elements/spinner.js +1 -1
- package/dist/ui/elements/spinner.tsx +32 -0
- package/dist/ui/elements/stack.js +1 -1
- package/dist/ui/elements/stack.tsx +28 -0
- package/dist/ui/elements/stat.js +1 -1
- package/dist/ui/elements/stat.tsx +35 -0
- package/dist/ui/elements/stat.works.ts +14 -0
- package/dist/ui/elements/surface.js +1 -1
- package/dist/ui/elements/surface.tsx +35 -0
- package/dist/ui/elements/surface.works.ts +9 -0
- package/dist/ui/elements/table.js +2 -2
- package/dist/ui/elements/table.tsx +61 -0
- package/dist/ui/elements/table.works.ts +30 -0
- package/dist/ui/elements/tabs.js +2 -2
- package/dist/ui/elements/tabs.tsx +47 -0
- package/dist/ui/elements/tabs.works.ts +84 -0
- package/dist/ui/elements/text.js +1 -1
- package/dist/ui/elements/text.tsx +32 -0
- package/dist/ui/elements/textarea.js +1 -1
- package/dist/ui/elements/textarea.tsx +37 -0
- package/dist/ui/elements/textarea.works.ts +5 -0
- package/dist/ui/elements/time.js +1 -1
- package/dist/ui/elements/time.tsx +34 -0
- package/dist/ui/elements/time.works.ts +25 -0
- package/dist/ui/elements/toggle.js +1 -1
- package/dist/ui/elements/toggle.tsx +33 -0
- package/dist/ui/elements/toggle.works.ts +5 -0
- package/dist/ui/elements/tooltip.js +1 -1
- package/dist/ui/elements/tooltip.tsx +39 -0
- package/dist/ui/elements/tooltip.works.ts +77 -0
- package/dist/ui/elements/value.js +1 -1
- package/dist/ui/elements/value.tsx +30 -0
- package/dist/ui/elements/value.works.ts +9 -0
- package/dist/ui/elements.ts +127 -0
- package/dist/ui/focus-scope.ts +121 -0
- package/dist/ui/fonts.ts +355 -0
- package/dist/ui/generators/dev.ts +79 -0
- package/dist/ui/generators/elements.d.ts +1 -11
- package/dist/ui/generators/elements.js +43 -13
- package/dist/ui/generators/elements.ts +794 -0
- package/dist/ui/generators/i18n.ts +301 -0
- package/dist/ui/generators/icons.ts +214 -0
- package/dist/ui/generators/react.js +10 -3
- package/dist/ui/generators/react.ts +268 -0
- package/dist/ui/generators/routes.d.ts +1 -0
- package/dist/ui/generators/routes.js +89 -69
- package/dist/ui/generators/routes.ts +739 -0
- package/dist/ui/generators/show.ts +259 -0
- package/dist/ui/generators/styles.js +1 -1
- package/dist/ui/generators/styles.ts +670 -0
- package/dist/ui/generators/variants.ts +205 -0
- package/dist/ui/i18n/catalogs/en.ts +191 -0
- package/dist/ui/i18n/format.ts +221 -0
- package/dist/ui/i18n/locales.ts +33 -0
- package/dist/ui/i18n/parse.ts +315 -0
- package/dist/ui/i18n/translator.ts +31 -0
- package/dist/ui/i18n/types.ts +96 -0
- package/dist/ui/icons.ts +331 -0
- package/dist/ui/islands/map.ts +58 -0
- package/dist/ui/localizations.ts +75 -0
- package/dist/ui/makers/message.ts +292 -0
- package/dist/ui/material.ts +236 -0
- package/dist/ui/materials/mat_bordered.ts +13 -0
- package/dist/ui/materials/mat_ceramic.ts +13 -0
- package/dist/ui/materials/mat_clay.ts +13 -0
- package/dist/ui/materials/mat_elevated.ts +24 -0
- package/dist/ui/materials/mat_flat.ts +34 -0
- package/dist/ui/materials/mat_glass.ts +20 -0
- package/dist/ui/materials/mat_lit.ts +13 -0
- package/dist/ui/materials/mat_moulded.ts +11 -0
- package/dist/ui/materials.ts +206 -0
- package/dist/ui/modes.ts +44 -0
- package/dist/ui/navigation.tsx +32 -0
- package/dist/ui/positioning.ts +90 -0
- package/dist/ui/presented.tsx +119 -0
- package/dist/ui/provider.d.ts +2 -0
- package/dist/ui/provider.js +7 -1
- package/dist/ui/provider.tsx +182 -0
- package/dist/ui/render-loop.ts +133 -0
- package/dist/ui/roving-focus.ts +161 -0
- package/dist/ui/screens.js +17 -8
- package/dist/ui/screens.ts +142 -0
- package/dist/ui/scroll-lock.ts +28 -0
- package/dist/ui/series.ts +97 -0
- package/dist/ui/signals.ts +386 -0
- package/dist/ui/styleguide.ts +231 -0
- package/dist/ui/styleguides.ts +374 -0
- package/dist/ui/themes.ts +22 -0
- package/dist/ui/token.js +1 -1
- package/dist/ui/token.ts +78 -0
- package/dist/ui/tokens/shadow.ts +25 -0
- package/dist/ui/tokens.ts +235 -0
- package/dist/ui/validators.ts +232 -0
- package/dist/ui/variant.d.ts +1 -0
- package/dist/ui/variant.js +3 -0
- package/dist/ui/variant.ts +352 -0
- package/dist/ui/variants/var_avatar-round.ts +14 -0
- package/dist/ui/variants/var_bubble-me.ts +14 -0
- package/dist/ui/variants/var_bubble-them.ts +13 -0
- package/dist/ui/variants/var_button-border.ts +76 -0
- package/dist/ui/variants/var_button-cta.ts +76 -0
- package/dist/ui/variants/var_button-destructive-ghost.ts +72 -0
- package/dist/ui/variants/var_button-destructive.ts +76 -0
- package/dist/ui/variants/var_button-fill.ts +76 -0
- package/dist/ui/variants/var_button-ghost.ts +72 -0
- package/dist/ui/variants/var_button-glass.ts +55 -0
- package/dist/ui/variants/var_button-normal.ts +76 -0
- package/dist/ui/variants/var_button-quiet.ts +74 -0
- package/dist/ui/variants/var_card.ts +17 -0
- package/dist/ui/variants/var_chip.ts +13 -0
- package/dist/ui/variants/var_choice.ts +15 -0
- package/dist/ui/variants/var_framed.ts +10 -0
- package/dist/ui/variants/var_glyph-plain.ts +11 -0
- package/dist/ui/variants/var_input.ts +43 -0
- package/dist/ui/variants/var_link-inline.ts +29 -0
- package/dist/ui/variants/var_list-row.ts +34 -0
- package/dist/ui/variants/var_rule.ts +10 -0
- package/dist/ui/variants/var_skeleton-block.ts +12 -0
- package/dist/ui/variants/var_spinner-turning.ts +12 -0
- package/dist/ui/variants/var_stat-tile.ts +13 -0
- package/dist/ui/variants/var_tab-bar.ts +15 -0
- package/dist/ui/variants/var_tap-target.ts +25 -0
- package/dist/ui/variants.ts +64 -0
- package/dist/ui/wrappers.d.ts +4 -1
- package/dist/ui/wrappers.js +60 -20
- package/dist/ui/wrappers.tsx +426 -0
- package/package.json +8 -2
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
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
|
+
}
|