@abide/abide 0.101.0 → 0.103.0
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/CHANGELOG.md +26 -0
- package/bin/abide.ts +226 -164
- package/docs/REGISTRY.md +730 -0
- package/docs/RULEBOOK.md +2768 -0
- package/docs/abide.md +13127 -0
- package/package.json +6 -3
- package/scaffold/AGENTS.md +38 -0
- package/scaffold/CLAUDE.md +1 -0
- package/scaffold/src/layout.abide +18 -0
- package/src/compiler/Lowering.ts +78 -9
- package/src/compiler/ShadowMap.ts +2 -2
- package/src/compiler/TypeSession.ts +341 -71
- package/src/compiler/appMap.ts +96 -0
- package/src/compiler/argsSchemaOf.ts +1 -1
- package/src/compiler/checkApp.ts +53 -8
- package/src/compiler/checkedShadow.ts +73 -6
- package/src/compiler/classifyRead.ts +6 -4
- package/src/compiler/classifyTemplate.ts +1 -1
- package/src/compiler/compileApp.ts +598 -200
- package/src/compiler/compiledEntry.ts +48 -40
- package/src/compiler/componentGraph.ts +57 -71
- package/src/compiler/declaredExports.ts +26 -9
- package/src/compiler/emitClient.ts +142 -72
- package/src/compiler/emitComponent.ts +41 -12
- package/src/compiler/emitSource.ts +102 -13
- package/src/compiler/handlersUnder.ts +164 -76
- package/src/compiler/hooksCalled.ts +35 -0
- package/src/compiler/index.ts +7 -0
- package/src/compiler/mountsOf.ts +150 -0
- package/src/compiler/nearest.ts +14 -0
- package/src/compiler/parseAbide.ts +157 -20
- package/src/compiler/projectShadow.ts +250 -19
- package/src/compiler/reactiveTypes.ts +20 -7
- package/src/compiler/readSources.ts +15 -2
- package/src/compiler/readsIn.ts +62 -8
- package/src/compiler/renderMarkup.ts +3 -3
- package/src/compiler/scaffold.ts +37 -15
- package/src/compiler/serveLanguage.ts +101 -34
- package/src/compiler/sharedModuleWrites.ts +2 -2
- package/src/compiler/sourceMapOf.ts +193 -0
- package/src/compiler/splitImports.ts +205 -83
- package/src/compiler/tokenize.ts +14 -0
- package/src/compiler/writeDefaultProject.ts +29 -2
- package/src/server/CLIENT_BUILD.ts +11 -13
- package/src/server/DEFAULT_PORT.ts +1 -1
- package/src/server/MODULE_QUERIES.ts +27 -0
- package/src/server/TABLES.ts +7 -0
- package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
- package/src/server/abideModules.ts +457 -0
- package/src/server/answerFailure.ts +16 -6
- package/src/server/answerRequest.ts +241 -107
- package/src/server/answerRpc.ts +107 -33
- package/src/server/answerSocket.ts +65 -20
- package/src/server/answerValue.ts +22 -9
- package/src/server/appDirectories.ts +1 -1
- package/src/server/appPlugins.ts +1 -1
- package/src/server/appTree.ts +238 -0
- package/src/server/argsFor.ts +2 -2
- package/src/server/assistant.ts +2 -2
- package/src/server/bridgeRelay.ts +6 -8
- package/src/server/claudeCode.ts +0 -6
- package/src/server/clientBuildOptions.ts +8 -3
- package/src/server/coerceArgs.ts +1 -1
- package/src/server/commands.ts +38 -37
- package/src/server/componentModules.d.ts +2 -1
- package/src/server/compressAnswer.ts +4 -4
- package/src/server/config.ts +25 -29
- package/src/server/configFields.ts +31 -25
- package/src/server/createApp.ts +77 -79
- package/src/server/devWorker.ts +118 -181
- package/src/server/executableName.ts +4 -0
- package/src/server/fenceServer.ts +57 -32
- package/src/server/generatedSurfaces.ts +99 -66
- package/src/server/hooks.ts +137 -52
- package/src/server/iconAccent.ts +191 -0
- package/src/server/index.ts +12 -7
- package/src/server/installSourceMaps.ts +93 -0
- package/src/server/launcherOptions.ts +33 -31
- package/src/server/launcherPage.ts +107 -64
- package/src/server/manifestOf.ts +16 -13
- package/src/server/namesUnder.ts +2 -2
- package/src/server/nativeMenu.ts +6 -6
- package/src/server/negotiateEncoding.ts +1 -1
- package/src/server/preload.ts +13 -0
- package/src/server/principal.ts +10 -5
- package/src/server/realOrAsGiven.ts +10 -0
- package/src/server/refuseAddressCollision.ts +21 -14
- package/src/server/remote.ts +49 -52
- package/src/server/render.ts +65 -94
- package/src/server/renderComponent.ts +5 -0
- package/src/server/reportHealth.ts +2 -2
- package/src/server/requestScope.ts +12 -1
- package/src/server/runBuild.ts +24 -0
- package/src/server/runCompiled.ts +5 -5
- package/src/server/runDesktop.ts +48 -28
- package/src/server/serveBuild.ts +43 -52
- package/src/server/serveDev.ts +79 -37
- package/src/server/shell.ts +7 -22
- package/src/server/sourceMappings.ts +139 -0
- package/src/server/surfaceOf.ts +20 -3
- package/src/server/testBuild.ts +1 -19
- package/src/server/withAbideSources.ts +117 -0
- package/src/shared/ADDRESSES.ts +10 -18
- package/src/shared/APP_META.ts +7 -0
- package/src/shared/CopyOptions.ts +1 -1
- package/src/shared/DEBUG_META.ts +1 -1
- package/src/shared/LAUNCHER_FILES.ts +9 -0
- package/src/shared/RESERVED_DIRECTORIES.ts +24 -0
- package/src/shared/cookies.ts +2 -2
- package/src/shared/csp.ts +1 -1
- package/src/shared/escapeText.ts +1 -1
- package/src/shared/filledPath.ts +85 -0
- package/src/shared/framing.ts +79 -6
- package/src/shared/headKeyOf.ts +2 -0
- package/src/shared/index.ts +2 -1
- package/src/shared/log.ts +27 -13
- package/src/shared/matchRoute.ts +23 -4
- package/src/{ui → shared}/menuBridge.ts +2 -2
- package/src/shared/menuItem.ts +4 -4
- package/src/shared/onMenu.ts +58 -0
- package/src/shared/reactive/Keyed.ts +2 -2
- package/src/shared/reactive/NotLanded.ts +1 -1
- package/src/shared/reactive/Reactive.ts +43 -43
- package/src/shared/reactive/argsContract.ts +4 -4
- package/src/shared/reactive/canonicalKey.ts +4 -4
- package/src/shared/reactive/chunksOfStream.ts +6 -0
- package/src/shared/reactive/index.ts +1 -1
- package/src/shared/reactive/recordWrites.ts +103 -5
- package/src/shared/reactive/structural.ts +1 -1
- package/src/shared/reactive/tracking.ts +1 -1
- package/src/shared/reactive/watch.ts +3 -3
- package/src/shared/refuse.ts +1 -1
- package/src/{ui → shared}/reportMenu.ts +8 -10
- package/src/shared/routePattern.ts +122 -48
- package/src/shared/rpc.ts +37 -8
- package/src/shared/rpcRequest.ts +27 -3
- package/src/shared/url.ts +4 -44
- package/src/shared/writePath.ts +1 -1
- package/src/ui/LAYER_NAMES.ts +1 -1
- package/src/ui/List.ts +3 -3
- package/src/ui/MOUNTING_LEVEL.ts +18 -0
- package/src/ui/Mismatch.ts +1 -1
- package/src/ui/RawRun.ts +1 -1
- package/src/ui/Router.ts +37 -36
- package/src/ui/Scope.ts +1 -1
- package/src/ui/TryRegion.ts +2 -2
- package/src/ui/applyHead.ts +33 -0
- package/src/ui/index.ts +1 -1
- package/src/ui/moveNode.ts +1 -1
- package/src/ui/reconcile.ts +2 -2
- package/src/ui/routing.ts +2 -2
- package/src/ui/setAttribute.ts +1 -1
- package/src/ui/setText.ts +1 -1
- package/src/ui/socketWire.ts +9 -7
- package/src/ui/wire.ts +1 -1
- package/scaffold/src/server/app.ts +0 -6
- package/scaffold/src/shared/.gitkeep +0 -0
- package/scaffold/src/ui/app.html +0 -8
- package/src/server/componentImports.ts +0 -56
- /package/scaffold/src/{ui/pages/page.abide → index.abide} +0 -0
- /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// 38.101 — THE LAUNCHER'S ACCENT, TAKEN FROM THE APP'S ICON. The icon is the one thing every desktop
|
|
2
|
+
// app already has that says what it looks like, so the launcher reads its color off it rather than
|
|
3
|
+
// asking for a theme. Read once by `abide bundle`, from the icon drawn small, and carried in the
|
|
4
|
+
// executable as a color.
|
|
5
|
+
//
|
|
6
|
+
// THE MOST SATURATED HUE, NOT THE AVERAGE: an icon is usually a mark on a ground, and the mean of a
|
|
7
|
+
// teal stroke on a near-black square is a muddy gray neither of them is.
|
|
8
|
+
|
|
9
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
|
|
10
|
+
const HUE_BINS = 24
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The accent color of the PNG `png`, as `#rrggbb`, or `undefined` where it has no color to give —
|
|
14
|
+
* a gray icon, or a PNG this reads no pixels of (8-bit RGB or RGBA, not interlaced).
|
|
15
|
+
*/
|
|
16
|
+
export function iconAccent(png: Uint8Array): string | undefined {
|
|
17
|
+
const pixels = decoded(png)
|
|
18
|
+
if (pixels === undefined) return undefined
|
|
19
|
+
const weights = new Float64Array(HUE_BINS)
|
|
20
|
+
const sums = new Float64Array(HUE_BINS * 3)
|
|
21
|
+
for (let at = 0; at < pixels.data.length; at += pixels.channels) {
|
|
22
|
+
const alpha =
|
|
23
|
+
pixels.channels === 4 ? (pixels.data[at + 3] as number) : 255
|
|
24
|
+
if (alpha < 128) continue
|
|
25
|
+
const red = pixels.data[at] as number
|
|
26
|
+
const green = pixels.data[at + 1] as number
|
|
27
|
+
const blue = pixels.data[at + 2] as number
|
|
28
|
+
const [hue, saturation, lightness] = hsl(red, green, blue)
|
|
29
|
+
if (saturation < 0.25 || lightness < 0.12 || lightness > 0.92) continue
|
|
30
|
+
const bin = Math.floor((hue / 360) * HUE_BINS) % HUE_BINS
|
|
31
|
+
const weight = saturation * (alpha / 255)
|
|
32
|
+
weights[bin] = (weights[bin] as number) + weight
|
|
33
|
+
sums[bin * 3] = (sums[bin * 3] as number) + red * weight
|
|
34
|
+
sums[bin * 3 + 1] = (sums[bin * 3 + 1] as number) + green * weight
|
|
35
|
+
sums[bin * 3 + 2] = (sums[bin * 3 + 2] as number) + blue * weight
|
|
36
|
+
}
|
|
37
|
+
let best = -1
|
|
38
|
+
for (let bin = 0; bin < HUE_BINS; bin += 1)
|
|
39
|
+
if (
|
|
40
|
+
(weights[bin] as number) > 0 &&
|
|
41
|
+
(best === -1 ||
|
|
42
|
+
(weights[bin] as number) > (weights[best] as number))
|
|
43
|
+
)
|
|
44
|
+
best = bin
|
|
45
|
+
if (best === -1) return undefined
|
|
46
|
+
const weight = weights[best] as number
|
|
47
|
+
const [hue, saturation, lightness] = hsl(
|
|
48
|
+
(sums[best * 3] as number) / weight,
|
|
49
|
+
(sums[best * 3 + 1] as number) / weight,
|
|
50
|
+
(sums[best * 3 + 2] as number) / weight,
|
|
51
|
+
)
|
|
52
|
+
// A button's fill: dark enough to carry white text, light enough to read as a color.
|
|
53
|
+
return hex(
|
|
54
|
+
hue,
|
|
55
|
+
Math.min(saturation, 0.85),
|
|
56
|
+
Math.min(Math.max(lightness, 0.32), 0.5),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `#rrggbb` text that reads on `accent`: near-black on a light one, white on a dark one. */
|
|
61
|
+
export function inkOn(accent: string): string {
|
|
62
|
+
const channel = (offset: number): number => {
|
|
63
|
+
const value =
|
|
64
|
+
Number.parseInt(accent.slice(offset, offset + 2), 16) / 255
|
|
65
|
+
return value <= 0.04045
|
|
66
|
+
? value / 12.92
|
|
67
|
+
: ((value + 0.055) / 1.055) ** 2.4
|
|
68
|
+
}
|
|
69
|
+
const luminance =
|
|
70
|
+
0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)
|
|
71
|
+
return luminance > 0.4 ? '#1d1d1f' : '#ffffff'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decoded(
|
|
75
|
+
png: Uint8Array,
|
|
76
|
+
): { data: Uint8Array; channels: number } | undefined {
|
|
77
|
+
for (let at = 0; at < SIGNATURE.length; at += 1)
|
|
78
|
+
if (png[at] !== SIGNATURE[at]) return undefined
|
|
79
|
+
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
|
80
|
+
let width = 0
|
|
81
|
+
let height = 0
|
|
82
|
+
let channels = 0
|
|
83
|
+
const compressed: Uint8Array[] = []
|
|
84
|
+
let length = 0
|
|
85
|
+
for (let at = 8; at + 8 <= png.length; ) {
|
|
86
|
+
const size = view.getUint32(at)
|
|
87
|
+
const type = String.fromCharCode(...png.subarray(at + 4, at + 8))
|
|
88
|
+
const body = png.subarray(at + 8, at + 8 + size)
|
|
89
|
+
if (type === 'IHDR') {
|
|
90
|
+
width = view.getUint32(at + 8)
|
|
91
|
+
height = view.getUint32(at + 12)
|
|
92
|
+
const depth = body[8]
|
|
93
|
+
const color = body[9]
|
|
94
|
+
const interlaced = body[12]
|
|
95
|
+
if (depth !== 8 || interlaced !== 0) return undefined
|
|
96
|
+
if (color === 6) channels = 4
|
|
97
|
+
else if (color === 2) channels = 3
|
|
98
|
+
else return undefined
|
|
99
|
+
} else if (type === 'IDAT') {
|
|
100
|
+
compressed.push(body)
|
|
101
|
+
length += body.length
|
|
102
|
+
} else if (type === 'IEND') break
|
|
103
|
+
at += 12 + size
|
|
104
|
+
}
|
|
105
|
+
if (channels === 0) return undefined
|
|
106
|
+
const joined = new Uint8Array(length)
|
|
107
|
+
let offset = 0
|
|
108
|
+
for (const part of compressed) {
|
|
109
|
+
joined.set(part, offset)
|
|
110
|
+
offset += part.length
|
|
111
|
+
}
|
|
112
|
+
const raw = Bun.inflateSync(joined, { windowBits: 15 })
|
|
113
|
+
const stride = width * channels
|
|
114
|
+
const data = new Uint8Array(stride * height)
|
|
115
|
+
for (let row = 0; row < height; row += 1) {
|
|
116
|
+
const filter = raw[row * (stride + 1)] as number
|
|
117
|
+
const from = row * (stride + 1) + 1
|
|
118
|
+
const to = row * stride
|
|
119
|
+
for (let column = 0; column < stride; column += 1) {
|
|
120
|
+
const value = raw[from + column] as number
|
|
121
|
+
const left =
|
|
122
|
+
column >= channels
|
|
123
|
+
? (data[to + column - channels] as number)
|
|
124
|
+
: 0
|
|
125
|
+
const up = row > 0 ? (data[to - stride + column] as number) : 0
|
|
126
|
+
const corner =
|
|
127
|
+
row > 0 && column >= channels
|
|
128
|
+
? (data[to - stride + column - channels] as number)
|
|
129
|
+
: 0
|
|
130
|
+
let predicted = 0
|
|
131
|
+
if (filter === 1) predicted = left
|
|
132
|
+
else if (filter === 2) predicted = up
|
|
133
|
+
else if (filter === 3) predicted = (left + up) >> 1
|
|
134
|
+
else if (filter === 4) predicted = paeth(left, up, corner)
|
|
135
|
+
data[to + column] = (value + predicted) & 255
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { data, channels }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function paeth(left: number, up: number, corner: number): number {
|
|
142
|
+
const estimate = left + up - corner
|
|
143
|
+
const toLeft = Math.abs(estimate - left)
|
|
144
|
+
const toUp = Math.abs(estimate - up)
|
|
145
|
+
const toCorner = Math.abs(estimate - corner)
|
|
146
|
+
if (toLeft <= toUp && toLeft <= toCorner) return left
|
|
147
|
+
return toUp <= toCorner ? up : corner
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hsl(
|
|
151
|
+
red: number,
|
|
152
|
+
green: number,
|
|
153
|
+
blue: number,
|
|
154
|
+
): [number, number, number] {
|
|
155
|
+
const r = red / 255
|
|
156
|
+
const g = green / 255
|
|
157
|
+
const b = blue / 255
|
|
158
|
+
const max = Math.max(r, g, b)
|
|
159
|
+
const min = Math.min(r, g, b)
|
|
160
|
+
const lightness = (max + min) / 2
|
|
161
|
+
if (max === min) return [0, 0, lightness]
|
|
162
|
+
const delta = max - min
|
|
163
|
+
const saturation =
|
|
164
|
+
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
|
165
|
+
let hue: number
|
|
166
|
+
if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0)
|
|
167
|
+
else if (max === g) hue = (b - r) / delta + 2
|
|
168
|
+
else hue = (r - g) / delta + 4
|
|
169
|
+
return [hue * 60, saturation, lightness]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hex(hue: number, saturation: number, lightness: number): string {
|
|
173
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
|
|
174
|
+
const part = chroma * (1 - Math.abs(((hue / 60) % 2) - 1))
|
|
175
|
+
const base = lightness - chroma / 2
|
|
176
|
+
const sector = Math.floor(hue / 60) % 6
|
|
177
|
+
const [r, g, b] = [
|
|
178
|
+
[chroma, part, 0],
|
|
179
|
+
[part, chroma, 0],
|
|
180
|
+
[0, chroma, part],
|
|
181
|
+
[0, part, chroma],
|
|
182
|
+
[part, 0, chroma],
|
|
183
|
+
[chroma, 0, part],
|
|
184
|
+
][sector] as [number, number, number]
|
|
185
|
+
let out = '#'
|
|
186
|
+
for (const channel of [r, g, b])
|
|
187
|
+
out += Math.round((channel + base) * 255)
|
|
188
|
+
.toString(16)
|
|
189
|
+
.padStart(2, '0')
|
|
190
|
+
return out
|
|
191
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { installServer } from './installServer.ts'
|
|
|
13
13
|
// emitted module reaches through here: 32.1's escaping and 32.2's `raw`, which both sides render
|
|
14
14
|
// markup with, 32.20's `safeUrl`, and 31.8's `writePath`.
|
|
15
15
|
export * from '#shared'
|
|
16
|
+
// 18.11 — one write a patch made, as a followed `GET`'s `patch` carries it.
|
|
17
|
+
export type { PatchOp } from '#shared/reactive/PATCH_SINKS.ts'
|
|
16
18
|
export {
|
|
17
19
|
type Clients,
|
|
18
20
|
DELETE,
|
|
@@ -23,8 +25,6 @@ export {
|
|
|
23
25
|
type Rpc,
|
|
24
26
|
type RpcOptions,
|
|
25
27
|
} from '#shared/rpc.ts'
|
|
26
|
-
// 18.11 — one write a patch made, as a followed `GET`'s `patch` carries it.
|
|
27
|
-
export type { PatchOp } from '#shared/reactive/PATCH_SINKS.ts'
|
|
28
28
|
// 45 — a conversation with a model, piped through a handler, and the two providers abide ships.
|
|
29
29
|
export {
|
|
30
30
|
type AssistantEvent,
|
|
@@ -43,26 +43,31 @@ export {
|
|
|
43
43
|
} from './claudeCode.ts'
|
|
44
44
|
// 27 — the resolved configuration.
|
|
45
45
|
export { type Config, config } from './config.ts'
|
|
46
|
-
//
|
|
46
|
+
// 38.82 — the props `src/launcher.abide` is rendered with.
|
|
47
|
+
export type { ConfigField } from './configFields.ts'
|
|
48
|
+
// 42 — the app object, and 37's hooks a `<script server>` calls.
|
|
47
49
|
export { type App, createApp } from './createApp.ts'
|
|
50
|
+
// 37.1's run-time half: what a compiled server module calls around its `<script server>`, so the
|
|
51
|
+
// hooks it makes are filed at its route.
|
|
48
52
|
export {
|
|
49
53
|
type ConfigDefaults,
|
|
50
54
|
type ConfigOptions,
|
|
51
55
|
type Env,
|
|
56
|
+
fileRoute,
|
|
52
57
|
middleware,
|
|
53
58
|
onConfig,
|
|
54
59
|
onError,
|
|
55
60
|
onHealth,
|
|
61
|
+
onLauncher,
|
|
56
62
|
onPrincipal,
|
|
57
63
|
onStart,
|
|
58
64
|
onStop,
|
|
59
65
|
} from './hooks.ts'
|
|
66
|
+
export type { LauncherOptions } from './launcherOptions.ts'
|
|
67
|
+
export type { Launch } from './launcherPage.ts'
|
|
60
68
|
// 23.10 — whether a host answers, asked before a call to it.
|
|
61
69
|
export { reachable } from './reachable.ts'
|
|
62
|
-
export { type Component, render
|
|
63
|
-
// 38.83 — the props `src/bundle/launch.abide` is rendered with.
|
|
64
|
-
export type { ConfigField } from './configFields.ts'
|
|
65
|
-
export type { Launch } from './launcherPage.ts'
|
|
70
|
+
export { type Component, render } from './render.ts'
|
|
66
71
|
// 32.17's run-time half: one invocation, rendered where it stands, with the anchors that make the
|
|
67
72
|
// child's markup one thing for the client to walk to.
|
|
68
73
|
export { renderComponent } from './renderComponent.ts'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// 38.97 — A STACK FRAME IN A MODULE COMPILED FROM A `.abide` NAMES THE `.abide`. Bun reads no
|
|
2
|
+
// source map it is handed — not inline in a module a plugin serves, not on disk, not in the
|
|
3
|
+
// bundler — so the frame is placed here, through `Error.prepareStackTrace`, which bun calls for
|
|
4
|
+
// every `stack` read. Every other frame is written as bun writes it.
|
|
5
|
+
//
|
|
6
|
+
// INSTALLED ONCE PER PROCESS, the maps added to as apps boot: `abide dev` boots one worker per
|
|
7
|
+
// rebuild, and a test boots several apps in one process.
|
|
8
|
+
|
|
9
|
+
import { resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
10
|
+
import { HANDLER_FACE } from './MODULE_QUERIES.ts'
|
|
11
|
+
import { decodeMappings, placeAt, type Segment } from './sourceMappings.ts'
|
|
12
|
+
|
|
13
|
+
/** Per `.abide` below the root, as its server module is served there: the file, and its `mappings`. */
|
|
14
|
+
export type SourceMaps = Record<string, readonly [string, string]>
|
|
15
|
+
|
|
16
|
+
type Registered = {
|
|
17
|
+
source: string
|
|
18
|
+
mappings: string
|
|
19
|
+
lines: Segment[][] | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** By the `.abide`'s path, which a frame in its served module names. */
|
|
23
|
+
const REGISTERED = new Map<string, Registered>()
|
|
24
|
+
/** An executable's files by their path below the root, for its frames, whose directory is not known. */
|
|
25
|
+
const BELOW = new Map<string, Registered>()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Registers `maps`, the files below `base`, and places frames in them from now on.
|
|
29
|
+
*
|
|
30
|
+
* `base` is `undefined` for an executable, whose frames name each file as it was bundled.
|
|
31
|
+
*/
|
|
32
|
+
export function installSourceMaps(
|
|
33
|
+
maps: SourceMaps,
|
|
34
|
+
base: string | undefined,
|
|
35
|
+
): void {
|
|
36
|
+
for (const [module, [source, mappings]] of Object.entries(maps)) {
|
|
37
|
+
const registered = { source, mappings, lines: undefined }
|
|
38
|
+
if (base !== undefined)
|
|
39
|
+
REGISTERED.set(resolve(base, module), registered)
|
|
40
|
+
else BELOW.set(module, registered)
|
|
41
|
+
}
|
|
42
|
+
if (Error.prepareStackTrace !== placeFrames)
|
|
43
|
+
Error.prepareStackTrace = placeFrames
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type CallSite = {
|
|
47
|
+
getFileName(): string | undefined | null
|
|
48
|
+
getLineNumber(): number | null
|
|
49
|
+
getColumnNumber(): number | null
|
|
50
|
+
getFunctionName(): string | null
|
|
51
|
+
isAsync?(): boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function placeFrames(error: Error, frames: CallSite[]): string {
|
|
55
|
+
let out = `${error.name}: ${error.message}`
|
|
56
|
+
if (error.message === '' || error.message === undefined) out = error.name
|
|
57
|
+
for (const frame of frames) {
|
|
58
|
+
const name = frame.getFunctionName()
|
|
59
|
+
const where = placed(frame)
|
|
60
|
+
out += `\n at ${frame.isAsync?.() ? 'async ' : ''}${name ? `${name} (${where})` : where}`
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A call site's column counts from 0 where the stack bun writes counts from 1.
|
|
66
|
+
function placed(frame: CallSite): string {
|
|
67
|
+
// A handler file's frames name the file the author wrote, not the face it is served as.
|
|
68
|
+
const file = (frame.getFileName() ?? '<anonymous>').replace(
|
|
69
|
+
HANDLER_FACE,
|
|
70
|
+
'',
|
|
71
|
+
)
|
|
72
|
+
const line = frame.getLineNumber() ?? 0
|
|
73
|
+
const column = frame.getColumnNumber() ?? 0
|
|
74
|
+
const registered = registeredFor(file)
|
|
75
|
+
if (registered !== undefined && line > 0) {
|
|
76
|
+
registered.lines ??= decodeMappings(registered.mappings)
|
|
77
|
+
const place = placeAt(registered.lines, line - 1, column)
|
|
78
|
+
if (place !== undefined)
|
|
79
|
+
return `${registered.source}:${place[0] + 1}:${place[1] + 1}`
|
|
80
|
+
}
|
|
81
|
+
return `${file}:${line}:${column + 1}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function registeredFor(path: string): Registered | undefined {
|
|
85
|
+
const found = REGISTERED.get(path)
|
|
86
|
+
if (found !== undefined) return found
|
|
87
|
+
// An executable's frames name the file as bun's own map does, relative to where it was
|
|
88
|
+
// bundled from — so it is found by the path below the root it ends with.
|
|
89
|
+
if (path.startsWith('/')) return undefined
|
|
90
|
+
for (const [module, registered] of BELOW)
|
|
91
|
+
if (path === module || path.endsWith(`/${module}`)) return registered
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
// 38.
|
|
2
|
-
//
|
|
3
|
-
// source tree when it starts (38.45).
|
|
1
|
+
// 38.80 — WHAT THE `onLauncher` A `src/launcher.abide` CALLS ANSWERS: the window the desktop app opens
|
|
2
|
+
// and the forms its launcher offers, read as the app launches.
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
import { isRecord } from '#shared/isRecord.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What `onLauncher` may answer. 38.81 — turning off both forms is not one of them, so the checker
|
|
8
|
+
* refuses it where the answer is typed.
|
|
9
|
+
*/
|
|
6
10
|
export type LauncherOptions = {
|
|
7
|
-
|
|
11
|
+
width?: number
|
|
12
|
+
height?: number
|
|
13
|
+
minWidth?: number
|
|
14
|
+
minHeight?: number
|
|
15
|
+
} & ({ start?: true; connect?: boolean } | { start: false; connect?: true })
|
|
16
|
+
|
|
17
|
+
/** The options with each default filled in. */
|
|
18
|
+
export type Launcher = {
|
|
8
19
|
width: number
|
|
9
20
|
height: number
|
|
10
21
|
minWidth: number
|
|
@@ -16,12 +27,12 @@ export type LauncherOptions = {
|
|
|
16
27
|
const SIZES = ['width', 'height', 'minWidth', 'minHeight'] as const
|
|
17
28
|
const FORMS = ['start', 'connect'] as const
|
|
18
29
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The launcher what `onLauncher` answered sets, or the refusal naming what is wrong with it — read
|
|
32
|
+
* at run time, where an untyped answer can still name a key it does not take.
|
|
33
|
+
*/
|
|
34
|
+
export function launcherOf(answered: unknown): Launcher | string {
|
|
35
|
+
const launcher: Launcher = {
|
|
25
36
|
width: 1100,
|
|
26
37
|
height: 760,
|
|
27
38
|
minWidth: 480,
|
|
@@ -29,34 +40,25 @@ export async function launcherOptionsUnder(
|
|
|
29
40
|
start: true,
|
|
30
41
|
connect: true,
|
|
31
42
|
}
|
|
32
|
-
|
|
33
|
-
if (!(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
given = await file.json()
|
|
37
|
-
} catch (thrown) {
|
|
38
|
-
return `src/bundle/launcher.json is not JSON — ${(thrown as Error).message}`
|
|
39
|
-
}
|
|
40
|
-
if (given === null || typeof given !== 'object' || Array.isArray(given))
|
|
41
|
-
return 'src/bundle/launcher.json holds an object'
|
|
42
|
-
for (const [key, value] of Object.entries(given)) {
|
|
43
|
-
if (key === 'name' && typeof value === 'string') options.name = value
|
|
44
|
-
else if (
|
|
43
|
+
if (answered === undefined) return launcher
|
|
44
|
+
if (!isRecord(answered)) return '`onLauncher` answers an object'
|
|
45
|
+
for (const [key, value] of Object.entries(answered)) {
|
|
46
|
+
if (
|
|
45
47
|
(SIZES as readonly string[]).includes(key) &&
|
|
46
48
|
typeof value === 'number' &&
|
|
47
49
|
value > 0
|
|
48
50
|
)
|
|
49
|
-
|
|
51
|
+
launcher[key as (typeof SIZES)[number]] = value
|
|
50
52
|
else if (
|
|
51
53
|
(FORMS as readonly string[]).includes(key) &&
|
|
52
54
|
typeof value === 'boolean'
|
|
53
55
|
)
|
|
54
|
-
|
|
56
|
+
launcher[key as (typeof FORMS)[number]] = value
|
|
55
57
|
else
|
|
56
|
-
return
|
|
58
|
+
return `\`${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
|
|
57
59
|
}
|
|
58
|
-
// 38.
|
|
59
|
-
if (!
|
|
60
|
-
return '
|
|
61
|
-
return
|
|
60
|
+
// 38.81.
|
|
61
|
+
if (!launcher.start && !launcher.connect)
|
|
62
|
+
return '`onLauncher` turns off both start and connect, which leaves the launcher nothing to do'
|
|
63
|
+
return launcher
|
|
62
64
|
}
|
|
@@ -1,21 +1,29 @@
|
|
|
1
|
-
// 38.
|
|
1
|
+
// 38.71 — THE DESKTOP LAUNCHER'S OWN PAGE: start the app here, under the configuration the form
|
|
2
2
|
// holds, or connect to one running elsewhere. The app's own screen and not abide's, so it speaks
|
|
3
|
-
// the platform's look rather than the docs site's.
|
|
3
|
+
// the platform's look rather than the docs site's — and the app's color, read off its icon (38.101).
|
|
4
|
+
//
|
|
5
|
+
// TWO BUTTONS AND A DIALOG EACH (38.102): the page is the app's icon, its name and what can be done,
|
|
6
|
+
// and a form opens only once it is asked for. `…` on a button is the platform's own sign that it
|
|
7
|
+
// asks something first, so a Start with no configuration to ask for has none and starts at once.
|
|
4
8
|
|
|
5
9
|
import { escapeAttribute, escapeText } from '#shared/escapeText.ts'
|
|
6
10
|
import type { ConfigField } from './configFields.ts'
|
|
11
|
+
import { inkOn } from './iconAccent.ts'
|
|
12
|
+
|
|
13
|
+
// 38.101 — the accent where the app has no icon to take one from.
|
|
14
|
+
const SYSTEM_ACCENT = '#0a84ff'
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
|
-
* What a launch page is rendered with: `src/
|
|
17
|
+
* What a launch page is rendered with: `src/launcher.abide`'s props, and the built-in page's.
|
|
10
18
|
*
|
|
11
|
-
* 38.
|
|
19
|
+
* 38.83 — a form the launcher does not offer has no address to post to.
|
|
12
20
|
*/
|
|
13
21
|
export type Launch = {
|
|
14
|
-
/** The app's name, as
|
|
22
|
+
/** The app's name, as its package gives it. */
|
|
15
23
|
name: string
|
|
16
|
-
/** Where the start form posts, or `undefined` where `
|
|
24
|
+
/** Where the start form posts, or `undefined` where what `onLauncher` answers turns starting off. */
|
|
17
25
|
start: string | undefined
|
|
18
|
-
/** Where the connect form posts, or `undefined` where `
|
|
26
|
+
/** Where the connect form posts, or `undefined` where what `onLauncher` answers turns connecting off. */
|
|
19
27
|
connect: string | undefined
|
|
20
28
|
fields: ConfigField[]
|
|
21
29
|
/** The URL the last connect was given. */
|
|
@@ -28,8 +36,12 @@ export type Launch = {
|
|
|
28
36
|
issues: Record<string, string[]>
|
|
29
37
|
}
|
|
30
38
|
| undefined
|
|
31
|
-
/**
|
|
32
|
-
focus: 'start' | 'connect'
|
|
39
|
+
/** 38.102 — the form the page opens on: the one refused, or the one the menu asked for. */
|
|
40
|
+
focus: 'start' | 'connect' | undefined
|
|
41
|
+
/** 38.101 — the app's icon, as a data URL, where it has one. */
|
|
42
|
+
icon: string | undefined
|
|
43
|
+
/** 38.101 — the color taken from the icon, as `#rrggbb`, where it gives one. */
|
|
44
|
+
accent: string | undefined
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/** The built-in launcher page as markup. */
|
|
@@ -74,81 +86,112 @@ export function launcherPage(page: Launch): string {
|
|
|
74
86
|
line += `<p class="issue">${escapeText(issue)}</p>`
|
|
75
87
|
return line
|
|
76
88
|
}
|
|
89
|
+
const accent = page.accent ?? SYSTEM_ACCENT
|
|
90
|
+
const name = escapeText(page.name)
|
|
91
|
+
// A START WITH NOTHING TO ASK posts from the page itself; one with fields opens its dialog.
|
|
92
|
+
const asks = inputs !== '' || startFailure !== undefined
|
|
93
|
+
// A refused form opens on its refusal; a start that asks for nothing has no dialog to open.
|
|
94
|
+
const focus = page.failure?.form ?? page.focus
|
|
95
|
+
const opened =
|
|
96
|
+
focus === undefined || (focus === 'start' && !asks)
|
|
97
|
+
? ''
|
|
98
|
+
: `${focus}-dialog`
|
|
99
|
+
let buttons = ''
|
|
100
|
+
let dialogs = ''
|
|
101
|
+
if (page.start !== undefined) {
|
|
102
|
+
buttons += asks
|
|
103
|
+
? '<button class="primary" type="button" data-opens="start-dialog">Start…</button>'
|
|
104
|
+
: `<form method="post" action="${escapeAttribute(page.start)}"><button class="primary" type="submit">Start</button></form>`
|
|
105
|
+
if (asks)
|
|
106
|
+
dialogs += `
|
|
107
|
+
<dialog id="start-dialog" aria-labelledby="start-title">
|
|
108
|
+
<form method="post" action="${escapeAttribute(page.start)}">
|
|
109
|
+
<h2 id="start-title">Start ${name}</h2>
|
|
110
|
+
<p class="about">Runs ${name} on this computer.</p>
|
|
111
|
+
${failureLine(startFailure)}
|
|
112
|
+
${inputs}
|
|
113
|
+
<div class="actions">
|
|
114
|
+
<button type="button" data-closes>Cancel</button>
|
|
115
|
+
<button class="primary" type="submit">Start</button>
|
|
116
|
+
</div>
|
|
117
|
+
</form>
|
|
118
|
+
</dialog>`
|
|
119
|
+
}
|
|
120
|
+
if (page.connect !== undefined) {
|
|
121
|
+
buttons += `<button${page.start === undefined ? ' class="primary"' : ''} type="button" data-opens="connect-dialog">Connect…</button>`
|
|
122
|
+
dialogs += `
|
|
123
|
+
<dialog id="connect-dialog" aria-labelledby="connect-title">
|
|
124
|
+
<form method="post" action="${escapeAttribute(page.connect)}">
|
|
125
|
+
<h2 id="connect-title">Connect to ${name}</h2>
|
|
126
|
+
<p class="about">Opens ${name} where it is already running.</p>
|
|
127
|
+
${failureLine(connectFailure)}
|
|
128
|
+
<div class="field${connectFailure === undefined ? '' : ' invalid'}">
|
|
129
|
+
<label for="connect-url">URL</label>
|
|
130
|
+
<input id="connect-url" type="url" name="url" value="${escapeAttribute(page.url)}" placeholder="https://" required autocomplete="off" spellcheck="false">
|
|
131
|
+
</div>
|
|
132
|
+
<div class="actions">
|
|
133
|
+
<button type="button" data-closes>Cancel</button>
|
|
134
|
+
<button class="primary" type="submit">Connect</button>
|
|
135
|
+
</div>
|
|
136
|
+
</form>
|
|
137
|
+
</dialog>`
|
|
138
|
+
}
|
|
77
139
|
return `<!doctype html>
|
|
78
140
|
<html lang="en">
|
|
79
141
|
<head>
|
|
80
142
|
<meta charset="utf-8">
|
|
81
143
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
82
|
-
<title>${
|
|
144
|
+
<title>${name}</title>
|
|
83
145
|
<style>
|
|
84
|
-
:root { color-scheme: light dark; --ground: #
|
|
85
|
-
@media (prefers-color-scheme: dark) { :root { --ground: #
|
|
146
|
+
:root { color-scheme: light dark; --accent: ${accent}; --accent-ink: ${inkOn(accent)}; --ground: color-mix(in srgb, var(--accent) 7%, #f7f7f8); --card: #fff; --ink: #1d1d1f; --muted: #6e6e73; --line: color-mix(in srgb, var(--ink) 14%, transparent); --field: #fff; --bad: #d70015; --ring: color-mix(in srgb, var(--accent) 40%, transparent); }
|
|
147
|
+
@media (prefers-color-scheme: dark) { :root { --ground: color-mix(in srgb, var(--accent) 10%, #161618); --card: #232325; --ink: #f5f5f7; --muted: #98989d; --field: #1b1b1d; --bad: #ff6961; --ring: color-mix(in srgb, var(--accent) 55%, transparent); } }
|
|
86
148
|
* { box-sizing: border-box; }
|
|
87
149
|
html, body { margin: 0; height: 100%; }
|
|
88
|
-
body { font: 13px/1.
|
|
89
|
-
main {
|
|
90
|
-
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
body { font: 13px/1.45 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: var(--ground); color: var(--ink); display: grid; place-items: center; padding: 32px; -webkit-user-select: none; user-select: none; }
|
|
151
|
+
main { display: grid; justify-items: center; text-align: center; margin-top: -4vh; }
|
|
152
|
+
.icon { width: 128px; height: 128px; margin-bottom: 12px; }
|
|
153
|
+
.mark { display: grid; place-items: center; width: 104px; height: 104px; margin: 12px 0 24px; border-radius: 24px; background: linear-gradient(color-mix(in srgb, var(--accent) 82%, white), var(--accent)); color: var(--accent-ink); font-size: 52px; font-weight: 600; box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 30%, transparent); }
|
|
154
|
+
h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 28px; }
|
|
155
|
+
.buttons { display: flex; gap: 10px; }
|
|
156
|
+
.buttons form { display: contents; }
|
|
157
|
+
button { font: inherit; font-weight: 500; color: var(--ink); background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 6px 16px; min-width: 96px; cursor: default; }
|
|
158
|
+
main button { font-size: 14px; padding: 8px 20px; min-width: 128px; }
|
|
159
|
+
button.primary { color: var(--accent-ink); background: var(--accent); border-color: transparent; }
|
|
160
|
+
button:focus-visible { outline: 3px solid var(--ring); outline-offset: 1px; }
|
|
161
|
+
button:active { filter: brightness(0.92); }
|
|
162
|
+
button[disabled] { opacity: 0.6; }
|
|
163
|
+
dialog { width: min(400px, calc(100vw - 48px)); max-height: calc(100vh - 48px); padding: 24px; border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--ink); box-shadow: 0 24px 64px rgb(0 0 0 / 0.28); text-align: left; }
|
|
164
|
+
dialog::backdrop { background: rgb(0 0 0 / 0.22); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
|
165
|
+
h2 { font-size: 15px; font-weight: 600; margin: 0 0 2px; }
|
|
166
|
+
.about { color: var(--muted); margin: 0 0 18px; }
|
|
96
167
|
.field { display: grid; gap: 4px; margin-bottom: 12px; }
|
|
97
168
|
.field.check { grid-template-columns: auto 1fr; align-items: center; column-gap: 8px; }
|
|
98
169
|
.field.check label { order: 2; }
|
|
99
|
-
.field.check input[type=checkbox] { order: 1; margin: 0; }
|
|
170
|
+
.field.check input[type=checkbox] { order: 1; margin: 0; accent-color: var(--accent); }
|
|
100
171
|
.field.check .hint, .field.check .issue { grid-column: 1 / -1; order: 3; }
|
|
101
|
-
label { font-weight: 500; font-
|
|
102
|
-
input[type=text], input[type=number], input[type=password], input[type=url], select { font: inherit; color: inherit; background: var(--
|
|
103
|
-
input:focus, select:focus { outline: 3px solid
|
|
172
|
+
label { font-weight: 500; font-size: 12px; }
|
|
173
|
+
input[type=text], input[type=number], input[type=password], input[type=url], select { font: inherit; color: inherit; background: var(--field); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; width: 100%; -webkit-user-select: text; user-select: text; }
|
|
174
|
+
input:focus, select:focus { outline: 3px solid var(--ring); outline-offset: 0; border-color: var(--accent); }
|
|
104
175
|
.invalid input, .invalid select { border-color: var(--bad); }
|
|
105
176
|
.hint { color: var(--muted); margin: 0; font-size: 12px; }
|
|
106
177
|
.issue, .failure { color: var(--bad); margin: 0; font-size: 12px; }
|
|
107
|
-
.failure { margin-bottom:
|
|
108
|
-
.actions { display: flex; justify-content: flex-end; margin-top:
|
|
109
|
-
button { font: inherit; font-weight: 500; color: #fff; background: var(--accent); border: 0; border-radius: 6px; padding: 6px 16px; cursor: default; }
|
|
110
|
-
button:active { filter: brightness(0.9); }
|
|
111
|
-
button[disabled] { opacity: 0.6; }
|
|
112
|
-
.empty { color: var(--muted); margin: 0; }
|
|
178
|
+
.failure { margin-bottom: 14px; -webkit-user-select: text; user-select: text; }
|
|
179
|
+
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
113
180
|
</style>
|
|
114
181
|
</head>
|
|
115
182
|
<body>
|
|
116
183
|
<main>
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
? ''
|
|
122
|
-
: ` <section id="start"${page.focus === 'start' ? ' class="focus"' : ''}>
|
|
123
|
-
<h2>Start</h2>
|
|
124
|
-
<p class="about">Runs the app here, with this configuration.</p>
|
|
125
|
-
<form method="post" action="${escapeAttribute(page.start)}">
|
|
126
|
-
${failureLine(startFailure)}
|
|
127
|
-
${inputs === '' ? '<p class="empty">This app takes no configuration.</p>' : inputs}
|
|
128
|
-
<div class="actions"><button type="submit">Start</button></div>
|
|
129
|
-
</form>
|
|
130
|
-
</section>`
|
|
131
|
-
}
|
|
132
|
-
${
|
|
133
|
-
page.connect === undefined
|
|
134
|
-
? ''
|
|
135
|
-
: ` <section id="connect"${page.focus === 'connect' ? ' class="focus"' : ''}>
|
|
136
|
-
<h2>Connect</h2>
|
|
137
|
-
<p class="about">Opens the app running at another address.</p>
|
|
138
|
-
<form method="post" action="${escapeAttribute(page.connect)}">
|
|
139
|
-
${failureLine(connectFailure)}
|
|
140
|
-
<div class="field${connectFailure === undefined ? '' : ' invalid'}">
|
|
141
|
-
<label for="connect-url">URL</label>
|
|
142
|
-
<input id="connect-url" type="url" name="url" value="${escapeAttribute(page.url)}" placeholder="https://" required autocomplete="off" spellcheck="false">
|
|
143
|
-
</div>
|
|
144
|
-
<div class="actions"><button type="submit">Connect</button></div>
|
|
145
|
-
</form>
|
|
146
|
-
</section>`
|
|
147
|
-
}
|
|
148
|
-
</main>
|
|
184
|
+
${page.icon === undefined ? `<div class="mark" aria-hidden="true">${escapeText(page.name.charAt(0).toUpperCase())}</div>` : `<img class="icon" src="${escapeAttribute(page.icon)}" alt="">`}
|
|
185
|
+
<h1>${name}</h1>
|
|
186
|
+
<div class="buttons">${buttons}</div>
|
|
187
|
+
</main>${dialogs}
|
|
149
188
|
<script>
|
|
150
|
-
for (const
|
|
151
|
-
|
|
189
|
+
for (const opener of document.querySelectorAll('[data-opens]')) opener.addEventListener('click', () => open(opener.dataset.opens))
|
|
190
|
+
for (const closer of document.querySelectorAll('[data-closes]')) closer.addEventListener('click', () => closer.closest('dialog').close())
|
|
191
|
+
for (const dialog of document.querySelectorAll('dialog')) dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close() })
|
|
192
|
+
for (const form of document.forms) form.addEventListener('submit', () => { for (const button of form.querySelectorAll('button')) { button.disabled = true; if (button.type === 'submit') button.textContent = button.textContent === 'Start' ? 'Starting…' : 'Connecting…' } })
|
|
193
|
+
function open(id) { const dialog = document.getElementById(id); dialog.showModal(); (dialog.querySelector('.invalid input:not([type=hidden]), .invalid select') ?? dialog.querySelector('input:not([type=hidden]), select'))?.focus() }
|
|
194
|
+
${opened === '' ? '' : `open('${opened}')`}
|
|
152
195
|
</script>
|
|
153
196
|
</body>
|
|
154
197
|
</html>
|