@dreki-gg/pi-plan-mode 0.15.0 → 0.16.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 (31) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
  3. package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
  4. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  5. package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
  6. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
  7. package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
  8. package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
  9. package/extensions/plan-mode/__tests__/types.test.ts +3 -1
  10. package/extensions/plan-mode/__tests__/utils.test.ts +3 -1
  11. package/extensions/plan-mode/constants.ts +1 -0
  12. package/extensions/plan-mode/context-filter.ts +1 -4
  13. package/extensions/plan-mode/html/render.ts +10 -97
  14. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  15. package/extensions/plan-mode/index.ts +78 -17
  16. package/extensions/plan-mode/phase-transitions.ts +9 -5
  17. package/extensions/plan-mode/plan-storage.ts +6 -1
  18. package/extensions/plan-mode/prompts.ts +5 -6
  19. package/extensions/plan-mode/resume.ts +20 -5
  20. package/extensions/plan-mode/state.ts +1 -3
  21. package/extensions/plan-mode/storage/atomic-write.ts +5 -1
  22. package/extensions/plan-mode/storage/plan-storage.ts +3 -1
  23. package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
  24. package/extensions/plan-mode/storage/task-storage.ts +29 -6
  25. package/extensions/plan-mode/tools/preview-prototype.ts +91 -0
  26. package/extensions/plan-mode/tools/submit-plan.ts +33 -13
  27. package/extensions/plan-mode/tools/update-task.ts +51 -11
  28. package/package.json +1 -1
  29. package/skills/planning-context/SKILL.md +42 -0
  30. package/skills/visual-prototype/SKILL.md +45 -0
  31. package/extensions/plan-mode/html/templates/plan.pug +0 -69
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5c70b28: Reframe plan-mode HTML as a planning-phase visual aid instead of a finalization receipt.
8
+
9
+ - `submit_plan` no longer generates `plan.html`. It writes only `tasks.jsonl`, `HANDOFF.md`, and the manifest entry. The previous HTML duplicated the handoff and task list (already tracked elsewhere) and was never opened.
10
+ - Added a `preview_prototype` tool, available during planning. It renders self-contained Pug to a standalone HTML visual aid under `.plans/_prototypes/`, opens it, and notifies the path — so the user can react to a UI/component/style design _before_ the plan hardens.
11
+ - Added a bundled `visual-prototype` skill that routes UI/component/layout/style planning work to `preview_prototype` before `submit_plan`.
12
+ - Added a bundled `planning-context` skill that drives the living `context.md` deliberation discipline (intent, decisions, constraints, open questions, discarded options).
13
+
14
+ ## 0.15.1
15
+
16
+ ### Patch Changes
17
+
18
+ - Fix execution stopping after the final task is marked done. `update_task` no longer terminates the turn merely because the task queue is empty, so the agent can run its closing summary / validation pass before the `agent_end` completion handler takes over. The `blocked` branch still terminates to pause for user input.
19
+
3
20
  ## 0.15.0
4
21
 
5
22
  ### Minor Changes
@@ -102,9 +102,9 @@ describe('agent_end handler safety', () => {
102
102
  if (violations.length > 0) {
103
103
  throw new Error(
104
104
  `Found sendUserMessage calls inside agent_end without deliverAs.\n` +
105
- `The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
106
- `Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
107
- `Fix: add { deliverAs: 'followUp' } to each call.`,
105
+ `The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
106
+ `Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
107
+ `Fix: add { deliverAs: 'followUp' } to each call.`,
108
108
  );
109
109
  }
110
110
  });
