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 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.
@@ -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'), /id="who"/)
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 data = await apiFetch('/api/abstracts', { method: 'POST' })
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
- if (sub === 'invite') return cmdFolderInvite(tail)
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|invite|remove|people> …
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
- folder invite <folder> --email <email>
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.12",
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": {
@@ -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
+ }