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.
- package/dist/index.d.ts +15 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +2 -0
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +177 -274
- package/docs/framework/workshop/facilitator.md +107 -84
- package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
- package/lib/cordis/door.d.ts +3 -1
- package/lib/cordis/door.js +11 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +370 -6
- package/lib/db/conformance.d.ts +6 -2
- package/lib/db/conformance.js +14 -2
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -0
- package/lib/door/page.js +29 -7
- package/lib/kv/conformance.d.ts +6 -2
- package/lib/kv/conformance.js +14 -2
- package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
- package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
- package/lib/vitest-binding-CrH7rKg2.js +17 -0
- package/package.json +1 -5
- package/plugins/acryl-door/src/host/channel.ts +43 -5
- package/plugins/acryl-door/src/host/door.ts +5 -0
- package/plugins/acryl-door/src/host/page-routes.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +1 -1
- package/plugins/acryl-door/src/page/channel.ts +8 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
- package/plugins/acryl-door/src/page/index.ts +6 -1
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/agent-panel/client/main.ts +105 -55
- package/plugins/agent-panel/index.ts +42 -5
- package/plugins/desktop/main.ts +5 -1
- package/plugins/instance/index.ts +6 -3
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +38 -5
- package/templates/agent/test/app.test.ts +38 -2
- package/templates/chat/src/main.ts +1 -6
- package/templates/nodejs/src/main.ts +1 -6
- 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 {
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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', {})
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -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: () =>
|
|
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
|
-
**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } : {}),
|