@venturewild/workspace 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -85
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +974 -828
  10. package/server/src/bazaar/index.mjs +88 -88
  11. package/server/src/bazaar/mcp-server.mjs +429 -417
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
  24. package/server/src/canvas/core.mjs +446 -446
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -108
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +3322 -3279
  34. package/server/src/listings-rails.mjs +156 -126
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-presence.mjs +0 -0
  51. package/server/src/workspace-registry.mjs +295 -295
  52. package/server/src/workspaces.mjs +145 -145
  53. package/web/dist/assets/{index-BxTh3dyq.js → index-CSWkWdtM.js} +23 -23
  54. package/web/dist/index.html +1 -1
@@ -1,97 +1,97 @@
1
- // Mock "TickUp" candidate-matching service.
2
- //
3
- // v1 stand-in for the producer's real tool/API (there is no real TickUp). The
4
- // point is the FLOW, not real ML: a deterministic, offline ranker so the wiring
5
- // is demonstrable and the demo looks smart. The built site POSTs { role,
6
- // candidates } here and renders the ranked result.
7
-
8
- const STOPWORDS = new Set([
9
- 'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'at',
10
- 'by', 'plus', 'pace', 'senior', 'junior', 'mid', 'lead', 'staff', 'engineer',
11
- 'developer', 'years', 'year', 'yr', 'yrs', 'experience', 'exp', 'who', 'can',
12
- 'is', 'are', 'be', 'we', 'you', 'your', 'our', 'team', 'role', 'work',
13
- ]);
14
-
15
- function tokenize(text) {
16
- return new Set(
17
- String(text || '')
18
- .toLowerCase()
19
- .replace(/[^a-z0-9+#.]+/g, ' ')
20
- .split(' ')
21
- .map((w) => w.trim())
22
- .filter((w) => w.length >= 2 && !STOPWORDS.has(w)),
23
- );
24
- }
25
-
26
- function parseYears(text) {
27
- const m = String(text || '').match(/(\d+)\s*\+?\s*(?:y|yr|yrs|year|years)\b/i);
28
- return m ? Math.min(parseInt(m[1], 10), 25) : 0;
29
- }
30
-
31
- function parseCandidate(line) {
32
- const raw = String(line || '').trim();
33
- // split "Name — skills" on em/en dash, hyphen, or colon
34
- const m = raw.split(/\s+[—–:-]\s+/);
35
- let name = raw;
36
- let skills = '';
37
- if (m.length >= 2) {
38
- name = m[0].trim();
39
- skills = m.slice(1).join(' ').trim();
40
- }
41
- return { name: name || raw, skills, raw };
42
- }
43
-
44
- const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
45
-
46
- export function matchCandidates({ role, candidates } = {}) {
47
- const roleTokens = tokenize(role);
48
- const list = (Array.isArray(candidates) ? candidates : [])
49
- // Defensive: a candidate may be a string, an object, or junk (null/number).
50
- // `typeof null === 'object'` would crash `null.name`, so coerce carefully.
51
- .map((c) => {
52
- if (typeof c === 'string') return c;
53
- if (c && typeof c === 'object') return `${c.name || ''} — ${c.skills || ''}`;
54
- return '';
55
- })
56
- .map((l) => String(l).trim())
57
- .filter(Boolean)
58
- .filter((l) => /[a-z0-9]/i.test(l)) // drop punctuation-only lines (e.g. "{}" -> " — ")
59
- .slice(0, 500); // bound CPU on a publicly-reachable endpoint
60
-
61
- const ranked = list.map((line) => {
62
- const { name, skills, raw } = parseCandidate(line);
63
- // Skills as a structured list (chips) — the contract returns string[], so a
64
- // consumer's site can render them directly without re-parsing.
65
- const skillList = skills
66
- .split(/[,;/]/)
67
- .map((s) => s.trim())
68
- .filter(Boolean)
69
- .slice(0, 8);
70
- const candTokens = tokenize(`${skills} ${name}`);
71
- const matched = [...roleTokens].filter((t) => candTokens.has(t));
72
- const years = parseYears(raw);
73
- const yearsBonus = Math.min(years, 10) * 2;
74
- let score;
75
- if (matched.length > 0) {
76
- score = clamp(40 + matched.length * 14 + yearsBonus, 45, 98);
77
- } else {
78
- score = clamp(8 + yearsBonus, 5, 35);
79
- }
80
- const top = matched.slice(0, 3);
81
- let why;
82
- if (top.length > 0) {
83
- why =
84
- `Strong on ${top.join(', ')}` +
85
- (years ? ` · ${years}y experience` : '') +
86
- (matched.length > 3 ? ` · +${matched.length - 3} more matches` : '');
87
- } else {
88
- why = years
89
- ? `Limited overlap with the role, but ${years}y of experience`
90
- : 'Limited overlap with the role';
91
- }
92
- return { name, skills: skillList, score: Math.round(score), why, matched };
93
- });
94
-
95
- ranked.sort((a, b) => b.score - a.score);
96
- return { role: String(role || ''), count: ranked.length, ranked };
97
- }
1
+ // Mock "TickUp" candidate-matching service.
2
+ //
3
+ // v1 stand-in for the producer's real tool/API (there is no real TickUp). The
4
+ // point is the FLOW, not real ML: a deterministic, offline ranker so the wiring
5
+ // is demonstrable and the demo looks smart. The built site POSTs { role,
6
+ // candidates } here and renders the ranked result.
7
+
8
+ const STOPWORDS = new Set([
9
+ 'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'at',
10
+ 'by', 'plus', 'pace', 'senior', 'junior', 'mid', 'lead', 'staff', 'engineer',
11
+ 'developer', 'years', 'year', 'yr', 'yrs', 'experience', 'exp', 'who', 'can',
12
+ 'is', 'are', 'be', 'we', 'you', 'your', 'our', 'team', 'role', 'work',
13
+ ]);
14
+
15
+ function tokenize(text) {
16
+ return new Set(
17
+ String(text || '')
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9+#.]+/g, ' ')
20
+ .split(' ')
21
+ .map((w) => w.trim())
22
+ .filter((w) => w.length >= 2 && !STOPWORDS.has(w)),
23
+ );
24
+ }
25
+
26
+ function parseYears(text) {
27
+ const m = String(text || '').match(/(\d+)\s*\+?\s*(?:y|yr|yrs|year|years)\b/i);
28
+ return m ? Math.min(parseInt(m[1], 10), 25) : 0;
29
+ }
30
+
31
+ function parseCandidate(line) {
32
+ const raw = String(line || '').trim();
33
+ // split "Name — skills" on em/en dash, hyphen, or colon
34
+ const m = raw.split(/\s+[—–:-]\s+/);
35
+ let name = raw;
36
+ let skills = '';
37
+ if (m.length >= 2) {
38
+ name = m[0].trim();
39
+ skills = m.slice(1).join(' ').trim();
40
+ }
41
+ return { name: name || raw, skills, raw };
42
+ }
43
+
44
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
45
+
46
+ export function matchCandidates({ role, candidates } = {}) {
47
+ const roleTokens = tokenize(role);
48
+ const list = (Array.isArray(candidates) ? candidates : [])
49
+ // Defensive: a candidate may be a string, an object, or junk (null/number).
50
+ // `typeof null === 'object'` would crash `null.name`, so coerce carefully.
51
+ .map((c) => {
52
+ if (typeof c === 'string') return c;
53
+ if (c && typeof c === 'object') return `${c.name || ''} — ${c.skills || ''}`;
54
+ return '';
55
+ })
56
+ .map((l) => String(l).trim())
57
+ .filter(Boolean)
58
+ .filter((l) => /[a-z0-9]/i.test(l)) // drop punctuation-only lines (e.g. "{}" -> " — ")
59
+ .slice(0, 500); // bound CPU on a publicly-reachable endpoint
60
+
61
+ const ranked = list.map((line) => {
62
+ const { name, skills, raw } = parseCandidate(line);
63
+ // Skills as a structured list (chips) — the contract returns string[], so a
64
+ // consumer's site can render them directly without re-parsing.
65
+ const skillList = skills
66
+ .split(/[,;/]/)
67
+ .map((s) => s.trim())
68
+ .filter(Boolean)
69
+ .slice(0, 8);
70
+ const candTokens = tokenize(`${skills} ${name}`);
71
+ const matched = [...roleTokens].filter((t) => candTokens.has(t));
72
+ const years = parseYears(raw);
73
+ const yearsBonus = Math.min(years, 10) * 2;
74
+ let score;
75
+ if (matched.length > 0) {
76
+ score = clamp(40 + matched.length * 14 + yearsBonus, 45, 98);
77
+ } else {
78
+ score = clamp(8 + yearsBonus, 5, 35);
79
+ }
80
+ const top = matched.slice(0, 3);
81
+ let why;
82
+ if (top.length > 0) {
83
+ why =
84
+ `Strong on ${top.join(', ')}` +
85
+ (years ? ` · ${years}y experience` : '') +
86
+ (matched.length > 3 ? ` · +${matched.length - 3} more matches` : '');
87
+ } else {
88
+ why = years
89
+ ? `Limited overlap with the role, but ${years}y of experience`
90
+ : 'Limited overlap with the role';
91
+ }
92
+ return { name, skills: skillList, score: Math.round(score), why, matched };
93
+ });
94
+
95
+ ranked.sort((a, b) => b.score - a.score);
96
+ return { role: String(role || ''), count: ranked.length, ranked };
97
+ }
@@ -1,95 +1,95 @@
1
- // Bazaar preview host — static-file helper for serving the agent's build through
2
- // the MAIN wild-workspace server at /preview/* (same-origin, behind the existing
3
- // auth/role middleware). NOT a separate listener: serving same-origin means no
4
- // port to squat (no collision with the user's real dev servers), no mixed-content
5
- // under the public https proxy, and the built page's fetch('./match') is genuinely
6
- // same-origin. The matching service (POST /preview/match) and the live earnings
7
- // broadcast live in index.mjs, which has the activity bus + chat clients.
8
- //
9
- // The build itself is the USER'S PRODUCT and lives in their workspace; this module
10
- // only READS from a build dir. No bazaar bookkeeping is ever written into the
11
- // synced workspace (CLAUDE.md rule #1) — that all lives in ~/.wild-workspace/bazaar.
12
-
13
- import fs from 'node:fs';
14
- import path from 'node:path';
15
- import mime from 'mime-types';
16
-
17
- // Confine the preview build dir to the user's workspace. `dir` comes from the
18
- // agent (preview.json) and is NOT trusted to stay inside the workspace — an
19
- // absolute path or `..` segments would otherwise let /preview/* read anywhere on
20
- // disk (host secrets). Returns the absolute build dir, or null if it escapes.
21
- export function confineBuildDir(workspaceDir, dir) {
22
- if (!dir) return null;
23
- const root = path.resolve(workspaceDir);
24
- const resolved = path.resolve(root, dir);
25
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
26
- return resolved;
27
- }
28
-
29
- // True if `file` (after symlink resolution) stays inside `root` (after symlink
30
- // resolution). Defeats a symlink/junction planted in the build dir that points
31
- // outside it — the lexical guard in resolvePreviewFile follows no symlinks, but
32
- // fs.readFile does.
33
- function realpathInside(file, root) {
34
- try {
35
- const realRoot = fs.realpathSync(root);
36
- const realFile = fs.realpathSync(file);
37
- return realFile === realRoot || realFile.startsWith(realRoot + path.sep);
38
- } catch {
39
- return false; // missing/broken link — refuse
40
- }
41
- }
42
-
43
- // Resolve a request path (everything after /preview/) to an absolute file inside
44
- // buildDir, with a path-traversal guard. Returns null if it escapes buildDir.
45
- export function resolvePreviewFile(buildDir, reqPath) {
46
- const root = path.resolve(buildDir);
47
- const clean = decodeURIComponent(String(reqPath || '').split('?')[0]).replace(/\\/g, '/');
48
- const rel = clean.replace(/^\/+/, '');
49
- const resolved = path.resolve(root, rel || 'index.html');
50
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
51
- return resolved;
52
- }
53
-
54
- // Serve one file from the build dir. Returns { status, contentType, body, headers }.
55
- // body is a Buffer (file bytes) or a string (the waiting/404 page).
56
- export function servePreviewFile(buildDir, reqPath) {
57
- if (!buildDir) {
58
- return { status: 200, contentType: 'text/html', body: WAITING_PAGE };
59
- }
60
- const file = resolvePreviewFile(buildDir, reqPath);
61
- if (!file) return { status: 403, contentType: 'text/plain', body: 'forbidden' };
62
-
63
- let target = file;
64
- try {
65
- if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
66
- } catch {
67
- /* not a dir / doesn't exist — fall through to read */
68
- }
69
-
70
- try {
71
- // Symlink guard: the lexical check above follows no symlinks, but readFileSync
72
- // does — so re-check the real path stays inside the build dir before reading.
73
- if (!realpathInside(target, buildDir)) {
74
- return { status: 403, contentType: 'text/plain', body: 'forbidden' };
75
- }
76
- const body = fs.readFileSync(target);
77
- const contentType = mime.lookup(target) || 'application/octet-stream';
78
- return { status: 200, contentType, body, headers: { 'Cache-Control': 'no-store' } };
79
- } catch {
80
- // SPA-ish fallback: serve index.html for unknown paths if it exists.
81
- try {
82
- const body = fs.readFileSync(path.join(path.resolve(buildDir), 'index.html'));
83
- return { status: 200, contentType: 'text/html', body, headers: { 'Cache-Control': 'no-store' } };
84
- } catch {
85
- return { status: 404, contentType: 'text/html', body: NOT_FOUND_PAGE };
86
- }
87
- }
88
- }
89
-
90
- export const WAITING_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
91
- <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
92
- </head><body><div><p>🛠️ Your build will appear here the moment your agent finishes it.</p></div></body></html>`;
93
-
94
- export const NOT_FOUND_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Not found</title></head>
95
- <body style="font-family:system-ui;padding:40px;color:#555">Nothing here yet.</body></html>`;
1
+ // Bazaar preview host — static-file helper for serving the agent's build through
2
+ // the MAIN wild-workspace server at /preview/* (same-origin, behind the existing
3
+ // auth/role middleware). NOT a separate listener: serving same-origin means no
4
+ // port to squat (no collision with the user's real dev servers), no mixed-content
5
+ // under the public https proxy, and the built page's fetch('./match') is genuinely
6
+ // same-origin. The matching service (POST /preview/match) and the live earnings
7
+ // broadcast live in index.mjs, which has the activity bus + chat clients.
8
+ //
9
+ // The build itself is the USER'S PRODUCT and lives in their workspace; this module
10
+ // only READS from a build dir. No bazaar bookkeeping is ever written into the
11
+ // synced workspace (CLAUDE.md rule #1) — that all lives in ~/.wild-workspace/bazaar.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import mime from 'mime-types';
16
+
17
+ // Confine the preview build dir to the user's workspace. `dir` comes from the
18
+ // agent (preview.json) and is NOT trusted to stay inside the workspace — an
19
+ // absolute path or `..` segments would otherwise let /preview/* read anywhere on
20
+ // disk (host secrets). Returns the absolute build dir, or null if it escapes.
21
+ export function confineBuildDir(workspaceDir, dir) {
22
+ if (!dir) return null;
23
+ const root = path.resolve(workspaceDir);
24
+ const resolved = path.resolve(root, dir);
25
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
26
+ return resolved;
27
+ }
28
+
29
+ // True if `file` (after symlink resolution) stays inside `root` (after symlink
30
+ // resolution). Defeats a symlink/junction planted in the build dir that points
31
+ // outside it — the lexical guard in resolvePreviewFile follows no symlinks, but
32
+ // fs.readFile does.
33
+ function realpathInside(file, root) {
34
+ try {
35
+ const realRoot = fs.realpathSync(root);
36
+ const realFile = fs.realpathSync(file);
37
+ return realFile === realRoot || realFile.startsWith(realRoot + path.sep);
38
+ } catch {
39
+ return false; // missing/broken link — refuse
40
+ }
41
+ }
42
+
43
+ // Resolve a request path (everything after /preview/) to an absolute file inside
44
+ // buildDir, with a path-traversal guard. Returns null if it escapes buildDir.
45
+ export function resolvePreviewFile(buildDir, reqPath) {
46
+ const root = path.resolve(buildDir);
47
+ const clean = decodeURIComponent(String(reqPath || '').split('?')[0]).replace(/\\/g, '/');
48
+ const rel = clean.replace(/^\/+/, '');
49
+ const resolved = path.resolve(root, rel || 'index.html');
50
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
51
+ return resolved;
52
+ }
53
+
54
+ // Serve one file from the build dir. Returns { status, contentType, body, headers }.
55
+ // body is a Buffer (file bytes) or a string (the waiting/404 page).
56
+ export function servePreviewFile(buildDir, reqPath) {
57
+ if (!buildDir) {
58
+ return { status: 200, contentType: 'text/html', body: WAITING_PAGE };
59
+ }
60
+ const file = resolvePreviewFile(buildDir, reqPath);
61
+ if (!file) return { status: 403, contentType: 'text/plain', body: 'forbidden' };
62
+
63
+ let target = file;
64
+ try {
65
+ if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
66
+ } catch {
67
+ /* not a dir / doesn't exist — fall through to read */
68
+ }
69
+
70
+ try {
71
+ // Symlink guard: the lexical check above follows no symlinks, but readFileSync
72
+ // does — so re-check the real path stays inside the build dir before reading.
73
+ if (!realpathInside(target, buildDir)) {
74
+ return { status: 403, contentType: 'text/plain', body: 'forbidden' };
75
+ }
76
+ const body = fs.readFileSync(target);
77
+ const contentType = mime.lookup(target) || 'application/octet-stream';
78
+ return { status: 200, contentType, body, headers: { 'Cache-Control': 'no-store' } };
79
+ } catch {
80
+ // SPA-ish fallback: serve index.html for unknown paths if it exists.
81
+ try {
82
+ const body = fs.readFileSync(path.join(path.resolve(buildDir), 'index.html'));
83
+ return { status: 200, contentType: 'text/html', body, headers: { 'Cache-Control': 'no-store' } };
84
+ } catch {
85
+ return { status: 404, contentType: 'text/html', body: NOT_FOUND_PAGE };
86
+ }
87
+ }
88
+ }
89
+
90
+ export const WAITING_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Preview</title>
91
+ <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f1117;color:#9aa4b2}div{text-align:center;max-width:340px;line-height:1.6}</style>
92
+ </head><body><div><p>🛠️ Your build will appear here the moment your agent finishes it.</p></div></body></html>`;
93
+
94
+ export const NOT_FOUND_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Not found</title></head>
95
+ <body style="font-family:system-ui;padding:40px;color:#555">Nothing here yet.</body></html>`;
@@ -1,23 +1,23 @@
1
- # Marco's feedback form — how to build it
2
-
3
- > Absorb and build in one shot. Single self-contained `feedback-form/index.html`
4
- > (inline CSS + JS), no build step.
5
-
6
- ## Build steps
7
-
8
- 1. Create `feedback-form/index.html`.
9
- 2. The page:
10
- - A **star rating** (1–5, clickable).
11
- - A **comment** textarea.
12
- - A **Submit** button. On submit, store `{rating, comment, ts}` in
13
- `localStorage` and clear the form.
14
- - A **live summary**: average rating across stored responses + the 3 most
15
- recent comments. Updates immediately on submit.
16
- 3. Clean, friendly styling, one accent color. Prefill nothing; show a small empty
17
- state ("Be the first to leave feedback").
18
-
19
- ## Finish
20
-
21
- After writing the files: call **`launch_preview`** `{ "dir": "feedback-form" }`,
22
- then **`record_use`** with recipe id `customer-feedback-form` and a short summary,
23
- then tell the user it's live in the preview (briefly).
1
+ # Marco's feedback form — how to build it
2
+
3
+ > Absorb and build in one shot. Single self-contained `feedback-form/index.html`
4
+ > (inline CSS + JS), no build step.
5
+
6
+ ## Build steps
7
+
8
+ 1. Create `feedback-form/index.html`.
9
+ 2. The page:
10
+ - A **star rating** (1–5, clickable).
11
+ - A **comment** textarea.
12
+ - A **Submit** button. On submit, store `{rating, comment, ts}` in
13
+ `localStorage` and clear the form.
14
+ - A **live summary**: average rating across stored responses + the 3 most
15
+ recent comments. Updates immediately on submit.
16
+ 3. Clean, friendly styling, one accent color. Prefill nothing; show a small empty
17
+ state ("Be the first to leave feedback").
18
+
19
+ ## Finish
20
+
21
+ After writing the files: call **`launch_preview`** `{ "dir": "feedback-form" }`,
22
+ then **`record_use`** with recipe id `customer-feedback-form` and a short summary,
23
+ then tell the user it's live in the preview (briefly).
@@ -1,24 +1,24 @@
1
- {
2
- "id": "customer-feedback-form",
3
- "title": "Customer feedback form with instant summary",
4
- "producer": { "name": "Marco", "handle": "marco", "kind": "maker" },
5
- "pitch": "Collect feedback and see the mood at a glance — built in one shot.",
6
- "vendorDescription": "Marco's feedback widget. A clean form (rating + comment) plus a live tally so you see how people feel without opening a spreadsheet.",
7
- "summary": "A single page: a star rating, a comment box, and a running summary (average score + recent comments). Stores responses locally for the demo.",
8
- "outcomeScore": 0.86,
9
- "outcomeStats": { "builds": 29, "working": 25 },
10
- "safetyBadge": "verified",
11
- "rating": { "stars": 4.5, "count": 19 },
12
- "tags": [
13
- "feedback", "customer feedback", "survey", "form", "nps", "rating",
14
- "review", "reviews", "questionnaire", "poll", "satisfaction", "comments"
15
- ],
16
- "reward": {
17
- "model": "one-time",
18
- "unit": "per build",
19
- "perUseValue": 0,
20
- "oneTimeValue": 5.0,
21
- "note": "Marco earns a one-time share each time his feedback-form know-how is used."
22
- },
23
- "buildDir": "feedback-form"
24
- }
1
+ {
2
+ "id": "customer-feedback-form",
3
+ "title": "Customer feedback form with instant summary",
4
+ "producer": { "name": "Marco", "handle": "marco", "kind": "maker" },
5
+ "pitch": "Collect feedback and see the mood at a glance — built in one shot.",
6
+ "vendorDescription": "Marco's feedback widget. A clean form (rating + comment) plus a live tally so you see how people feel without opening a spreadsheet.",
7
+ "summary": "A single page: a star rating, a comment box, and a running summary (average score + recent comments). Stores responses locally for the demo.",
8
+ "outcomeScore": 0.86,
9
+ "outcomeStats": { "builds": 29, "working": 25 },
10
+ "safetyBadge": "verified",
11
+ "rating": { "stars": 4.5, "count": 19 },
12
+ "tags": [
13
+ "feedback", "customer feedback", "survey", "form", "nps", "rating",
14
+ "review", "reviews", "questionnaire", "poll", "satisfaction", "comments"
15
+ ],
16
+ "reward": {
17
+ "model": "one-time",
18
+ "unit": "per build",
19
+ "perUseValue": 0,
20
+ "oneTimeValue": 5.0,
21
+ "note": "Marco earns a one-time share each time his feedback-form know-how is used."
22
+ },
23
+ "buildDir": "feedback-form"
24
+ }
@@ -1,29 +1,29 @@
1
- # Lina's launch page — how to build it
2
-
3
- > Absorb this and build the user a one-page launch site in one shot. Single
4
- > self-contained `index.html` (inline CSS + JS), no build step.
5
-
6
- ## Build steps
7
-
8
- 1. Create `landing-page/index.html`.
9
- 2. Sections, top to bottom:
10
- - **Hero**: a big headline, a one-line subhead, and a primary call-to-action
11
- button. Use a soft gradient background. Prefill with the user's product if
12
- you know it; otherwise a believable placeholder they can edit.
13
- - **Three feature blocks**: each an icon (an emoji is fine), a short title, and
14
- one sentence.
15
- - **Social proof**: a single line of fake-but-tasteful stats (e.g. "Loved by
16
- 800+ early users").
17
- - **Email waitlist**: an email input + "Join the waitlist" button. On submit,
18
- store the address in `localStorage` and show a "You're on the list 🎉"
19
- confirmation. (No backend needed for v1.)
20
- 3. House style: modern, generous whitespace, system font stack, one accent color.
21
- Make it look like a real launch, not a template.
22
-
23
- ## Finish
24
-
25
- After writing the files:
26
-
27
- 1. Call **`launch_preview`** with `{ "dir": "landing-page" }`.
28
- 2. Call **`record_use`** with recipe id `landing-page-launch` and a short summary.
29
- 3. Tell the user it's live in the preview, in one or two short sentences.
1
+ # Lina's launch page — how to build it
2
+
3
+ > Absorb this and build the user a one-page launch site in one shot. Single
4
+ > self-contained `index.html` (inline CSS + JS), no build step.
5
+
6
+ ## Build steps
7
+
8
+ 1. Create `landing-page/index.html`.
9
+ 2. Sections, top to bottom:
10
+ - **Hero**: a big headline, a one-line subhead, and a primary call-to-action
11
+ button. Use a soft gradient background. Prefill with the user's product if
12
+ you know it; otherwise a believable placeholder they can edit.
13
+ - **Three feature blocks**: each an icon (an emoji is fine), a short title, and
14
+ one sentence.
15
+ - **Social proof**: a single line of fake-but-tasteful stats (e.g. "Loved by
16
+ 800+ early users").
17
+ - **Email waitlist**: an email input + "Join the waitlist" button. On submit,
18
+ store the address in `localStorage` and show a "You're on the list 🎉"
19
+ confirmation. (No backend needed for v1.)
20
+ 3. House style: modern, generous whitespace, system font stack, one accent color.
21
+ Make it look like a real launch, not a template.
22
+
23
+ ## Finish
24
+
25
+ After writing the files:
26
+
27
+ 1. Call **`launch_preview`** with `{ "dir": "landing-page" }`.
28
+ 2. Call **`record_use`** with recipe id `landing-page-launch` and a short summary.
29
+ 3. Tell the user it's live in the preview, in one or two short sentences.
@@ -1,25 +1,25 @@
1
- {
2
- "id": "landing-page-launch",
3
- "title": "Launch a landing page with a waitlist",
4
- "producer": { "name": "Lina", "handle": "lina", "kind": "maker" },
5
- "pitch": "A clean one-page launch site with a hero, features, and an email waitlist — live in one shot.",
6
- "vendorDescription": "Lina's launch page. The exact layout that got her side-project 800 signups in a week: a punchy hero, three benefit blocks, social proof, and an email capture that just works.",
7
- "summary": "A single-page marketing site: hero headline, three feature blocks, and an email signup that collects addresses. No setup. Make it yours by changing the words.",
8
- "outcomeScore": 0.88,
9
- "outcomeStats": { "builds": 51, "working": 45 },
10
- "safetyBadge": "verified",
11
- "rating": { "stars": 4.6, "count": 33 },
12
- "tags": [
13
- "landing page", "landing", "waitlist", "marketing site", "website",
14
- "hero", "signup", "sign up", "coming soon", "product launch", "launch",
15
- "email capture", "newsletter", "homepage", "promo"
16
- ],
17
- "reward": {
18
- "model": "one-time",
19
- "unit": "per build",
20
- "perUseValue": 0,
21
- "oneTimeValue": 6.0,
22
- "note": "Lina earns a one-time share each time her launch page know-how is used to build a site."
23
- },
24
- "buildDir": "landing-page"
25
- }
1
+ {
2
+ "id": "landing-page-launch",
3
+ "title": "Launch a landing page with a waitlist",
4
+ "producer": { "name": "Lina", "handle": "lina", "kind": "maker" },
5
+ "pitch": "A clean one-page launch site with a hero, features, and an email waitlist — live in one shot.",
6
+ "vendorDescription": "Lina's launch page. The exact layout that got her side-project 800 signups in a week: a punchy hero, three benefit blocks, social proof, and an email capture that just works.",
7
+ "summary": "A single-page marketing site: hero headline, three feature blocks, and an email signup that collects addresses. No setup. Make it yours by changing the words.",
8
+ "outcomeScore": 0.88,
9
+ "outcomeStats": { "builds": 51, "working": 45 },
10
+ "safetyBadge": "verified",
11
+ "rating": { "stars": 4.6, "count": 33 },
12
+ "tags": [
13
+ "landing page", "landing", "waitlist", "marketing site", "website",
14
+ "hero", "signup", "sign up", "coming soon", "product launch", "launch",
15
+ "email capture", "newsletter", "homepage", "promo"
16
+ ],
17
+ "reward": {
18
+ "model": "one-time",
19
+ "unit": "per build",
20
+ "perUseValue": 0,
21
+ "oneTimeValue": 6.0,
22
+ "note": "Lina earns a one-time share each time her launch page know-how is used to build a site."
23
+ },
24
+ "buildDir": "landing-page"
25
+ }
@@ -1,21 +1,21 @@
1
- # Nia's portfolio — how to build it
2
-
3
- > Absorb and build in one shot. Single self-contained `portfolio/index.html`
4
- > (inline CSS + JS), no build step.
5
-
6
- ## Build steps
7
-
8
- 1. Create `portfolio/index.html`.
9
- 2. Sections:
10
- - **Hero**: name, a one-line "what I do", and a primary contact button.
11
- - **Projects**: a responsive grid of 3–6 cards (title, one-line description, a
12
- tag or two). Use placeholder content the user can edit.
13
- - **Contact**: an email line + simple links (placeholder).
14
- 3. House style: confident, lots of whitespace, one accent color, a tasteful hover
15
- on the project cards. Make it look hireable.
16
-
17
- ## Finish
18
-
19
- After writing the files: call **`launch_preview`** `{ "dir": "portfolio" }`, then
20
- **`record_use`** with recipe id `personal-portfolio` and a short summary, then tell
21
- the user it's live (briefly).
1
+ # Nia's portfolio — how to build it
2
+
3
+ > Absorb and build in one shot. Single self-contained `portfolio/index.html`
4
+ > (inline CSS + JS), no build step.
5
+
6
+ ## Build steps
7
+
8
+ 1. Create `portfolio/index.html`.
9
+ 2. Sections:
10
+ - **Hero**: name, a one-line "what I do", and a primary contact button.
11
+ - **Projects**: a responsive grid of 3–6 cards (title, one-line description, a
12
+ tag or two). Use placeholder content the user can edit.
13
+ - **Contact**: an email line + simple links (placeholder).
14
+ 3. House style: confident, lots of whitespace, one accent color, a tasteful hover
15
+ on the project cards. Make it look hireable.
16
+
17
+ ## Finish
18
+
19
+ After writing the files: call **`launch_preview`** `{ "dir": "portfolio" }`, then
20
+ **`record_use`** with recipe id `personal-portfolio` and a short summary, then tell
21
+ the user it's live (briefly).