@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,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())
|
package/dist/ui/modes.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/ui/provider.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/provider.js
CHANGED
|
@@ -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 });
|