@abide/abide 0.101.0 → 0.102.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.
Files changed (157) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/abide.ts +223 -164
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +5 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -59
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +102 -30
  53. package/src/server/answerSocket.ts +65 -20
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +4 -4
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +114 -181
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +73 -37
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  107. package/src/shared/cookies.ts +2 -2
  108. package/src/shared/csp.ts +1 -1
  109. package/src/shared/escapeText.ts +1 -1
  110. package/src/shared/filledPath.ts +85 -0
  111. package/src/shared/framing.ts +79 -6
  112. package/src/shared/headKeyOf.ts +2 -0
  113. package/src/shared/index.ts +1 -1
  114. package/src/shared/log.ts +27 -13
  115. package/src/shared/matchRoute.ts +23 -4
  116. package/src/shared/menuItem.ts +3 -3
  117. package/src/shared/reactive/Keyed.ts +2 -2
  118. package/src/shared/reactive/NotLanded.ts +1 -1
  119. package/src/shared/reactive/Reactive.ts +43 -43
  120. package/src/shared/reactive/argsContract.ts +4 -4
  121. package/src/shared/reactive/canonicalKey.ts +4 -4
  122. package/src/shared/reactive/chunksOfStream.ts +6 -0
  123. package/src/shared/reactive/index.ts +1 -1
  124. package/src/shared/reactive/recordWrites.ts +103 -5
  125. package/src/shared/reactive/structural.ts +1 -1
  126. package/src/shared/reactive/tracking.ts +1 -1
  127. package/src/shared/reactive/watch.ts +3 -3
  128. package/src/shared/refuse.ts +1 -1
  129. package/src/shared/routePattern.ts +82 -46
  130. package/src/shared/rpc.ts +37 -8
  131. package/src/shared/rpcRequest.ts +27 -3
  132. package/src/shared/url.ts +4 -44
  133. package/src/shared/writePath.ts +1 -1
  134. package/src/ui/LAYER_NAMES.ts +1 -1
  135. package/src/ui/List.ts +3 -3
  136. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  137. package/src/ui/Mismatch.ts +1 -1
  138. package/src/ui/RawRun.ts +1 -1
  139. package/src/ui/Router.ts +38 -31
  140. package/src/ui/Scope.ts +1 -1
  141. package/src/ui/TryRegion.ts +2 -2
  142. package/src/ui/applyHead.ts +33 -0
  143. package/src/ui/index.ts +1 -1
  144. package/src/ui/menuBridge.ts +1 -1
  145. package/src/ui/moveNode.ts +1 -1
  146. package/src/ui/reconcile.ts +2 -2
  147. package/src/ui/reportMenu.ts +2 -2
  148. package/src/ui/routing.ts +2 -2
  149. package/src/ui/setAttribute.ts +1 -1
  150. package/src/ui/setText.ts +1 -1
  151. package/src/ui/socketWire.ts +9 -7
  152. package/src/ui/wire.ts +1 -1
  153. package/scaffold/src/server/app.ts +0 -6
  154. package/scaffold/src/shared/.gitkeep +0 -0
  155. package/scaffold/src/ui/app.html +0 -8
  156. package/src/server/componentImports.ts +0 -56
  157. /package/scaffold/src/{ui/pages/page.abide → page.abide} +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
+ }
@@ -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,12 +43,17 @@ export {
43
43
  } from './claudeCode.ts'
44
44
  // 27 — the resolved configuration.
45
45
  export { type Config, config } from './config.ts'
46
- // 42 — the app object, and 37's hooks an `src/server/app.ts` calls.
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,
@@ -57,12 +62,11 @@ export {
57
62
  onStart,
58
63
  onStop,
59
64
  } from './hooks.ts'
65
+ export type { LauncherOptions } from './launcherOptions.ts'
66
+ export type { Launch } from './launcherPage.ts'
60
67
  // 23.10 — whether a host answers, asked before a call to it.
61
68
  export { reachable } from './reachable.ts'
62
- export { type Component, render, type Shell } from './render.ts'
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'
69
+ export { type Component, render } from './render.ts'
66
70
  // 32.17's run-time half: one invocation, rendered where it stands, with the anchors that make the
67
71
  // child's markup one thing for the client to walk to.
68
72
  export { renderComponent } from './renderComponent.ts'
