amorphous-cli 0.1.12 → 0.1.13
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/README.md +2 -0
- package/lib/agent-contract.mjs +6 -2
- package/lib/agent-contract.test.mjs +12 -1
- package/lib/commands.mjs +18 -86
- package/lib/help.mjs +8 -10
- package/mcp.mjs +11 -0
- package/package.json +2 -1
- package/skill/amorphous/SKILL.md +2 -1
- package/templates.json +30 -0
package/README.md
CHANGED
|
@@ -5,3 +5,5 @@ Make whatever you want, yourself, with your own agent, or with others. And whate
|
|
|
5
5
|
```bash
|
|
6
6
|
npx amorphous-cli@latest setup
|
|
7
7
|
```
|
|
8
|
+
|
|
9
|
+
Create a purpose-built starter with `amorphous-cli create --template list|board|landing` (or omit the flag for a blank page). Collaborators can run `amorphous-cli recommend --note "…"` to send local source to the owner for review. The owner can publish a recommendation live or decline it in the abstract.
|
package/lib/agent-contract.mjs
CHANGED
|
@@ -53,12 +53,16 @@ export function syncProjectContract(cwd) {
|
|
|
53
53
|
return 'refreshed'
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function writeStarter(cwd) {
|
|
56
|
+
export function writeStarter(cwd, template = 'blank') {
|
|
57
57
|
const assets = join(skillRoot, 'assets')
|
|
58
|
+
const templates = JSON.parse(readFileSync(join(dirname(skillRoot), '../templates.json'), 'utf8'))
|
|
59
|
+
const selected = templates[template]
|
|
60
|
+
if (!selected) throw new Error(`Unknown template “${template}”. Choose blank, list, board, or landing.`)
|
|
61
|
+
const starterFiles = { 'index.html': selected.html, 'styles.css': selected.css, 'app.js': selected.js }
|
|
58
62
|
for (const name of ['index.html', 'styles.css', 'app.js']) {
|
|
59
63
|
const dest = join(cwd, name)
|
|
60
64
|
if (!existsSync(dest)) {
|
|
61
|
-
writeFileSync(dest, readFileSync(join(assets, name)))
|
|
65
|
+
writeFileSync(dest, starterFiles[name] ?? readFileSync(join(assets, name)))
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
for (const name of ['data.ndjson', 'private.ndjson']) {
|
|
@@ -31,7 +31,18 @@ test('setup copies the skill, and an edited contract is left alone', () => {
|
|
|
31
31
|
const project = mkdtempSync(join(tmpdir(), 'amorphous-page-'))
|
|
32
32
|
assert.equal(syncProjectContract(project), 'wrote')
|
|
33
33
|
writeStarter(project)
|
|
34
|
-
assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /
|
|
34
|
+
assert.match(readFileSync(join(project, 'index.html'), 'utf8'), /Make this abstract your own/)
|
|
35
|
+
assert.equal(readFileSync(join(project, 'styles.css'), 'utf8').length > 0, true)
|
|
36
|
+
const board = mkdtempSync(join(tmpdir(), 'amorphous-board-'))
|
|
37
|
+
writeStarter(board, 'board')
|
|
38
|
+
assert.match(readFileSync(join(board, 'index.html'), 'utf8'), /Project board/)
|
|
39
|
+
assert.match(readFileSync(join(board, 'app.js'), 'utf8'), /board-card/)
|
|
40
|
+
for (const [template, expected] of [['list', 'SHARED LIST'], ['landing', 'A CLEAR, HUMAN INTRODUCTION']]) {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), `amorphous-${template}-`))
|
|
42
|
+
writeStarter(dir, template)
|
|
43
|
+
assert.match(readFileSync(join(dir, 'index.html'), 'utf8'), new RegExp(expected))
|
|
44
|
+
}
|
|
45
|
+
assert.throws(() => writeStarter(project, 'unknown'), /Unknown template/)
|
|
35
46
|
assert.match(readFileSync(join(project, 'AGENTS.md'), 'utf8'), new RegExp(`amorphous-contract: ${CONTRACT_VERSION}`))
|
|
36
47
|
|
|
37
48
|
const edited = readFileSync(join(project, 'AGENTS.md'), 'utf8') + '\nEdited.\n'
|
package/lib/commands.mjs
CHANGED
|
@@ -215,7 +215,8 @@ They tell you when to push, preview, publish, or invite.`)
|
|
|
215
215
|
async function cmdCreate(cwd, rest) {
|
|
216
216
|
const { flags } = parseFlags(rest)
|
|
217
217
|
if (flags.help) return printHelp('create')
|
|
218
|
-
const
|
|
218
|
+
const template = typeof flags.template === 'string' ? flags.template : 'blank'
|
|
219
|
+
const data = await apiFetch('/api/abstracts', { method: 'POST', body: JSON.stringify({ template }) })
|
|
219
220
|
const id = data.id
|
|
220
221
|
writeMeta(cwd, id)
|
|
221
222
|
const name = typeof flags.name === 'string' ? flags.name.trim() : ''
|
|
@@ -233,7 +234,7 @@ async function cmdCreate(cwd, rest) {
|
|
|
233
234
|
body: JSON.stringify({ collection_id: folder.id }),
|
|
234
235
|
})
|
|
235
236
|
}
|
|
236
|
-
writeStarter(cwd)
|
|
237
|
+
writeStarter(cwd, template)
|
|
237
238
|
syncProjectContract(cwd)
|
|
238
239
|
if (jsonMode(flags)) printJson({ id, name: name || 'Untitled' })
|
|
239
240
|
else {
|
|
@@ -469,6 +470,18 @@ async function cmdPublish(cwd, rest) {
|
|
|
469
470
|
else console.log(live ? 'It’s live.' : 'Published.')
|
|
470
471
|
}
|
|
471
472
|
|
|
473
|
+
async function cmdRecommend(cwd, rest) {
|
|
474
|
+
const { flags } = parseFlags(rest)
|
|
475
|
+
if (flags.help) return printHelp('recommend')
|
|
476
|
+
const id = requireMeta(cwd)
|
|
477
|
+
const { html, css, js } = readProjectFiles(cwd)
|
|
478
|
+
const result = await apiFetch(`/api/abstracts/${id}/recommendations`, {
|
|
479
|
+
method: 'POST', body: JSON.stringify({ html, css, js, note: typeof flags.note === 'string' ? flags.note : '' }),
|
|
480
|
+
})
|
|
481
|
+
if (jsonMode(flags)) printJson(result)
|
|
482
|
+
else console.log('Recommendation sent to the abstract owner.')
|
|
483
|
+
}
|
|
484
|
+
|
|
472
485
|
async function cmdLive(cwd, rest) {
|
|
473
486
|
const { flags, positional } = parseFlags(rest)
|
|
474
487
|
if (flags.help) return printHelp('live')
|
|
@@ -520,7 +533,6 @@ async function cmdList(rest) {
|
|
|
520
533
|
if (folder) params.set('collection', String(folder))
|
|
521
534
|
if (flags.tag) params.set('tag', String(flags.tag))
|
|
522
535
|
if (flags.search) params.set('search', String(flags.search))
|
|
523
|
-
if (flags.space) params.set('space', String(flags.space))
|
|
524
536
|
for (const key of ['starred', 'archived', 'owned', 'member', 'open', 'closed']) {
|
|
525
537
|
if (flags[key]) params.set(key, 'true')
|
|
526
538
|
}
|
|
@@ -703,58 +715,6 @@ async function cmdFolderMove(rest) {
|
|
|
703
715
|
console.log('Moved folder.')
|
|
704
716
|
}
|
|
705
717
|
|
|
706
|
-
async function cmdFolderInvite(rest) {
|
|
707
|
-
const { flags, positional } = parseFlags(rest)
|
|
708
|
-
const token = positional[0]
|
|
709
|
-
const email = flags.email || positional[1]
|
|
710
|
-
if (!token || !email) throw new Error('Pass a folder and --email <email>.')
|
|
711
|
-
const folder = await resolveFolder(token)
|
|
712
|
-
await apiFetch(`/api/organise/collections/${folder.id}/invite`, {
|
|
713
|
-
method: 'POST',
|
|
714
|
-
body: JSON.stringify({ email }),
|
|
715
|
-
})
|
|
716
|
-
console.log('Added.')
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
async function cmdFolderRemove(rest) {
|
|
720
|
-
const { flags, positional } = parseFlags(rest)
|
|
721
|
-
const token = positional[0]
|
|
722
|
-
const who = flags.email || positional[1]
|
|
723
|
-
if (!token || !who) throw new Error('Pass a folder and an email or id.')
|
|
724
|
-
const folder = await resolveFolder(token)
|
|
725
|
-
const body = /@/.test(who) ? { email: who } : { userId: who }
|
|
726
|
-
await apiFetch(`/api/organise/collections/${folder.id}/revoke`, {
|
|
727
|
-
method: 'POST',
|
|
728
|
-
body: JSON.stringify(body),
|
|
729
|
-
})
|
|
730
|
-
console.log('Removed.')
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
async function cmdFolderPeople(rest) {
|
|
734
|
-
const { flags, positional } = parseFlags(rest)
|
|
735
|
-
const token = positional[0]
|
|
736
|
-
if (!token) throw new Error('Pass a folder.')
|
|
737
|
-
const folder = await resolveFolder(token)
|
|
738
|
-
const share = await apiFetch(`/api/organise/collections/${folder.id}`)
|
|
739
|
-
const people = (share.people || []).map((m) => ({
|
|
740
|
-
id: m.user_id,
|
|
741
|
-
name: m.name,
|
|
742
|
-
access: accessLabel(m.role),
|
|
743
|
-
role: m.role,
|
|
744
|
-
}))
|
|
745
|
-
if (jsonMode(flags)) {
|
|
746
|
-
printJson({ folder: folder.path, people })
|
|
747
|
-
return
|
|
748
|
-
}
|
|
749
|
-
if (!people.length) {
|
|
750
|
-
console.log('No people yet.')
|
|
751
|
-
return
|
|
752
|
-
}
|
|
753
|
-
for (const p of people) {
|
|
754
|
-
console.log(`${p.name} · ${p.access} · ${p.id}`)
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
718
|
async function cmdFolder(rest) {
|
|
759
719
|
const { flags } = parseFlags(rest)
|
|
760
720
|
if (flags.help || !rest[0]) return printHelp('folder')
|
|
@@ -762,33 +722,7 @@ async function cmdFolder(rest) {
|
|
|
762
722
|
if (sub === 'new') return cmdFolderNew(tail)
|
|
763
723
|
if (sub === 'list') return cmdFolderList(tail)
|
|
764
724
|
if (sub === 'move') return cmdFolderMove(tail)
|
|
765
|
-
|
|
766
|
-
if (sub === 'remove') return cmdFolderRemove(tail)
|
|
767
|
-
if (sub === 'people') return cmdFolderPeople(tail)
|
|
768
|
-
throw new Error('folder needs new, list, move, invite, remove, or people.')
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
async function cmdSpaceNew(rest) {
|
|
772
|
-
const { flags } = parseFlags(rest)
|
|
773
|
-
const name = flags.name || rest.find((a) => !a.startsWith('--'))
|
|
774
|
-
if (!name) throw new Error('Use folder new instead.')
|
|
775
|
-
const data = await apiFetch('/api/spaces', {
|
|
776
|
-
method: 'POST',
|
|
777
|
-
body: JSON.stringify({ name }),
|
|
778
|
-
})
|
|
779
|
-
console.log(data.id, data.name)
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async function cmdSpaceMember(rest) {
|
|
783
|
-
const { flags, positional } = parseFlags(rest)
|
|
784
|
-
const spaceId = flags.space || positional[0]
|
|
785
|
-
const email = flags.email || positional[1]
|
|
786
|
-
if (!spaceId || !email) throw new Error('Use folder invite instead.')
|
|
787
|
-
await apiFetch(`/api/spaces/${spaceId}/members`, {
|
|
788
|
-
method: 'POST',
|
|
789
|
-
body: JSON.stringify({ email, role: flags.role || 'member' }),
|
|
790
|
-
})
|
|
791
|
-
console.log('Added.')
|
|
725
|
+
throw new Error('folder needs new, list, or move.')
|
|
792
726
|
}
|
|
793
727
|
|
|
794
728
|
export async function runCommand(argv, cwd = process.cwd()) {
|
|
@@ -827,6 +761,8 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
827
761
|
return cmdServe(cwd, rest)
|
|
828
762
|
case 'publish':
|
|
829
763
|
return cmdPublish(cwd, rest)
|
|
764
|
+
case 'recommend':
|
|
765
|
+
return cmdRecommend(cwd, rest)
|
|
830
766
|
case 'live':
|
|
831
767
|
return cmdLive(cwd, rest)
|
|
832
768
|
case 'visibility':
|
|
@@ -853,10 +789,6 @@ export async function runCommand(argv, cwd = process.cwd()) {
|
|
|
853
789
|
return cmdFolder(rest)
|
|
854
790
|
case 'rename':
|
|
855
791
|
return cmdRename(cwd, rest)
|
|
856
|
-
case 'space':
|
|
857
|
-
if (rest[0] === 'new') return cmdSpaceNew(rest.slice(1))
|
|
858
|
-
if (rest[0] === 'member') return cmdSpaceMember(rest.slice(1))
|
|
859
|
-
throw new Error('Use folder instead.')
|
|
860
792
|
default:
|
|
861
793
|
throw new Error(`Unknown command “${raw}”. Run amorphous-cli --help.`)
|
|
862
794
|
}
|
package/lib/help.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const ROOT_HELP = `Amorphous
|
|
|
11
11
|
pull Pull draft and records to this folder
|
|
12
12
|
push Push local files
|
|
13
13
|
publish [--live] Publish a version; --live is Go live
|
|
14
|
+
recommend [--note <text>] Send this draft to the owner for review
|
|
14
15
|
live [abstract] Make a version live
|
|
15
16
|
visibility anyone|members Anyone can view / Members only
|
|
16
17
|
invite <email> Email an invite and print the link
|
|
@@ -25,9 +26,6 @@ export const ROOT_HELP = `Amorphous
|
|
|
25
26
|
folder new New folder
|
|
26
27
|
folder list Folders
|
|
27
28
|
folder move Move folder
|
|
28
|
-
folder invite Invite to a folder
|
|
29
|
-
folder remove Remove from a folder
|
|
30
|
-
folder people People on a folder
|
|
31
29
|
rename --name Rename
|
|
32
30
|
|
|
33
31
|
Run amorphous-cli <command> --help for flags. Pass --json where you need machine output.
|
|
@@ -54,7 +52,7 @@ Start the local MCP server on stdio.`,
|
|
|
54
52
|
List abstracts. Default view is all (All Abstracts).
|
|
55
53
|
--folder filters to a folder. --tag filters to a tag.`,
|
|
56
54
|
|
|
57
|
-
create: `create [--name <name>] [--folder <name>] [--json]
|
|
55
|
+
create: `create [--name <name>] [--folder <name>] [--template blank|list|board|landing] [--json]
|
|
58
56
|
|
|
59
57
|
New abstract. Writes .amorphous.json in this directory.`,
|
|
60
58
|
|
|
@@ -84,6 +82,10 @@ Push local index.html, styles.css, and app.js.
|
|
|
84
82
|
|
|
85
83
|
Publish a new version. --live also makes it the live version (Go live).`,
|
|
86
84
|
|
|
85
|
+
recommend: `recommend [--note <text>] [--json]
|
|
86
|
+
|
|
87
|
+
Send this folder's HTML, CSS, and JavaScript to the owner as a proposal. It does not change the live version.`,
|
|
88
|
+
|
|
87
89
|
live: `live [abstract] [--version <id>]
|
|
88
90
|
|
|
89
91
|
Make this the live version. Uses the latest published version if you omit --version.`,
|
|
@@ -140,16 +142,12 @@ Unstar.`,
|
|
|
140
142
|
|
|
141
143
|
Suggest folder from the name. Default is a suggestion only. --apply uses it.`,
|
|
142
144
|
|
|
143
|
-
folder: `folder <new|list|move
|
|
145
|
+
folder: `folder <new|list|move> …
|
|
144
146
|
|
|
145
147
|
folder new --name <name> [--parent <id>]
|
|
146
148
|
folder list [--json]
|
|
147
149
|
folder move <folder> --parent <id|none>
|
|
148
|
-
|
|
149
|
-
folder remove <folder> <email|id>
|
|
150
|
-
folder people <folder> [--json]
|
|
151
|
-
|
|
152
|
-
New folder, or move a folder. folder invite adds someone who already has an account. It does not send an email.`,
|
|
150
|
+
New folder, or move a folder. Folders organize your library and do not grant access.`,
|
|
153
151
|
|
|
154
152
|
rename: `rename [abstract] --name <name>
|
|
155
153
|
|
package/mcp.mjs
CHANGED
|
@@ -117,6 +117,17 @@ await serveStdio(() => {
|
|
|
117
117
|
async ({ live }) => call(live ? ['publish', '--live', '--json'] : ['publish', '--json']),
|
|
118
118
|
)
|
|
119
119
|
|
|
120
|
+
server.registerTool(
|
|
121
|
+
'recommend',
|
|
122
|
+
{
|
|
123
|
+
title: 'Recommend a version to the owner',
|
|
124
|
+
description: 'Submit the local HTML, CSS, and JavaScript as a proposal. It stays out of the live version until the owner publishes it.',
|
|
125
|
+
inputSchema: z.object({ note: z.string().max(1000).optional().describe('Context for the owner') }),
|
|
126
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
127
|
+
},
|
|
128
|
+
async ({ note }) => call(note ? ['recommend', '--note', note] : ['recommend']),
|
|
129
|
+
)
|
|
130
|
+
|
|
120
131
|
server.registerTool(
|
|
121
132
|
'invite',
|
|
122
133
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amorphous-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"mcpName": "io.github.amorphous-one/amorphous",
|
|
5
5
|
"description": "Make whatever you want, yourself, with your own agent, or with others.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lib",
|
|
16
16
|
"hooks",
|
|
17
17
|
"skill",
|
|
18
|
+
"templates.json",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|
package/skill/amorphous/SKILL.md
CHANGED
|
@@ -43,7 +43,7 @@ They tell you when to push, preview, publish, or invite. Do those when they ask.
|
|
|
43
43
|
Install the CLI once with `npm install -g amorphous-cli@latest`, then call it as `amorphous-cli <command>`. If a command prints `Run: npm install -g amorphous-cli@latest && amorphous-cli setup`, run that line, then repeat the command.
|
|
44
44
|
|
|
45
45
|
1. If you are not signed in, run `amorphous-cli login`. Show the person the link it prints and ask them to finish in the browser. Do not ask for an email or a code.
|
|
46
|
-
2. `amorphous-cli create --name "..."` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there.
|
|
46
|
+
2. `amorphous-cli create --name "..." [--template blank|list|board|landing]` in a folder, or `amorphous-cli pull` when `.amorphous.json` is already there. The list, board, and landing starters are working examples, not just mockups.
|
|
47
47
|
3. Edit the files to match what they asked for. `me` and `names` are how the abstract knows who is here.
|
|
48
48
|
4. Prefer `amorphous-cli serve` while you edit. Open that URL and leave it running. It reloads when the files change, and it is the easiest way to check the work. Records on that page start in this folder. Switch the page to prod to read and write the abstract's records. You can push and preview without it.
|
|
49
49
|
|
|
@@ -52,6 +52,7 @@ When they ask:
|
|
|
52
52
|
- `amorphous-cli push` sends the source. `amorphous-cli push --data` also sends the records in this folder.
|
|
53
53
|
- `amorphous-cli preview` and open that URL.
|
|
54
54
|
- `amorphous-cli publish --live`
|
|
55
|
+
- Collaborators who should propose source for the owner to review can use `amorphous-cli recommend --note "..."`. It does not change the live abstract; the owner reviews and chooses Publish live or Decline.
|
|
55
56
|
- `amorphous-cli invite <email> use` or `edit` or `view`. It emails them a link and prints that link. They open it and sign in with that email.
|
|
56
57
|
- `amorphous-cli notify` emails members that the published version changed. It does not publish. Do that only when they ask.
|
|
57
58
|
|
package/templates.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"blank": {
|
|
3
|
+
"label": "Blank",
|
|
4
|
+
"description": "A clean starting point",
|
|
5
|
+
"html": "<main>\n <h1>Untitled</h1>\n <p>Make this abstract your own.</p>\n</main>",
|
|
6
|
+
"css": "body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: #18181b; background: #fafafa; }\nmain { max-width: 720px; margin: 12vh auto; padding: 24px; }\nh1 { letter-spacing: -0.04em; }",
|
|
7
|
+
"js": ""
|
|
8
|
+
},
|
|
9
|
+
"list": {
|
|
10
|
+
"label": "List",
|
|
11
|
+
"description": "A shared checklist everyone can update",
|
|
12
|
+
"html": "<main class=\"shell\">\n <header><p class=\"eyebrow\">SHARED LIST</p><h1>Things to do</h1><p class=\"muted\">Add an item or check one off. Everyone sees the same list.</p></header>\n <form id=\"add-form\"><input id=\"new-item\" aria-label=\"New item\" placeholder=\"Add something…\" maxlength=\"180\" required><button>Add</button></form>\n <p id=\"empty\" class=\"muted\">Your list is empty.</p><ul id=\"items\"></ul>\n</main>",
|
|
13
|
+
"css": "* { box-sizing: border-box; } body { margin: 0; background: #f5f4f0; color: #20201e; font: 16px/1.5 system-ui, sans-serif; } .shell { max-width: 680px; margin: 8vh auto; padding: 32px; background: #fff; border: 1px solid #e8e6e0; border-radius: 18px; } .eyebrow { color: #77746c; font-size: 11px; font-weight: 700; letter-spacing: .14em; } h1 { margin: 4px 0; font-size: clamp(32px, 8vw, 52px); letter-spacing: -.055em; } .muted { color: #77746c; } form { display: flex; gap: 8px; margin: 28px 0 18px; } input { min-width: 0; flex: 1; padding: 12px 14px; border: 1px solid #d8d5cd; border-radius: 9px; font: inherit; } button { padding: 10px 18px; border: 0; border-radius: 9px; background: #242421; color: white; font: inherit; cursor: pointer; } ul { display: grid; gap: 8px; padding: 0; list-style: none; } li { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #eeece7; border-radius: 9px; } li.done span { color: #929087; text-decoration: line-through; } li span { flex: 1; overflow-wrap: anywhere; } li button { padding: 5px 8px; color: #66635d; background: transparent; }",
|
|
14
|
+
"js": "const form = document.querySelector('#add-form')\nconst input = document.querySelector('#new-item')\nconst list = document.querySelector('#items')\nconst empty = document.querySelector('#empty')\n\nfunction render(rows) {\n list.replaceChildren()\n empty.hidden = rows.length > 0\n for (const row of rows.filter(item => item.kind === 'list-item')) {\n const li = document.createElement('li')\n if (row.done) li.classList.add('done')\n const check = document.createElement('input')\n check.type = 'checkbox'\n check.checked = Boolean(row.done)\n check.setAttribute('aria-label', `Mark ${row.text} complete`)\n check.addEventListener('change', async () => {\n await amorphous.data.replace(row.id, { ...row, done: check.checked })\n render(await amorphous.data.find())\n })\n const text = document.createElement('span')\n text.textContent = row.text\n const remove = document.createElement('button')\n remove.type = 'button'\n remove.textContent = 'Remove'\n remove.addEventListener('click', async () => {\n // The bridge intentionally has no delete method; mark removed items instead.\n await amorphous.data.replace(row.id, { ...row, removed: true })\n render(await amorphous.data.find())\n })\n if (row.removed) continue\n li.append(check, text, remove)\n list.append(li)\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: 'list-item', text, done: false })\n input.value = ''\n render(await amorphous.data.find())\n})\n\nrender(await amorphous.data.find())"
|
|
15
|
+
},
|
|
16
|
+
"board": {
|
|
17
|
+
"label": "Board",
|
|
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)); } }",
|
|
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
|
+
},
|
|
23
|
+
"landing": {
|
|
24
|
+
"label": "Landing page",
|
|
25
|
+
"description": "A polished one-page introduction for an idea or project",
|
|
26
|
+
"html": "<main>\n <nav><a class=\"brand\" href=\"#top\">Your project</a><a href=\"#about\">About</a><a class=\"nav-cta\" href=\"#contact\">Get in touch</a></nav>\n <section id=\"top\" class=\"hero\"><p class=\"eyebrow\">A CLEAR, HUMAN INTRODUCTION</p><h1>Make your next good idea easier to find.</h1><p class=\"lead\">Describe what you are building, who it helps, and why it matters. Replace this copy with your own.</p><a class=\"cta\" href=\"#contact\">Start a conversation <span>↗</span></a><div class=\"orb\" aria-hidden=\"true\"></div></section>\n <section id=\"about\" class=\"about\"><p class=\"eyebrow\">WHAT IT IS</p><h2>A simple page for a specific purpose.</h2><div class=\"cards\"><article><b>01</b><h3>Who it is for</h3><p>Name the people you want to reach.</p></article><article><b>02</b><h3>What it does</h3><p>Explain the useful thing you make possible.</p></article><article><b>03</b><h3>Why now</h3><p>Give visitors a reason to care today.</p></article></div></section>\n <footer id=\"contact\"><div><p class=\"eyebrow\">NEXT STEP</p><h2>Have a question?</h2><p>Replace this with your preferred contact link.</p></div><a class=\"cta\" href=\"mailto:hello@example.com\">Say hello <span>↗</span></a></footer>\n</main>",
|
|
27
|
+
"css": "* { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; background: #f8f7f4; color: #20211f; font: 16px/1.55 system-ui, sans-serif; } main { max-width: 1180px; margin: auto; padding: 0 28px; } nav { display: flex; align-items: center; gap: 26px; min-height: 76px; border-bottom: 1px solid #e5e3dc; } nav a { color: inherit; text-decoration: none; font-size: 14px; } nav .brand { margin-right: auto; font-weight: 700; } .nav-cta { padding: 9px 13px; border: 1px solid #d6d3ca; border-radius: 99px; } .hero { position: relative; min-height: 570px; padding: 110px 0 95px; overflow: hidden; } .eyebrow { color: #77786d; font-size: 11px; font-weight: 700; letter-spacing: .15em; } h1 { position: relative; z-index: 1; max-width: 760px; margin: 18px 0; font-size: clamp(48px, 9vw, 88px); line-height: .98; letter-spacing: -.07em; } .lead { position: relative; z-index: 1; max-width: 490px; color: #696a62; font-size: 18px; } .cta { position: relative; z-index: 1; display: inline-flex; gap: 22px; align-items: center; margin-top: 22px; padding: 13px 17px; border-radius: 99px; background: #242622; color: white; text-decoration: none; font-size: 14px; } .orb { position: absolute; right: 4%; bottom: 4%; width: 270px; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #e9c7a6, #c89f7e 48%, #756c61 75%); filter: saturate(.7); opacity: .72; } .about { padding: 65px 0 95px; border-top: 1px solid #e5e3dc; } h2 { max-width: 600px; font-size: clamp(32px, 5vw, 48px); line-height: 1.05; letter-spacing: -.055em; } .cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 42px; } .cards article { padding: 22px; border: 1px solid #e5e3dc; border-radius: 12px; background: #fffefa; } .cards b { color: #9d795c; font-size: 12px; } h3 { margin: 18px 0 4px; } .cards p, footer p:not(.eyebrow) { color: #696a62; } footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 55px; border-top: 1px solid #e5e3dc; } footer h2 { margin: 6px 0; } @media (max-width: 650px) { main { padding: 0 18px; } nav { gap: 14px; } .hero { min-height: 520px; padding-top: 84px; } .orb { right: -40px; width: 210px; } .cards { grid-template-columns: 1fr; } footer { align-items: flex-start; flex-direction: column; } }",
|
|
28
|
+
"js": "// This starter is a static landing page. Replace the copy, links, and sections in index.html.\n"
|
|
29
|
+
}
|
|
30
|
+
}
|