@@ -1,43 +1,34 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { renderPlanHtml } from '../html/render.js';
3
- import type { PlanData } from '../types.js';
4
-
5
- const plan: PlanData = {
6
- title: 'Refactor Auth',
7
- planName: 'refactor-auth',
8
- handoff: '# Handoff\nShip carefully.',
9
- tasks: [
10
- {
11
- _type: 'task',
12
- id: 't-001',
13
- description: 'Add auth middleware',
14
- details: 'Implement the middleware.',
15
- status: 'pending',
16
- created_at: '2026-05-27T12:00:00.000Z',
17
- updated_at: '2026-05-27T12:00:00.000Z',
18
- },
19
- ],
20
- };
21
-
22
- describe('renderPlanHtml', () => {
23
- test('renders title, task count, handoff, and tasks', () => {
24
- const html = renderPlanHtml(plan);
25
-
26
- expect(html).toContain('Refactor Auth');
27
- expect(html).toContain('1 task');
28
- expect(html).toContain('Ship carefully.');
29
- expect(html).toContain('t-001');
30
- expect(html).toContain('Add auth middleware');
2
+ import { renderPrototypeHtml } from '../html/render.js';
3
+
4
+ describe('renderPrototypeHtml', () => {
5
+ test('renders title and intent in a minimal header', () => {
6
+ const html = renderPrototypeHtml('Sidebar redesign', 'Left-aligned nav with icons', '.nav Nav');
7
+
8
+ expect(html).toContain('Sidebar redesign');
9
+ expect(html).toContain('Left-aligned nav with icons');
10
+ expect(html).toContain('Prototype');
11
+ });
12
+
13
+ test('renders the Pug body markup', () => {
14
+ const html = renderPrototypeHtml('Card', 'A product card', '.card Product card');
15
+
16
+ expect(html).toContain('class="card"');
17
+ expect(html).toContain('Product card');
31
18
  });
32
19
 
33
- test('omits prototypes section when no prototype is provided', () => {
34
- expect(renderPlanHtml(plan)).not.toContain('Prototype');
20
+ test('does not render tasks or handoff sections', () => {
21
+ const html = renderPrototypeHtml('Card', 'A product card', '.card Hello');
22
+
23
+ expect(html).not.toContain('Handoff');
24
+ expect(html).not.toContain('Tasks');
25
+ expect(html).not.toContain('Depends on');
35
26
  });
36
27
 
37
- test('renders optional prototype pug markup', () => {
38
- const html = renderPlanHtml(plan, '.mockup Prototype card');
28
+ test('handles an empty prototype body without throwing', () => {
29
+ const html = renderPrototypeHtml('Empty', 'Nothing yet', '');
39
30
 
40
- expect(html).toContain('Prototype');
41
- expect(html).toContain('Prototype card');
31
+ expect(html).toContain('Empty');
32
+ expect(html).toContain('Nothing yet');
42
33
  });
43
34
  });
@@ -45,3 +45,54 @@ describe('bundled technical-options skill', () => {
45
45
  expect(content).toMatch(/challenge.*framing|framing.*challenge/i);
46
46
  });
47
47
  });
48
+
49
+ describe('bundled visual-prototype skill', () => {
50
+ const skillDir = join(PKG_ROOT, 'skills', 'visual-prototype');
51
+ const skillFile = join(skillDir, 'SKILL.md');
52
+
53
+ test('SKILL.md exists', () => {
54
+ expect(existsSync(skillFile)).toBe(true);
55
+ });
56
+
57
+ test('has valid frontmatter with name and description', () => {
58
+ const content = readFileSync(skillFile, 'utf-8');
59
+ expect(content).toMatch(/^---\n/);
60
+ expect(content).toMatch(/name:\s*visual-prototype/);
61
+ expect(content).toMatch(/description:/);
62
+ });
63
+
64
+ test('routes on visual work, not backend-only', () => {
65
+ const content = readFileSync(skillFile, 'utf-8');
66
+ expect(content).toMatch(/description:.*\b(UI|component|layout|style)\b/i);
67
+ expect(content).toMatch(/not for.*backend|backend-only/i);
68
+ });
69
+
70
+ test('directs use of preview_prototype before submit_plan', () => {
71
+ const content = readFileSync(skillFile, 'utf-8');
72
+ expect(content).toContain('preview_prototype');
73
+ expect(content).toContain('submit_plan');
74
+ });
75
+ });
76
+
77
+ describe('bundled planning-context skill', () => {
78
+ const skillDir = join(PKG_ROOT, 'skills', 'planning-context');
79
+ const skillFile = join(skillDir, 'SKILL.md');
80
+
81
+ test('SKILL.md exists', () => {
82
+ expect(existsSync(skillFile)).toBe(true);
83
+ });
84
+
85
+ test('has valid frontmatter with name and description', () => {
86
+ const content = readFileSync(skillFile, 'utf-8');
87
+ expect(content).toMatch(/^---\n/);
88
+ expect(content).toMatch(/name:\s*planning-context/);
89
+ expect(content).toMatch(/description:/);
90
+ });
91
+
92
+ test('covers context.md deliberation sections', () => {
93
+ const content = readFileSync(skillFile, 'utf-8');
94
+ expect(content).toContain('context.md');
95
+ expect(content).toMatch(/discarded options/i);
96
+ expect(content).toMatch(/open questions/i);
97
+ });
98
+ });
@@ -4,10 +4,21 @@ import type { PlanData, TaskRecord } from '../types.js';
4
4
 
5
5
  function makePlan(overrides?: Partial<PlanData>): PlanData {
6
6
  const task: TaskRecord = {
7
- _type: 'task', id: 't-001', description: 'Do work', details: 'Details',
8
- status: 'pending', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
7
+ _type: 'task',
8
+ id: 't-001',
9
+ description: 'Do work',
10
+ details: 'Details',
11
+ status: 'pending',
12
+ created_at: '2026-01-01T00:00:00Z',
13
+ updated_at: '2026-01-01T00:00:00Z',
14
+ };
15
+ return {
16
+ title: 'Test Plan',
17
+ planName: 'test-plan',
18
+ handoff: '# Handoff',
19
+ tasks: [task],
20
+ ...overrides,
9
21
  };
10
- return { title: 'Test Plan', planName: 'test-plan', handoff: '# Handoff', tasks: [task], ...overrides };
11
22
  }
12
23
 
13
24
  describe('PlanModeState', () => {
@@ -3,17 +3,34 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { readPlansManifest, upsertPlanEntry, writePlansManifest } from '../storage/plans-manifest.js';
6
+ import {
7
+ readPlansManifest,
8
+ upsertPlanEntry,
9
+ writePlansManifest,
10
+ } from '../storage/plans-manifest.js';
7
11
 
8
12
  const originalCwd = process.cwd();
9
13
  let dir: string;
10
14
 
11
- beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-')); chdir(dir); });
12
- afterEach(async () => { chdir(originalCwd); await rm(dir, { recursive: true, force: true }); });
15
+ beforeEach(async () => {
16
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-'));
17
+ chdir(dir);
18
+ });
19
+ afterEach(async () => {
20
+ chdir(originalCwd);
21
+ await rm(dir, { recursive: true, force: true });
22
+ });
13
23
 
14
24
  describe('plans.jsonl manifest', () => {
15
25
  test('round trips entries', async () => {
16
- const entry = { _type: 'plan' as const, name: 'refactor', status: 'in-progress' as const, title: 'Refactor', created_at: 'now', completed_at: null };
26
+ const entry = {
27
+ _type: 'plan' as const,
28
+ name: 'refactor',
29
+ status: 'in-progress' as const,
30
+ title: 'Refactor',
31
+ created_at: 'now',
32
+ completed_at: null,
33
+ };
17
34
  await writePlansManifest([entry]);
18
35
  await expect(readPlansManifest()).resolves.toEqual([entry]);
19
36
  });
@@ -27,7 +44,16 @@ describe('plans.jsonl manifest', () => {
27
44
  });
28
45
 
29
46
  test('upserts existing entries without changing created_at', async () => {
30
- await writePlansManifest([{ _type: 'plan', name: 'p', status: 'in-progress', title: 'Old', created_at: 'created', completed_at: null }]);
47
+ await writePlansManifest([
48
+ {
49
+ _type: 'plan',
50
+ name: 'p',
51
+ status: 'in-progress',
52
+ title: 'Old',
53
+ created_at: 'created',
54
+ completed_at: null,
55
+ },
56
+ ]);
31
57
  await upsertPlanEntry('p', { status: 'done', title: 'New' });
32
58
  const [entry] = await readPlansManifest();
33
59
  expect(entry.created_at).toBe('created');
@@ -5,7 +5,15 @@ import type { PlanData, TaskRecord } from '../types.js';
5
5
 
6
6
  const now = '2026-01-01T00:00:00Z';
7
7
  function makeTask(overrides?: Partial<TaskRecord>): TaskRecord {
8
- return { _type: 'task', id: 't-001', description: 'Do work', status: 'pending', created_at: now, updated_at: now, ...overrides };
8
+ return {
9
+ _type: 'task',
10
+ id: 't-001',
11
+ description: 'Do work',
12
+ status: 'pending',
13
+ created_at: now,
14
+ updated_at: now,
15
+ ...overrides,
16
+ };
9
17
  }
10
18
 
11
19
  describe('buildPlanModePrompt', () => {
@@ -54,13 +62,23 @@ describe('buildExecutionPrompt', () => {
54
62
  });
55
63
 
56
64
  test('includes Details line when task has details', () => {
57
- const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ details: 'Full instructions here' })] };
65
+ const plan: PlanData = {
66
+ title: 'Test',
67
+ planName: 'test',
68
+ handoff: '# H',
69
+ tasks: [makeTask({ details: 'Full instructions here' })],
70
+ };
58
71
  const prompt = buildExecutionPrompt(plan)!;
59
72
  expect(prompt).toContain('Details: Full instructions here');
60
73
  });
61
74
 
62
75
  test('returns undefined when no pending tasks', () => {
63
- const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ status: 'done' })] };
76
+ const plan: PlanData = {
77
+ title: 'Test',
78
+ planName: 'test',
79
+ handoff: '# H',
80
+ tasks: [makeTask({ status: 'done' })],
81
+ };
64
82
  expect(buildExecutionPrompt(plan)).toBeUndefined();
65
83
  });
66
84
  });
@@ -8,10 +8,22 @@ import type { TaskMeta, TaskRecord } from '../types.js';
8
8
  let dir: string;
9
9
  const now = '2026-05-27T12:00:00.000Z';
10
10
  const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
11
- const task: TaskRecord = { _type: 'task', id: 't-001', description: 'Do work', details: 'Details', status: 'pending', created_at: now, updated_at: now };
11
+ const task: TaskRecord = {
12
+ _type: 'task',
13
+ id: 't-001',
14
+ description: 'Do work',
15
+ details: 'Details',
16
+ status: 'pending',
17
+ created_at: now,
18
+ updated_at: now,
19
+ };
12
20
 
13
- beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-')); });
14
- afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
21
+ beforeEach(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
23
+ });
24
+ afterEach(async () => {
25
+ await rm(dir, { recursive: true, force: true });
26
+ });
15
27
 
16
28
  describe('tasks.jsonl storage', () => {
17
29
  test('round trips meta and tasks', async () => {
@@ -20,7 +32,14 @@ describe('tasks.jsonl storage', () => {
20
32
  });
21
33
 
22
34
  test('round trips tasks without details (lightweight checklist)', async () => {
23
- const lightweight: TaskRecord = { _type: 'task', id: 't-002', description: 'Quick fix', status: 'pending', created_at: now, updated_at: now };
35
+ const lightweight: TaskRecord = {
36
+ _type: 'task',
37
+ id: 't-002',
38
+ description: 'Quick fix',
39
+ status: 'pending',
40
+ created_at: now,
41
+ updated_at: now,
42
+ };
24
43
  await writeTasksJsonl(dir, meta, [lightweight]);
25
44
  const result = await readTasksJsonl(dir);
26
45
  expect(result?.tasks[0]?.id).toBe('t-002');
@@ -63,6 +63,8 @@ describe('task meta type guard', () => {
63
63
 
64
64
  test('rejects malformed meta records', () => {
65
65
  expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
66
- expect(isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now })).toBe(false);
66
+ expect(
67
+ isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now }),
68
+ ).toBe(false);
67
69
  });
68
70
  });
@@ -14,7 +14,9 @@ describe('isSafeCommand', () => {
14
14
 
15
15
  test('command with 2>/dev/null stderr redirect', () => {
16
16
  expect(
17
- isSafeCommand('cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null'),
17
+ isSafeCommand(
18
+ 'cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null',
19
+ ),
18
20
  ).toBe(true);
19
21
  });
20
22
 
@@ -10,6 +10,7 @@ export const PLAN_TOOLS = [
10
10
  'find',
11
11
  'ls',
12
12
  'submit_plan',
13
+ 'preview_prototype',
13
14
  'write',
14
15
  'questionnaire',
15
16
  'search_skills',
@@ -4,10 +4,7 @@
4
4
  */
5
5
 
6
6
  /** Drop everything before the execution start index. */
7
- export function filterExecutionMessages<T>(
8
- messages: T[],
9
- executionStartIdx: number,
10
- ): T[] {
7
+ export function filterExecutionMessages<T>(messages: T[], executionStartIdx: number): T[] {
11
8
  return messages.filter((_m, i) => i >= executionStartIdx);
12
9
  }
13
10
 
@@ -2,110 +2,23 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import pug from 'pug';
5
- import type { PlanData } from '../types.js';
6
5
 
7
- const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'plan.pug');
6
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'prototype.pug');
8
7
 
9
- export function renderPlanHtml(plan: PlanData, prototype?: string): string {
8
+ /**
9
+ * Renders a standalone prototype preview: a minimal header (title + one-line
10
+ * intent) wrapping the rendered Pug body. This is a planning-phase visual aid,
11
+ * not a plan dump — no tasks, no handoff.
12
+ */
13
+ export function renderPrototypeHtml(title: string, intent: string, pugBody: string): string {
10
14
  const template = readFileSync(templatePath, 'utf8');
11
- const prototypeHtml = prototype?.trim() ? pug.render(prototype) : undefined;
15
+ const prototypeHtml = pugBody.trim() ? pug.render(pugBody) : '';
12
16
 
13
17
  return pug.render(template, {
14
18
  filename: templatePath,
15
- plan,
19
+ title,
20
+ intent,
16
21
  prototypeHtml,
17
- handoffHtml: markdownToHtml(plan.handoff),
18
- taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
19
22
  generatedAt: new Date().toISOString(),
20
23
  });
21
24
  }
22
-
23
- function markdownToHtml(markdown: string): string {
24
- const lines = markdown.split(/\r?\n/);
25
- const html: string[] = [];
26
- let inList = false;
27
- let inCode = false;
28
- let codeLang = '';
29
- const codeLines: string[] = [];
30
-
31
- for (const line of lines) {
32
- // Fenced code blocks
33
- if (line.startsWith('```')) {
34
- if (!inCode) {
35
- if (inList) { html.push('</ul>'); inList = false; }
36
- inCode = true;
37
- codeLang = line.slice(3).trim();
38
- codeLines.length = 0;
39
- } else {
40
- const langAttr = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : '';
41
- html.push(`<pre><code${langAttr}>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
42
- inCode = false;
43
- codeLang = '';
44
- }
45
- continue;
46
- }
47
-
48
- if (inCode) {
49
- codeLines.push(line);
50
- continue;
51
- }
52
-
53
- // Headings
54
- const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
55
- if (headingMatch) {
56
- if (inList) { html.push('</ul>'); inList = false; }
57
- const level = headingMatch[1].length;
58
- html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
59
- continue;
60
- }
61
-
62
- // List items (- or *)
63
- const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
64
- if (listMatch) {
65
- if (!inList) { html.push('<ul>'); inList = true; }
66
- html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
67
- continue;
68
- }
69
-
70
- // Blank line
71
- if (!line.trim()) {
72
- if (inList) { html.push('</ul>'); inList = false; }
73
- continue;
74
- }
75
-
76
- // Paragraph
77
- if (inList) { html.push('</ul>'); inList = false; }
78
- html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
79
- }
80
-
81
- // Close unclosed blocks
82
- if (inCode) {
83
- html.push(`<pre><code>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
84
- }
85
- if (inList) html.push('</ul>');
86
- return html.join('\n');
87
- }
88
-
89
- /** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
90
- function inlineMarkdown(text: string): string {
91
- return text
92
- // Inline code: `code`
93
- .replace(/`([^`]+)`/g, '<code>$1</code>')
94
- // Bold: **text** or __text__
95
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
96
- .replace(/__(.+?)__/g, '<strong>$1</strong>')
97
- // Italic: *text* or _text_
98
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
99
- .replace(/_(.+?)_/g, '<em>$1</em>')
100
- // Links: [text](url)
101
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
102
- }
103
-
104
- function escapeHtml(value: string): string {
105
- return value
106
- .replaceAll('&', '&amp;')
107
- .replaceAll('<', '&lt;')
108
- .replaceAll('>', '&gt;')
109
- .replaceAll('"', '&quot;')
110
- .replaceAll("'", '&#39;');
111
- }
@@ -0,0 +1,25 @@
1
+ doctype html
2
+ html(lang="en")
3
+ head
4
+ meta(charset="utf-8")
5
+ meta(name="viewport" content="width=device-width, initial-scale=1")
6
+ title= title
7
+ style.
8
+ :root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; }
9
+ * { box-sizing: border-box; }
10
+ body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
11
+ main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
12
+ header { margin-bottom:24px; }
13
+ h1 { font-size:30px; line-height:1.1; margin:0 0 8px; letter-spacing:-0.04em; }
14
+ .intent { color:var(--muted); margin:0; }
15
+ .badge { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; background:rgba(255,255,255,0.03); color:var(--muted); font-size:12px; margin-bottom:14px; }
16
+ .prototype { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
17
+ body
18
+ main
19
+ header
20
+ span.badge Prototype · #{generatedAt}
21
+ h1= title
22
+ if intent
23
+ p.intent= intent
24
+ section.prototype
25
+ != prototypeHtml