@@ -0,0 +1,88 @@
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 { decodeMappings, placeAt, type Segment } from './sourceMappings.ts'
11
+
12
+ /** Per `.abide` below the root, as its server module is served there: the file, and its `mappings`. */
13
+ export type SourceMaps = Record<string, readonly [string, string]>
14
+
15
+ type Registered = {
16
+ source: string
17
+ mappings: string
18
+ lines: Segment[][] | undefined
19
+ }
20
+
21
+ /** By the `.abide`'s path, which a frame in its served module names. */
22
+ const REGISTERED = new Map<string, Registered>()
23
+ /** An executable's files by their path below the root, for its frames, whose directory is not known. */
24
+ const BELOW = new Map<string, Registered>()
25
+
26
+ /**
27
+ * Registers `maps`, the files below `base`, and places frames in them from now on.
28
+ *
29
+ * `base` is `undefined` for an executable, whose frames name each file as it was bundled.
30
+ */
31
+ export function installSourceMaps(
32
+ maps: SourceMaps,
33
+ base: string | undefined,
34
+ ): void {
35
+ for (const [module, [source, mappings]] of Object.entries(maps)) {
36
+ const registered = { source, mappings, lines: undefined }
37
+ if (base !== undefined)
38
+ REGISTERED.set(resolve(base, module), registered)
39
+ else BELOW.set(module, registered)
40
+ }
41
+ if (Error.prepareStackTrace !== placeFrames)
42
+ Error.prepareStackTrace = placeFrames
43
+ }
44
+
45
+ type CallSite = {
46
+ getFileName(): string | undefined | null
47
+ getLineNumber(): number | null
48
+ getColumnNumber(): number | null
49
+ getFunctionName(): string | null
50
+ isAsync?(): boolean
51
+ }
52
+
53
+ function placeFrames(error: Error, frames: CallSite[]): string {
54
+ let out = `${error.name}: ${error.message}`
55
+ if (error.message === '' || error.message === undefined) out = error.name
56
+ for (const frame of frames) {
57
+ const name = frame.getFunctionName()
58
+ const where = placed(frame)
59
+ out += `\n at ${frame.isAsync?.() ? 'async ' : ''}${name ? `${name} (${where})` : where}`
60
+ }
61
+ return out
62
+ }
63
+
64
+ // A call site's column counts from 0 where the stack bun writes counts from 1.
65
+ function placed(frame: CallSite): string {
66
+ const file = frame.getFileName() ?? '<anonymous>'
67
+ const line = frame.getLineNumber() ?? 0
68
+ const column = frame.getColumnNumber() ?? 0
69
+ const registered = registeredFor(file)
70
+ if (registered !== undefined && line > 0) {
71
+ registered.lines ??= decodeMappings(registered.mappings)
72
+ const place = placeAt(registered.lines, line - 1, column)
73
+ if (place !== undefined)
74
+ return `${registered.source}:${place[0] + 1}:${place[1] + 1}`
75
+ }
76
+ return `${file}:${line}:${column + 1}`
77
+ }
78
+
79
+ function registeredFor(path: string): Registered | undefined {
80
+ const found = REGISTERED.get(path)
81
+ if (found !== undefined) return found
82
+ // An executable's frames name the file as bun's own map does, relative to where it was
83
+ // bundled from — so it is found by the path below the root it ends with.
84
+ if (path.startsWith('/')) return undefined
85
+ for (const [module, registered] of BELOW)
86
+ if (path === module || path.endsWith(`/${module}`)) return registered
87
+ return undefined
88
+ }
@@ -1,10 +1,22 @@
1
- // 38.81 — `src/bundle/launcher.json`: the window the desktop app opens and the forms its launcher
2
- // offers. Read when `abide bundle` runs and carried in the executable, which reads nothing from the
3
- // source tree when it starts (38.45).
1
+ // 38.80 — THE `launcher` A `src/launcher.abide`'S `<script module>` EXPORTS: the window the desktop
2
+ // app opens and the forms its launcher offers. 34.32 — read off the module as the app launches,
3
+ // never by evaluating it during the bundle.
4
4
 
