acrylblends 1.0.9 → 1.1.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 (47) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/bring-your-own-tool.md +2 -0
  6. package/docs/framework/guides/production.md +20 -0
  7. package/docs/framework/workshop/README.md +177 -274
  8. package/docs/framework/workshop/facilitator.md +107 -84
  9. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  10. package/lib/cordis/door.d.ts +3 -1
  11. package/lib/cordis/door.js +11 -1
  12. package/lib/cordis/state.d.ts +3 -2
  13. package/lib/cordis/state.js +370 -6
  14. package/lib/db/conformance.d.ts +6 -2
  15. package/lib/db/conformance.js +14 -2
  16. package/lib/door/host.d.ts +1 -1
  17. package/lib/door/host.js +1 -1
  18. package/lib/door/page.d.ts +10 -0
  19. package/lib/door/page.js +29 -7
  20. package/lib/kv/conformance.d.ts +6 -2
  21. package/lib/kv/conformance.js +14 -2
  22. package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
  23. package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
  24. package/lib/vitest-binding-CrH7rKg2.js +17 -0
  25. package/package.json +1 -5
  26. package/plugins/acryl-door/src/host/channel.ts +43 -5
  27. package/plugins/acryl-door/src/host/door.ts +5 -0
  28. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  29. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  30. package/plugins/acryl-door/src/page/channel.ts +8 -0
  31. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  32. package/plugins/acryl-door/src/page/index.ts +6 -1
  33. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  34. package/plugins/acryl-state/plugin.json +4 -3
  35. package/plugins/acryl-state/src/client-source.ts +244 -0
  36. package/plugins/acryl-state/src/service.ts +33 -6
  37. package/plugins/acryl-state/src/socket.ts +96 -0
  38. package/plugins/agent-panel/client/main.ts +105 -55
  39. package/plugins/agent-panel/index.ts +42 -5
  40. package/plugins/desktop/main.ts +5 -1
  41. package/plugins/instance/index.ts +6 -3
  42. package/templates/agent/AGENTS.md +3 -1
  43. package/templates/agent/src/routes.ts +38 -5
  44. package/templates/agent/test/app.test.ts +38 -2
  45. package/templates/chat/src/main.ts +1 -6
  46. package/templates/nodejs/src/main.ts +1 -6
  47. package/templates/notes/src/main.ts +1 -6
@@ -42,6 +42,11 @@ export const name = 'agent-panel'
42
42
  export const inject = ['router', 'agent', 'extensions', 'appInstance']
43
43
 
44
44
  const PREFIX = '/api/agent-panel'
45
+
46
+ /** The slice of the door the panel uses; the door is optional (it is a development plugin, and a different row). */
47
+ interface DoorPort {
48
+ driveFor(windowId: string): () => void
49
+ }
45
50
  const CLIENT_DIR = join(import.meta.dirname, 'client')
46
51
 
47
52
  export interface PanelConfig {
@@ -123,10 +128,14 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
123
128
  .post(`${PREFIX}/ask`, async (c) => {
124
129
  if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
125
130
  return c.json({ error: "only the app's own page may ask" }, 403)
126
- const body = (await c.req.json().catch(() => ({}))) as { message?: unknown; driver?: unknown }
131
+ const body = (await c.req.json().catch(() => ({}))) as {
132
+ message?: unknown
133
+ driver?: unknown
134
+ page?: unknown
135
+ }
127
136
  const checked = checkAsk(body.message, body.driver)
128
137
  if ('error' in checked) return c.json({ error: checked.error }, checked.status)
129
- const run = startRun(checked.driverName, checked.message, checked.driver)
138
+ const run = startRun(checked.driverName, checked.message, checked.driver, pageOf(body.page))
130
139
  return follow(c, run, false)
131
140
  })
132
141
  // Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
@@ -184,7 +193,15 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
184
193
  }
185
194
  let current: Run | undefined
186
195
  let runs = 0
