amorphous-cli 0.3.5 → 0.3.7
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/lib/agent-contract.test.mjs +7 -0
- package/lib/files.mjs +1 -1
- package/lib/serve.mjs +75 -4
- package/lib/serve.test.mjs +81 -4
- package/package.json +1 -1
- package/templates.json +2 -2
|
@@ -22,6 +22,13 @@ test('starter app scripts parse as browser classic scripts', () => {
|
|
|
22
22
|
}
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
test('board starter explains horizontal scrolling on mobile', () => {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'amorphous-board-scroll-hint-'))
|
|
27
|
+
writeStarter(dir, 'board')
|
|
28
|
+
const html = readFileSync(join(dir, 'index.html'), 'utf8')
|
|
29
|
+
assert.match(html, /Scroll sideways to see all columns\./)
|
|
30
|
+
})
|
|
31
|
+
|
|
25
32
|
test('setup copies the skill, and an edited contract is left alone', () => {
|
|
26
33
|
const home = mkdtempSync(join(tmpdir(), 'amorphous-skill-'))
|
|
27
34
|
const dirs = installUserSkill(home)
|
package/lib/files.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function insertLocalRecord(cwd, row) {
|
|
|
69
69
|
body.visibility = body.visibility === 'private' ? 'private' : 'shared'
|
|
70
70
|
if (!body.id) body.id = randomUUID()
|
|
71
71
|
body.revision = 1
|
|
72
|
-
body.collection = 'default'
|
|
72
|
+
body.collection = typeof body.collection === 'string' && body.collection ? body.collection : 'default'
|
|
73
73
|
body.created_by = null
|
|
74
74
|
const rows = recordsFromLocal(cwd)
|
|
75
75
|
if (rows.some((r) => r.id === body.id)) return { status: 409, message: 'Conflict' }
|
package/lib/serve.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, watch } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { apiFetch } from './api.mjs'
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
|
-
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord } from './files.mjs'
|
|
6
|
+
import { insertLocalRecord, readProjectFiles, recordsFromLocal, replaceLocalRecord, writeLocalRecords } from './files.mjs'
|
|
7
7
|
|
|
8
8
|
const BAR_CSS = `#amorphous-serve{position:fixed;z-index:2147483646;left:12px;bottom:12px;display:flex;gap:6px;align-items:center;padding:4px 6px 4px 10px;border-radius:999px;background:#18181b;color:#fff;font:500 12px/1 system-ui,-apple-system,sans-serif}
|
|
9
9
|
#amorphous-serve button{border:0;border-radius:999px;padding:4px 8px;font:inherit;color:#a1a1aa;background:transparent;cursor:pointer}
|
|
@@ -185,6 +185,50 @@ export function startServe(options) {
|
|
|
185
185
|
const invoke = (name, input) => hosted(`${appPath}/actions/${name}/invoke${environmentQuery}`, {
|
|
186
186
|
method: 'POST', headers: { 'Idempotency-Key': randomUUID() }, body: JSON.stringify(input),
|
|
187
187
|
})
|
|
188
|
+
function localAction(name, input) {
|
|
189
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { status: 'validation_error' }
|
|
190
|
+
const collection = typeof input.collection === 'string' && input.collection ? input.collection : null
|
|
191
|
+
const visibility = input.visibility
|
|
192
|
+
if (!collection || !['shared', 'private'].includes(visibility) ||
|
|
193
|
+
(name.startsWith('private.') && (collection !== 'default' || visibility !== 'private')) ||
|
|
194
|
+
(name.startsWith('items.') && (collection !== 'items' || visibility !== 'shared')) ||
|
|
195
|
+
(name.startsWith('form.') && (collection !== 'form' || visibility !== 'shared'))) {
|
|
196
|
+
return { status: 'validation_error' }
|
|
197
|
+
}
|
|
198
|
+
const rows = recordsFromLocal(cwd)
|
|
199
|
+
const wireRow = (row) => {
|
|
200
|
+
const { id, revision, visibility: rowVisibility, collection: rowCollection, created_by, ...body } = row
|
|
201
|
+
return { id, revision, visibility: rowVisibility, collection: rowCollection, created_by, body }
|
|
202
|
+
}
|
|
203
|
+
if (['records.query', 'private.query', 'form.mine'].includes(name)) {
|
|
204
|
+
const filtered = rows.filter((row) => row.collection === collection && row.visibility === visibility &&
|
|
205
|
+
(!input.id || row.id === input.id))
|
|
206
|
+
const after = input.cursor ? filtered.findIndex((row) => row.id === input.cursor) + 1 : 0
|
|
207
|
+
return { status: 'ok', output: filtered.slice(after, after + Math.max(1, Math.min(100, Number(input.limit) || 100))).map(wireRow) }
|
|
208
|
+
}
|
|
209
|
+
if (['records.create', 'private.create', 'items.add', 'form.submit'].includes(name)) {
|
|
210
|
+
if (!input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return { status: 'validation_error' }
|
|
211
|
+
const result = insertLocalRecord(cwd, { ...input.body, ...(input.id ? { id: input.id } : {}), visibility, collection })
|
|
212
|
+
if (result.status !== 200) return { status: result.status === 409 ? 'conflict' : 'validation_error' }
|
|
213
|
+
return { status: 'ok', output: wireRow(result.row) }
|
|
214
|
+
}
|
|
215
|
+
if (['records.patch', 'private.patch', 'items.done', 'items.move', 'items.edit', 'form.edit'].includes(name)) {
|
|
216
|
+
const row = rows.find((entry) => entry.id === input.id)
|
|
217
|
+
if (!row || row.visibility !== visibility || row.collection !== collection) return { status: 'not_found' }
|
|
218
|
+
if (row.revision !== input.expectedRevision) return { status: 'conflict' }
|
|
219
|
+
if (!input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return { status: 'validation_error' }
|
|
220
|
+
const result = replaceLocalRecord(cwd, row.id, input.body)
|
|
221
|
+
return result.status === 200 ? { status: 'ok', output: wireRow(result.row) } : { status: 'not_found' }
|
|
222
|
+
}
|
|
223
|
+
if (['records.delete', 'private.delete', 'items.delete'].includes(name)) {
|
|
224
|
+
const row = rows.find((entry) => entry.id === input.id)
|
|
225
|
+
if (!row || row.visibility !== visibility || row.collection !== collection) return { status: 'not_found' }
|
|
226
|
+
if (row.revision !== input.expectedRevision) return { status: 'conflict' }
|
|
227
|
+
writeLocalRecords(cwd, rows.filter((entry) => entry.id !== row.id))
|
|
228
|
+
return { status: 'ok', output: { id: row.id, deleted: true } }
|
|
229
|
+
}
|
|
230
|
+
return { status: 'validation_error' }
|
|
231
|
+
}
|
|
188
232
|
const recordFromOutput = (row) => ({ ...row.body, id: row.id, revision: row.revision,
|
|
189
233
|
visibility: row.visibility, collection: row.collection, created_by: row.created_by })
|
|
190
234
|
async function hostedRecords(method, url, req) {
|
|
@@ -252,6 +296,10 @@ export function startServe(options) {
|
|
|
252
296
|
return
|
|
253
297
|
}
|
|
254
298
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
299
|
+
send(res, 302, null, { Location: `/${abstractId}/${url.search}` })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (req.method === 'GET' && (url.pathname === `/${abstractId}` || url.pathname === `/${abstractId}/`)) {
|
|
255
303
|
const mode = recordsMode(req, url)
|
|
256
304
|
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }
|
|
257
305
|
if (url.searchParams.get('records') === 'local' || url.searchParams.get('records') === 'prod') {
|
|
@@ -268,6 +316,28 @@ export function startServe(options) {
|
|
|
268
316
|
send(res, 200, readProjectFiles(cwd).js, { 'Content-Type': 'text/javascript; charset=utf-8' })
|
|
269
317
|
return
|
|
270
318
|
}
|
|
319
|
+
const actionMatch = /^\/api\/v1\/apps\/([^/]+)\/actions\/([a-z.]+)\/invoke$/.exec(url.pathname)
|
|
320
|
+
if (actionMatch) {
|
|
321
|
+
if (req.method !== 'POST') {
|
|
322
|
+
send(res, 405, { message: 'Method not allowed' })
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
if (actionMatch[1] !== abstractId) {
|
|
326
|
+
send(res, 404, { message: 'Not found' })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const body = await readJson(req)
|
|
330
|
+
if (recordsMode(req, url) === 'prod') {
|
|
331
|
+
const receipt = await hosted(`${appPath}/actions/${actionMatch[2]}/invoke${environmentQuery}`, {
|
|
332
|
+
method: 'POST', headers: { 'Idempotency-Key': req.headers['idempotency-key'] || randomUUID() },
|
|
333
|
+
body: JSON.stringify(body),
|
|
334
|
+
})
|
|
335
|
+
send(res, 200, receipt)
|
|
336
|
+
} else {
|
|
337
|
+
send(res, 200, localAction(actionMatch[2], body))
|
|
338
|
+
}
|
|
339
|
+
return
|
|
340
|
+
}
|
|
271
341
|
if (req.method === 'GET' && url.pathname === '/__amorphous/events') {
|
|
272
342
|
res.writeHead(200, {
|
|
273
343
|
'Content-Type': 'text/event-stream',
|
|
@@ -377,14 +447,15 @@ export function startServe(options) {
|
|
|
377
447
|
const address = server.address()
|
|
378
448
|
const port = typeof address === 'object' && address ? address.port : options.port
|
|
379
449
|
const url = `http://${host}:${port}/`
|
|
380
|
-
|
|
450
|
+
const appUrl = `${url}${abstractId}/`
|
|
451
|
+
resolve({ url, appUrl, port })
|
|
381
452
|
})
|
|
382
453
|
})
|
|
383
454
|
|
|
384
455
|
return ready.then((info) => {
|
|
385
456
|
if (!options.silent) {
|
|
386
|
-
if (options.json) console.log(JSON.stringify({ url: info.
|
|
387
|
-
else console.log(info.
|
|
457
|
+
if (options.json) console.log(JSON.stringify({ url: info.appUrl }))
|
|
458
|
+
else console.log(info.appUrl)
|
|
388
459
|
}
|
|
389
460
|
return {
|
|
390
461
|
...info,
|
package/lib/serve.test.mjs
CHANGED
|
@@ -45,9 +45,11 @@ test('serve reloads local source and keeps prod records off disk', async () => {
|
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
-
const origin = running.url.
|
|
48
|
+
const origin = new URL(running.url).origin
|
|
49
|
+
assert.equal(new URL(running.appUrl).pathname, '/00000000-0000-4000-8000-000000000001/')
|
|
49
50
|
const page = await fetch(running.url)
|
|
50
51
|
const html = await page.text()
|
|
52
|
+
assert.equal(page.url, running.appUrl)
|
|
51
53
|
assert.match(html, /id="who"/)
|
|
52
54
|
assert.doesNotMatch(html, /alert\(1\)/)
|
|
53
55
|
assert.match(html, /data-set="local" aria-pressed="true"/)
|
|
@@ -127,6 +129,81 @@ test('serve reloads local source and keeps prod records off disk', async () => {
|
|
|
127
129
|
}
|
|
128
130
|
})
|
|
129
131
|
|
|
132
|
+
test('serve routes generated recipe actions to local records in local mode', async () => {
|
|
133
|
+
const cwd = project()
|
|
134
|
+
const appId = '00000000-0000-4000-8000-000000000001'
|
|
135
|
+
const calls = []
|
|
136
|
+
const running = await startServe({ cwd, abstractId: appId, silent: true,
|
|
137
|
+
hostedFetch: async (path) => { calls.push(path); throw new Error('Local actions must not reach production') },
|
|
138
|
+
})
|
|
139
|
+
try {
|
|
140
|
+
const origin = new URL(running.url).origin
|
|
141
|
+
const invoke = async (name, input) => fetch(`${running.url}api/v1/apps/${appId}/actions/${name}/invoke`, {
|
|
142
|
+
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
|
143
|
+
})
|
|
144
|
+
const create = await invoke('private.create', { collection: 'default', visibility: 'private',
|
|
145
|
+
body: { thing_to_track: 'QA local entry', date: '2026-10-22', done: false } })
|
|
146
|
+
assert.equal(create.status, 200)
|
|
147
|
+
const created = await create.json()
|
|
148
|
+
assert.equal(created.status, 'ok')
|
|
149
|
+
assert.equal(created.output.body.thing_to_track, 'QA local entry')
|
|
150
|
+
assert.equal(created.output.visibility, 'private')
|
|
151
|
+
|
|
152
|
+
const query = await invoke('private.query', { collection: 'default', visibility: 'private', limit: 100 })
|
|
153
|
+
assert.deepEqual((await query.json()).output.map(row => row.body.thing_to_track), ['QA local entry'])
|
|
154
|
+
assert.match(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), /QA local entry/)
|
|
155
|
+
|
|
156
|
+
const remove = await invoke('private.delete', { collection: 'default', visibility: 'private',
|
|
157
|
+
id: created.output.id, expectedRevision: created.output.revision })
|
|
158
|
+
assert.equal((await remove.json()).status, 'ok')
|
|
159
|
+
assert.equal((await invoke('private.query', { collection: 'default', visibility: 'private', limit: 100 })
|
|
160
|
+
.then(response => response.json())).output.length, 0)
|
|
161
|
+
assert.equal(readFileSync(join(cwd, 'private.ndjson'), 'utf8'), '')
|
|
162
|
+
|
|
163
|
+
const item = await invoke('items.add', { collection: 'items', visibility: 'shared', body: { title: 'QA milk' } })
|
|
164
|
+
const itemCreated = (await item.json()).output
|
|
165
|
+
assert.equal(itemCreated.collection, 'items')
|
|
166
|
+
const itemQuery = await invoke('records.query', { collection: 'items', visibility: 'shared', limit: 100 })
|
|
167
|
+
assert.equal((await itemQuery.json()).output[0].body.title, 'QA milk')
|
|
168
|
+
const itemDone = await invoke('items.done', { collection: 'items', visibility: 'shared', id: itemCreated.id,
|
|
169
|
+
expectedRevision: itemCreated.revision, body: { done: true } })
|
|
170
|
+
assert.equal((await itemDone.json()).output.body.done, true)
|
|
171
|
+
const itemDelete = await invoke('items.delete', { collection: 'items', visibility: 'shared', id: itemCreated.id,
|
|
172
|
+
expectedRevision: itemCreated.revision + 1 })
|
|
173
|
+
assert.equal((await itemDelete.json()).status, 'ok')
|
|
174
|
+
|
|
175
|
+
const signup = await invoke('form.submit', { collection: 'form', visibility: 'shared', body: { email: 'qa@example.invalid' } })
|
|
176
|
+
assert.equal((await signup.json()).output.collection, 'form')
|
|
177
|
+
assert.match(readFileSync(join(cwd, 'data.ndjson'), 'utf8'), /qa@example\.invalid/)
|
|
178
|
+
assert.deepEqual(calls, [])
|
|
179
|
+
} finally { await running.close() }
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('serve proxies generated recipe actions to the selected hosted environment in live mode', async () => {
|
|
183
|
+
const appId = '00000000-0000-4000-8000-000000000001'
|
|
184
|
+
const calls = []
|
|
185
|
+
const running = await startServe({ cwd: project(), abstractId: appId, silent: true, environment: 'preview',
|
|
186
|
+
hostedFetch: async (path, init) => {
|
|
187
|
+
calls.push({ path, init })
|
|
188
|
+
return { status: 'ok', output: [{ id: 'hosted-entry' }] }
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
try {
|
|
192
|
+
const origin = new URL(running.url).origin
|
|
193
|
+
const mode = await fetch(`${origin}/__amorphous/mode`, { method: 'POST',
|
|
194
|
+
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
195
|
+
const response = await fetch(`${origin}/api/v1/apps/${appId}/actions/private.query/invoke`, {
|
|
196
|
+
method: 'POST', headers: { Origin: origin, Cookie: cookieFrom(mode), 'Content-Type': 'application/json',
|
|
197
|
+
'Idempotency-Key': 'recipe-query-1' }, body: JSON.stringify({ collection: 'default', visibility: 'private', limit: 10 }),
|
|
198
|
+
})
|
|
199
|
+
assert.equal(response.status, 200)
|
|
200
|
+
assert.deepEqual(await response.json(), { status: 'ok', output: [{ id: 'hosted-entry' }] })
|
|
201
|
+
assert.equal(calls.length, 1)
|
|
202
|
+
assert.equal(calls[0].path, `/api/v1/apps/${appId}/actions/private.query/invoke?environment=preview`)
|
|
203
|
+
assert.equal(calls[0].init.headers['Idempotency-Key'], 'recipe-query-1')
|
|
204
|
+
} finally { await running.close() }
|
|
205
|
+
})
|
|
206
|
+
|
|
130
207
|
test('serve live mode invokes hosted actions with no pass cookie', async () => {
|
|
131
208
|
const cwd = project()
|
|
132
209
|
const calls = []
|
|
@@ -144,7 +221,7 @@ test('serve live mode invokes hosted actions with no pass cookie', async () => {
|
|
|
144
221
|
},
|
|
145
222
|
})
|
|
146
223
|
try {
|
|
147
|
-
const origin = running.url.
|
|
224
|
+
const origin = new URL(running.url).origin
|
|
148
225
|
const mode = await fetch(`${running.url}__amorphous/mode`, { method: 'POST',
|
|
149
226
|
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
150
227
|
assert.equal(mode.status, 204)
|
|
@@ -177,7 +254,7 @@ test('serve continues both visibilities with a nondefault hosted page limit', as
|
|
|
177
254
|
},
|
|
178
255
|
})
|
|
179
256
|
try {
|
|
180
|
-
const origin = running.url.
|
|
257
|
+
const origin = new URL(running.url).origin
|
|
181
258
|
const mode = await fetch(`${running.url}__amorphous/mode`, { method: 'POST',
|
|
182
259
|
headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'prod' }) })
|
|
183
260
|
const found = await (await fetch(`${running.url}__amorphous/data`,
|
|
@@ -229,7 +306,7 @@ test('serve exposes scoped hosted undo and keeps local undo hosted-only', async
|
|
|
229
306
|
},
|
|
230
307
|
})
|
|
231
308
|
try {
|
|
232
|
-
const origin = running.url.
|
|
309
|
+
const origin = new URL(running.url).origin
|
|
233
310
|
const localHistory = await fetch(`${running.url}__amorphous/recovery/${recordId}`)
|
|
234
311
|
assert.deepEqual(await localHistory.json(), { revisions: [] })
|
|
235
312
|
const localUndo = await fetch(`${running.url}__amorphous/recovery/${recordId}`, {
|
package/package.json
CHANGED
package/templates.json
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"board": {
|
|
17
17
|
"label": "Board",
|
|
18
18
|
"description": "A small kanban board with shared cards",
|
|
19
|
-
"html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">TEAM BOARD</p><h1>Project board</h1><p class=\"muted\">Move cards across the columns as work progresses.</p></header>\n <form id=\"add-form\"><input id=\"new-card\" aria-label=\"New card\" placeholder=\"Add a card…\" maxlength=\"180\" required><button>Add card</button></form>\n <section id=\"columns\" class=\"columns\"></section>\n</main>",
|
|
20
|
-
"css": "* { box-sizing: border-box; } body { margin: 0; background: #f4f5f7; color: #20242b; font: 15px/1.5 system-ui, sans-serif; } .shell { max-width: 1120px; margin: 7vh auto; padding: 28px; } .eyebrow { color: #727985; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.05em; } .muted { color: #727985; } form { display: flex; gap: 8px; max-width: 580px; margin: 24px 0; } input { min-width: 0; flex: 1; padding: 11px 13px; border: 1px solid #d7dbe2; border-radius: 8px; font: inherit; } button { padding: 9px 14px; border: 0; border-radius: 8px; background: #29313d; color: #fff; font: inherit; cursor: pointer; } .columns { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; } .column { min-height: 190px; padding: 12px; border: 1px solid #e0e3e9; border-radius: 12px; background: #e9ecf1; } .column h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; } .card { display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 11px; border: 1px solid #e2e4e9; border-radius: 9px; background: white; } .card span { flex: 1; overflow-wrap: anywhere; } .card select { max-width: 105px; padding: 5px; border: 1px solid #d7dbe2; border-radius: 6px; background: white; font: inherit; font-size: 12px; } @media (max-width: 760px) { .columns { grid-template-columns: repeat(3, minmax(250px, 1fr)); } }",
|
|
19
|
+
"html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">TEAM BOARD</p><h1>Project board</h1><p class=\"muted\">Move cards across the columns as work progresses.</p></header>\n <p id=\"columns-scroll-hint\" class=\"muted scroll-hint\">Scroll sideways to see all columns.</p>\n <form id=\"add-form\"><input id=\"new-card\" aria-label=\"New card\" placeholder=\"Add a card…\" maxlength=\"180\" required><button>Add card</button></form>\n <section id=\"columns\" class=\"columns\" aria-describedby=\"columns-scroll-hint\"></section>\n</main>",
|
|
20
|
+
"css": "* { box-sizing: border-box; } body { margin: 0; background: #f4f5f7; color: #20242b; font: 15px/1.5 system-ui, sans-serif; } .shell { max-width: 1120px; margin: 7vh auto; padding: 28px; } .eyebrow { color: #727985; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.05em; } .muted { color: #727985; } .scroll-hint { display: none; } form { display: flex; gap: 8px; max-width: 580px; margin: 24px 0; } input { min-width: 0; flex: 1; padding: 11px 13px; border: 1px solid #d7dbe2; border-radius: 8px; font: inherit; } button { padding: 9px 14px; border: 0; border-radius: 8px; background: #29313d; color: #fff; font: inherit; cursor: pointer; } .columns { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; align-items: start; overflow-x: auto; } .column { min-height: 190px; padding: 12px; border: 1px solid #e0e3e9; border-radius: 12px; background: #e9ecf1; } .column h2 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; } .card { display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 11px; border: 1px solid #e2e4e9; border-radius: 9px; background: white; } .card span { flex: 1; overflow-wrap: anywhere; } .card select { max-width: 105px; padding: 5px; border: 1px solid #d7dbe2; border-radius: 6px; background: white; font: inherit; font-size: 12px; } @media (max-width: 760px) { .scroll-hint { display: block; margin: -8px 0 14px; } .columns { grid-template-columns: repeat(3, minmax(250px, 1fr)); } }",
|
|
21
21
|
"js": "const stages = ['To do', 'In progress', 'Done']\nconst form = document.querySelector('#add-form')\nconst input = document.querySelector('#new-card')\nconst columns = document.querySelector('#columns')\n\nasync function render() {\n const rows = (await amorphous.data.find()).filter(row => row.kind === 'board-card')\n columns.replaceChildren()\n for (const stage of stages) {\n const column = document.createElement('section')\n column.className = 'column'\n const heading = document.createElement('h2')\n heading.textContent = `${stage} · ${rows.filter(row => (row.stage || stages[0]) === stage).length}`\n column.append(heading)\n for (const row of rows.filter(item => (item.stage || stages[0]) === stage)) {\n const card = document.createElement('article')\n card.className = 'card'\n const text = document.createElement('span')\n text.textContent = row.text\n const select = document.createElement('select')\n select.setAttribute('aria-label', `Move ${row.text}`)\n for (const optionText of stages) {\n const option = document.createElement('option')\n option.value = optionText\n option.textContent = optionText\n option.selected = optionText === (row.stage || stages[0])\n select.append(option)\n }\n select.addEventListener('change', async () => {\n await amorphous.data.replace(row.id, { ...row, stage: select.value })\n await render()\n })\n card.append(text, select)\n column.append(card)\n }\n columns.append(column)\n }\n}\n\nform.addEventListener('submit', async event => {\n event.preventDefault()\n const text = input.value.trim()\n if (!text) return\n await amorphous.data.insert({ kind: 'board-card', text, stage: stages[0] })\n input.value = ''\n await render()\n})\n\nrender()"
|
|
22
22
|
},
|
|
23
23
|
"landing": {
|