5
- /** What `src/bundle/launcher.json` may set, each with its default filled in. */
5
+ import { isRecord } from '#shared/isRecord.ts'
6
+
7
+ /**
8
+ * What the `launcher` export may set. 38.81 — turning off both forms is not one of them, so the
9
+ * checker refuses it where the export is typed.
10
+ */
6
11
  export type LauncherOptions = {
7
- name: string | undefined
12
+ width?: number
13
+ height?: number
14
+ minWidth?: number
15
+ minHeight?: number
16
+ } & ({ start?: true; connect?: boolean } | { start: false; connect?: true })
17
+
18
+ /** The options with each default filled in. */
19
+ export type Launcher = {
8
20
  width: number
9
21
  height: number
10
22
  minWidth: number
@@ -16,12 +28,12 @@ export type LauncherOptions = {
16
28
  const SIZES = ['width', 'height', 'minWidth', 'minHeight'] as const
17
29
  const FORMS = ['start', 'connect'] as const
18
30
 
19
- /** The launcher options under `root`, or the refusal naming what is wrong with them. */
20
- export async function launcherOptionsUnder(
21
- root: string,
22
- ): Promise<LauncherOptions | string> {
23
- const options: LauncherOptions = {
24
- name: undefined,
31
+ /**
32
+ * The launcher `exported` sets, or the refusal naming what is wrong with it — the export read at
33
+ * run time, where an untyped one can still name a key it does not take.
34
+ */
35
+ export function launcherOf(exported: unknown): Launcher | string {
36
+ const launcher: Launcher = {
25
37
  width: 1100,
26
38
  height: 760,
27
39
  minWidth: 480,
@@ -29,34 +41,25 @@ export async function launcherOptionsUnder(
29
41
  start: true,
30
42
  connect: true,
31
43
  }
32
- const file = Bun.file(`${root}/bundle/launcher.json`)
33
- if (!(await file.exists())) return options
34
- let given: unknown
35
- try {
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 (
44
+ if (exported === undefined) return launcher
45
+ if (!isRecord(exported)) return '`launcher` is an object'
46
+ for (const [key, value] of Object.entries(exported)) {
47
+ if (
45
48
  (SIZES as readonly string[]).includes(key) &&
46
49
  typeof value === 'number' &&
47
50
  value > 0
48
51
  )
49
- options[key as (typeof SIZES)[number]] = value
52
+ launcher[key as (typeof SIZES)[number]] = value
50
53
  else if (
51
54
  (FORMS as readonly string[]).includes(key) &&
52
55
  typeof value === 'boolean'
53
56
  )
54
- options[key as (typeof FORMS)[number]] = value
57
+ launcher[key as (typeof FORMS)[number]] = value
55
58
  else
56
- return `src/bundle/launcher.json: \`${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
59
+ return `\`launcher.${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
57
60
  }
58
- // 38.82.
59
- if (!options.start && !options.connect)
60
- return 'src/bundle/launcher.json turns off both start and connect, which leaves the launcher nothing to do'
61
- return options
61
+ // 38.81.
62
+ if (!launcher.start && !launcher.connect)
63
+ return '`launcher` turns off both start and connect, which leaves the launcher nothing to do'
64
+ return launcher
62
65
  }
@@ -1,21 +1,29 @@
1
- // 38.72 — THE DESKTOP LAUNCHER'S OWN PAGE: start the app here, under the configuration the form
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/bundle/launch.abide`'s props, and the built-in page's.
17
+ * What a launch page is rendered with: `src/launcher.abide`'s props, and the built-in page's.
10
18
  *
11
- * 38.84 — a form the launcher does not offer has no address to post to.
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 `launcher.json` gives it or as its package does. */
22
+ /** The app's name, as its package gives it. */
15
23
  name: string
16
- /** Where the start form posts, or `undefined` where `launcher.json` turns starting off. */
24
+ /** Where the start form posts, or `undefined` where the `launcher` export turns starting off. */
17
25
  start: string | undefined
18
- /** Where the connect form posts, or `undefined` where `launcher.json` turns connecting off. */
26
+ /** Where the connect form posts, or `undefined` where the `launcher` export 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
- /** The form the page opens on. */
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>${escapeText(page.name)}</title>
144
+ <title>${name}</title>
83
145
  <style>
84
- :root { color-scheme: light dark; --ground: #f5f5f7; --card: #fff; --ink: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7; --accent: #0a84ff; --bad: #d70015; }
85
- @media (prefers-color-scheme: dark) { :root { --ground: #1c1c1e; --card: #2c2c2e; --ink: #f5f5f7; --muted: #98989d; --line: #3a3a3c; --bad: #ff6961; } }
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.4 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: var(--ground); color: var(--ink); display: flex; align-items: flex-start; justify-content: center; padding: 48px 24px; overflow-y: auto; -webkit-user-select: none; user-select: none; }
89
- main { width: 100%; max-width: 440px; }
90
- h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; }
91
- .lede { color: var(--muted); margin: 0 0 24px; }
92
- section { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
93
- section.focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
94
- h2 { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
95
- section > p.about { color: var(--muted); margin: 0 0 16px; }
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-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
102
- input[type=text], input[type=number], input[type=password], input[type=url], select { font: inherit; color: inherit; background: var(--ground); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; width: 100%; -webkit-user-select: text; user-select: text; }
103
- input:focus, select:focus { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 0; border-color: var(--accent); }
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: 12px; -webkit-user-select: text; user-select: text; }
108
- .actions { display: flex; justify-content: flex-end; margin-top: 16px; }
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
- <h1>${escapeText(page.name)}</h1>
118
- <p class="lede">${page.start === undefined ? 'Connect to the app where it is running.' : page.connect === undefined ? 'Start the app on this computer.' : 'Start the app on this computer, or connect to one already running.'}</p>
119
- ${
120
- page.start === undefined
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 form of document.forms) form.addEventListener('submit', () => { for (const button of form.querySelectorAll('button')) { button.disabled = true; button.textContent = button.textContent === 'Start' ? 'Starting…' : 'Connecting…' } })
151
- document.querySelector('${page.focus === 'connect' ? '#connect-url' : '#start input:not([type=hidden]), #start select'}')?.focus()
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>