187
- const startRun = (driverName: string, message: string, driver: ChatDriver): Run => {
196
+ /** The door's id of the page that asked, when it sent one that looks like it. */
197
+ const pageOf = (raw: unknown): string | undefined =>
198
+ typeof raw === 'string' && /^[A-Za-z0-9-]{1,80}$/u.test(raw) ? raw : undefined
199
+ const startRun = (
200
+ driverName: string,
201
+ message: string,
202
+ driver: ChatDriver,
203
+ page?: string,
204
+ ): Run => {
188
205
  runs += 1
189
206
  const run: Run = {
190
207
  id: runs,
@@ -209,6 +226,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
209
226
  })
210
227
  ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
211
228
  audit({ ask: 'start', driver: driverName, characters: message.length })
229
+ // The agent's door calls name no window: while this run goes, they are for the page whose panel asked, not for another tab or window that happens to be open.
230
+ const door = page === undefined ? undefined : (ctx.get('door') as DoorPort | undefined)
231
+ const releasePage = door?.driveFor(page as string)
212
232
  void (async () => {
213
233
  try {
214
234
  for await (const { event, data } of driver.start({ message, signal: run.cancel.signal }))
@@ -218,6 +238,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
218
238
  emit('error', { message: error instanceof Error ? error.message : String(error) })
219
239
  } finally {
220
240
  off()
241
+ releasePage?.()
221
242
  run.ended = true
222
243
  run.endedAt = Date.now()
223
244
  audit({ ask: 'end', driver: driverName, changed })
@@ -339,7 +360,13 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
339
360
  },
340
361
  onMessage(event, ws) {
341
362
  if (typeof event.data !== 'string') return
342
- let message: { t?: unknown; token?: unknown; message?: unknown; driver?: unknown }
363
+ let message: {
364
+ t?: unknown
365
+ token?: unknown
366
+ message?: unknown
367
+ driver?: unknown
368
+ page?: unknown
369
+ }
343
370
  try {
344
371
  message = JSON.parse(event.data) as typeof message
345
372
  } catch {
@@ -352,6 +379,8 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
352
379
  }
353
380
  authed = true
354
381
  if (hello !== undefined) clearTimeout(hello)
382
+ // The page waits for this before it counts the socket as open: a token it was given by an earlier start of the app is refused (4401) and it asks for a new one.
383
+ ws.send(JSON.stringify({ e: 'accepted', d: {} }))
355
384
  return
356
385
  }
357
386
  const send = (e: string, d: unknown): void => {
@@ -374,7 +403,15 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
374
403
  ws.close(1000, 'refused')
375
404
  return
376
405
  }
377
- watch(startRun(checked.driverName, checked.message, checked.driver), false)
406
+ watch(
407
+ startRun(
408
+ checked.driverName,
409
+ checked.message,
410
+ checked.driver,
411
+ pageOf(message.page),
412
+ ),
413
+ false,
414
+ )
378
415
  } else if (message.t === 'follow') {
379
416
  if (current === undefined) {
380
417
  send('idle', {})
@@ -91,6 +91,8 @@ if (!app.requestSingleInstanceLock()) {
91
91
  }
92
92
  const origin = new URL(url).origin
93
93
  /** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
94
+ // Where each window was opened: `window list` shows it until the page reports its own address (Electron gives none while the first load has not started).
95
+ const openedOn = new Map<number, string>()
94
96
  const createWindow = (path: string): BrowserWindow => {
95
97
  // A window opened while another is open sits a step down and to the right of it, not exactly on top.
96
98
  const beside =
@@ -112,6 +114,7 @@ if (!app.requestSingleInstanceLock()) {
112
114
  )
113
115
  })()
114
116
  const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
117
+ openedOn.set(created.id, path)
115
118
  created.webContents.setUserAgent(
116
119
  `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
117
120
  )
@@ -139,7 +142,8 @@ if (!app.requestSingleInstanceLock()) {
139
142
  const port = (candidate: BrowserWindow): WindowPort => ({
140
143
  id: candidate.id,
141
144
  getTitle: () => candidate.getTitle(),
142
- getURL: () => candidate.webContents.getURL(),
145
+ getURL: () =>
146
+ candidate.webContents.getURL() || new URL(openedOn.get(candidate.id) ?? '/', url).href,
143
147
  isFocused: () => candidate.isFocused(),
144
148
  isMinimized: () => candidate.isMinimized(),
145
149
  getSize: () => candidate.getSize(),
@@ -44,6 +44,9 @@ export const PORT_SPAN = 900
44
44
  export const HOME_ENV = 'ACRYLBLENDS_HOME'
45
45
  export const PORT_ENV = 'ACRYLBLENDS_PORT'
46
46
  export const HOST_ENV = 'ACRYLBLENDS_HOST'
47
+ /** The names hosting platforms set: used when `ACRYLBLENDS_PORT` and `ACRYLBLENDS_HOST` are not. */
48
+ export const PLATFORM_PORT_ENV = 'PORT'
49
+ export const PLATFORM_HOST_ENV = 'HOST'
47
50
 
48
51
  export class AppInstanceError extends Error {
49
52
  override name = 'AppInstanceError'
@@ -134,7 +137,7 @@ export function withPort(instance: AppInstance, start: number): AppInstance {
134
137
 
135
138
  /**
136
139
  * Choose the instance. The only place that reads the environment: `ACRYLBLENDS_HOME` pins the home, `ACRYLBLENDS_PORT` sets the start port, `ACRYLBLENDS_HOST` the interface;
137
- * otherwise the project folder decides.
140
+ * hosting platforms set `PORT` and `HOST` instead, and those are read when the `ACRYLBLENDS_` ones are not; otherwise the project folder decides.
138
141
  */
139
142
  export function selectInstance(options: {
140
143
  projectRoot: string
@@ -145,9 +148,9 @@ export function selectInstance(options: {
145
148
  let instance = pinned
146
149
  ? pinnedInstance(pinned, { name: plainName(options.projectRoot), root: options.projectRoot })
147
150
  : projectInstance(options.projectRoot)
148
- const port = env[PORT_ENV]?.trim()
151
+ const port = env[PORT_ENV]?.trim() || env[PLATFORM_PORT_ENV]?.trim()
149
152
  if (port) instance = withPort(instance, Number(port))
150
- const host = env[HOST_ENV]?.trim()
153
+ const host = env[HOST_ENV]?.trim() || env[PLATFORM_HOST_ENV]?.trim()
151
154
  if (host) instance = Object.freeze({ ...instance, host })
152
155
  return instance
153
156
  }
@@ -9,6 +9,8 @@ and an optional `check(ctx)` that receives the plugin's own context (so `ctx.rou
9
9
 
10
10
  **Drive the app you build.** `.acryl/bin/acryl look` reads the open page as text with refs, and `acryl act click|type|press|scroll|wait --ref <ref>` presses its buttons, opens its sidebars and fills its forms (look again before each act: the refs change). `act navigate --path /settings` goes to another page of the app; a path the app does not serve answers `404 at /nope` and the window shows a 404 page, so navigate on from there. In the desktop app (`npm run desktop`) the app's windows and menu are verbs too: `acryl window list`, `acryl window open --path /settings`, `acryl look --window <id>` (and `act ... --window <id>`) to drive that window's page, `acryl window resize|focus|close --id <id>`, `acryl menu list` and `acryl menu click --path "File>New Note"`. Closing the last window and Quit end the desktop app, so they need `--force`: do not use it unless the person asks. The app has a `/settings` page already (the appearance: system, light or dark, kept in the browser) and the desktop app's menu has a Settings item that opens it; add a person's other settings to that page, not to a new one, so `acryl menu click --path "File>Settings..."` and `acryl window open --path /settings` keep opening them (on macOS the item is in the application menu). Without a desktop app these answer "no desktop window is attached; run npm run desktop".
11
11
 
12
- **Keep to two event streams a page.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open. The door and the panel use none (they are WebSockets), but a page you build that streams several things in two windows can fill all six, and then every request waits. Give a page at most two streams: share one stream for several cells, or fetch what changes now and then instead.
12
+ **Pages you build follow the app's appearance.** The person chooses System, Light or Dark in `/settings`, and every open window follows it at once. A page you build gets that by putting `<script src="/appearance.js"></script>` in its `<head>` (before any other script) and colouring itself with the custom properties the script defines: `var(--bg)` for the page, `var(--fg)` for text, `var(--surface)` for cards and sidebars, `var(--border)`, `var(--muted)` for secondary text and `var(--accent)`. Do not write fixed colours (`#fff`, `white`, `#111`), `color-scheme`, or a `@media (prefers-color-scheme)` of your own: they ignore the person's choice (a page that does so stays light in a window set to Dark, or the other way round). `look` reports structure and text, not colours: check a page's colours by its CSS (every colour is a `var(--...)`), and tell the person what you did.
13
+
14
+ **Follow state with `watch`, not an `EventSource`.** A browser lets a page hold six connections to one host, across all the windows of the app, and an `EventSource` holds one for as long as the page is open: a page that streams a few things in two windows fills all six, and then every request waits. A cell of `ctx.state` (`ctx.state.cell('board.cards', [])`) is followed in the page with `import { watch } from '/api/state/client.js'` and `const stop = watch('board.cards', (cards) => { render(cards) })`: one WebSocket for the whole page carries every key (a WebSocket is not one of the six), the callback gets the value now and after every change, and `stop()` ends it. Without a WebSocket it falls back to one event stream per key and prints one warning in the console; then keep to two keys a page. Do not open an `EventSource` on `/api/state/<key>/events` yourself, and do not poll in a loop what `watch` already pushes.
13
15
 
14
16
  **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves the agent and the builder out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. The extensions you installed (the folders of `plugins/`, committed with the app) still run in production, read-only: nothing can be installed or changed there. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
@@ -16,14 +16,41 @@ const DOOR_TAG =
16
16
  /**
17
17
  * The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
18
18
  * (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
19
- * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent and builder rows out
19
+ * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events` (a page follows them with `watch` of `/api/state/client.js`, over one WebSocket). In production `src/app.ts` leaves the agent and builder rows out
20
20
  * and starts the extensions read-only (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
21
21
  */
22
- /** The page's look: the person's choice from /settings (kept in this browser), applied before the page paints. */
22
+ /**
23
+ * The app's look: the person's choice from /settings (kept in this browser), as `GET /appearance.js`. One script tag in a page's head (`<script src="/appearance.js"></script>`) applies it
24
+ * before the page paints, again whenever another window of the app changes it (the `storage` event), and defines the colours as custom properties for the page to use:
25
+ * `--bg`, `--fg`, `--muted`, `--surface`, `--border`, `--accent` (`html[data-theme=dark|light]` is set when the person chose one, and the system's choice decides otherwise). A page the agent builds
26
+ * carries the tag and colours itself with `var(--bg)` and the rest, and so follows Dark and Light.
27
+ */
28
+ const APPEARANCE_JS = `(function () {
29
+ var root = document.documentElement
30
+ function apply() {
31
+ var saved
32
+ try { saved = localStorage.getItem('theme') } catch (e) { /* storage blocked: follow the system */ }
33
+ if (saved === 'dark' || saved === 'light') root.dataset.theme = saved
34
+ else delete root.dataset.theme
35
+ }
36
+ apply()
37
+ addEventListener('storage', function (event) { if (event.key === null || event.key === 'theme') apply() })
38
+ var light = '--bg:#ffffff;--fg:#111827;--muted:#6b7280;--surface:#f3f4f6;--border:#d1d5db;--accent:#2563eb'
39
+ var dark = '--bg:#111827;--fg:#f9fafb;--muted:#9ca3af;--surface:#1f2937;--border:#374151;--accent:#60a5fa'
40
+ var style = document.createElement('style')
41
+ style.id = 'appearance'
42
+ style.textContent = ':root{color-scheme:light dark;' + light + '}'
43
+ + '@media (prefers-color-scheme:dark){html:not([data-theme]){' + dark + '}}'
44
+ + 'html[data-theme=dark]{color-scheme:dark;' + dark + '}'
45
+ + 'html[data-theme=light]{color-scheme:light;' + light + '}'
46
+ + 'html{background:var(--bg);color:var(--fg)}'
47
+ ;(document.head || root).appendChild(style)
48
+ })()
49
+ `
50
+ /** The home and settings pages' own layout; their colours come from /appearance.js. */
23
51
  const APPEARANCE_STYLE =
24
- '<style>:root{color-scheme:light dark}html[data-theme=dark]{color-scheme:dark;background:#111827;color:#f9fafb}html[data-theme=light]{color-scheme:light;background:#fff;color:#111827}body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
25
- const APPEARANCE_SCRIPT =
26
- "<script>(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}})()</script>"
52
+ '<style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:42rem;padding:0 1rem}a{color:inherit}</style>'
53
+ const APPEARANCE_SCRIPT = '<script src="/appearance.js"></script>'
27
54
 
28
55
  /** `/settings`: the one setting the template has, the appearance. The desktop app's Settings menu item opens this page. */
29
56
  const settingsPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Settings - ${APP_NAME}</title>${APPEARANCE_STYLE}${APPEARANCE_SCRIPT}</head><body><h1>Settings</h1><p><label for="theme">Appearance</label> <select id="theme" name="theme"><option value="system">Match the system</option><option value="light">Light</option><option value="dark">Dark</option></select></p><p id="saved" role="status"></p><p><a href="/">Back to ${APP_NAME}</a></p><script>(function(){var select=document.getElementById('theme'),saved=document.getElementById('saved');try{select.value=localStorage.getItem('theme')||'system'}catch(e){}select.addEventListener('change',function(){var value=select.value;try{if(value==='system'){localStorage.removeItem('theme');delete document.documentElement.dataset.theme}else{localStorage.setItem('theme',value);document.documentElement.dataset.theme=value}saved.textContent='Saved: '+select.options[select.selectedIndex].text}catch(e){saved.textContent='This browser would not keep the setting.'}})})()</script></body></html>`
@@ -52,6 +79,12 @@ export const routes = {
52
79
  )
53
80
  .get('/health', (c) => c.json({ ok: true }))
54
81
  .get('/settings', (c) => c.html(settingsPage))
82
+ .get('/appearance.js', (c) =>
83
+ c.body(APPEARANCE_JS, 200, {
84
+ 'content-type': 'text/javascript; charset=utf-8',
85
+ 'cache-control': 'no-cache',
86
+ }),
87
+ )
55
88
  ctx.router.route('/', base)
56
89
  // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
57
90
  ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
@@ -156,11 +156,47 @@ test('the app has a /settings page with the appearance setting, and the home pag
156
156
  assert.match(page, /<select id="theme"/)
157
157
  for (const option of ['System', 'Light', 'Dark'])
158
158
  assert.match(page, new RegExp(`>(?:Match the )?${option.toLowerCase()}|>${option}<`, 'i'))
159
- // The setting is kept in the browser and applied to the home page before it paints.
159
+ // The setting is kept in the browser and applied to the home page before it paints, by the one script every page of the app can carry.
160
160
  const home = await (
161
161
  await app.root.router.request('/', { headers: { accept: 'text/html' } })
162
162
  ).text()
163
- assert.match(home, /localStorage\.getItem\('theme'\)/)
163
+ assert.match(home, /<script src="\/appearance\.js"><\/script>/)
164
+ assert.match(page, /<script src="\/appearance\.js"><\/script>/)
164
165
  // ... without leaving a global behind for the page's own code to collide with.
165
166
  assert.doesNotMatch(home, /<script>\s*(?:var|let|const) /)
167
+ const script = await app.root.router.request('/appearance.js')
168
+ assert.equal(script.status, 200)
169
+ assert.match(script.headers.get('content-type') ?? '', /javascript/)
170
+ const code = await script.text()
171
+ assert.match(code, /localStorage\.getItem\('theme'\)/)
172
+ // A change made in another window of the app is applied here at once, and the colours are custom properties for a page to use.
173
+ assert.match(code, /addEventListener\('storage'/)
174
+ for (const token of ['--bg', '--fg', '--muted', '--surface', '--border', '--accent'])
175
+ assert.ok(code.includes(token), token)
176
+ assert.match(code, /prefers-color-scheme:dark/)
177
+ // Run against a stand-in page: it applies the saved choice, follows a change made in another window, and goes back to the system's choice when the setting is removed.
178
+ const storage = new Map<string, string>([['theme', 'dark']])
179
+ const dataset: Record<string, string> = {}
180
+ const appended: Array<{ id: string; textContent: string }> = []
181
+ const listeners: Array<(event: { key: string | null }) => void> = []
182
+ new Function('document', 'localStorage', 'addEventListener', code)(
183
+ {
184
+ documentElement: { dataset },
185
+ head: { appendChild: (style: { id: string; textContent: string }) => appended.push(style) },
186
+ createElement: () => ({ id: '', textContent: '' }),
187
+ },
188
+ { getItem: (key: string) => storage.get(key) ?? null },
189
+ (_type: string, listener: (event: { key: string | null }) => void) =>
190
+ listeners.push(listener),
191
+ )
192
+ assert.equal(dataset.theme, 'dark')
193
+ assert.equal(appended[0]?.id, 'appearance')
194
+ storage.set('theme', 'light')
195
+ for (const listener of listeners) listener({ key: 'theme' })
196
+ assert.equal(dataset.theme, 'light')
197
+ storage.delete('theme')
198
+ for (const listener of listeners) listener({ key: 'something-else' })
199
+ assert.equal(dataset.theme, 'light')
200
+ for (const listener of listeners) listener({ key: null })
201
+ assert.equal(dataset.theme, undefined)
166
202
  }))
@@ -5,12 +5,7 @@ import { createApp } from './app.ts'
5
5
  // The composition root: the one place that reads the environment and chooses where the app lives.
6
6
  const chosen = selectInstance({
7
7
  projectRoot: fileURLToPath(new URL('..', import.meta.url)),
8
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
9
- env: {
10
- ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
11
- ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
12
- ...process.env,
13
- },
8
+ env: process.env,
14
9
  })
15
10
  const app = await createApp(chosen, {
16
11
  ...(process.env.CHAT_DB ? { database: process.env.CHAT_DB } : {}),
@@ -6,12 +6,7 @@ import { APP_NAME } from './routes.ts'
6
6
  // The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
7
7
  const chosen = selectInstance({
8
8
  projectRoot: fileURLToPath(new URL('..', import.meta.url)),
9
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
10
- env: {
11
- ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
12
- ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
13
- ...process.env,
14
- },
9
+ env: process.env,
15
10
  })
16
11
  const app = await createApp(chosen)
17
12
  console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
@@ -5,12 +5,7 @@ import { createApp } from './app.ts'
5
5
  // The composition root: the one place that reads the environment and chooses where the app lives.
6
6
  const chosen = selectInstance({
7
7
  projectRoot: fileURLToPath(new URL('..', import.meta.url)),
8
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
9
- env: {
10
- ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
11
- ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
12
- ...process.env,
13
- },
8
+ env: process.env,
14
9
  })
15
10
  const app = await createApp(chosen, {
16
11
  ...(process.env.NOTES_DB ? { database: process.env.NOTES_DB } : {}),